diff --git a/hedera-node/configuration/dev/keys/disk-gossip-key.pass b/hedera-node/configuration/dev/keys/disk-gossip-key.pass
new file mode 100644
index 000000000000..fc80254b619d
--- /dev/null
+++ b/hedera-node/configuration/dev/keys/disk-gossip-key.pass
@@ -0,0 +1 @@
+pass
\ No newline at end of file
diff --git a/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/keys/Ed25519Utils.java b/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/keys/Ed25519Utils.java
index beae25e1273a..f8152b01a667 100644
--- a/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/keys/Ed25519Utils.java
+++ b/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/keys/Ed25519Utils.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2022-2024 Hedera Hashgraph, LLC
+ * Copyright (C) 2022-2025 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -53,7 +53,7 @@
*/
public final class Ed25519Utils {
private static final int ENCRYPTOR_ITERATION_COUNT = 10_000;
- private static final Provider BC_PROVIDER = new BouncyCastleProvider();
+ public static final Provider BC_PROVIDER = new BouncyCastleProvider();
private static final Provider ED_PROVIDER = new EdDSASecurityProvider();
private static final String RESOURCE_PATH_SEGMENT = "src/main/resource";
diff --git a/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/keys/RSAUtils.java b/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/keys/RSAUtils.java
new file mode 100644
index 000000000000..a1a986085868
--- /dev/null
+++ b/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/keys/RSAUtils.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2025 Hedera Hashgraph, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.hedera.node.app.hapi.utils.keys;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import java.io.ByteArrayInputStream;
+import java.io.FileReader;
+import java.math.BigInteger;
+import java.nio.file.Path;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.spec.RSAPublicKeySpec;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.openssl.PEMDecryptorProvider;
+import org.bouncycastle.openssl.PEMEncryptedKeyPair;
+import org.bouncycastle.openssl.PEMKeyPair;
+import org.bouncycastle.openssl.PEMParser;
+import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
+import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.bouncycastle.util.io.pem.PemObject;
+import org.bouncycastle.util.io.pem.PemReader;
+
+public class RSAUtils {
+ public static final String SHA_384_WITH_RSA = "SHA384withRSA";
+ public static final String RSA = "RSA";
+ public static final String X_509 = "X.509";
+
+ public static X509Certificate generateCertificate(@NonNull final RSAPrivateKey privateKey, final int nodeId)
+ throws Exception {
+ // Generate the public key from the private key
+ final RSAPublicKeySpec publicKeySpec =
+ new RSAPublicKeySpec(privateKey.getModulus(), privateKey.getPrivateExponent());
+ final KeyFactory keyFactory = KeyFactory.getInstance(RSA);
+ final PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
+
+ // Set certificate details
+ final X500Name issuer = new X500Name("CN=s-node" + nodeId);
+ final X500Name subject = new X500Name("CN=s-node" + nodeId);
+ // The following values are constant so that we can verify the certificate in tests
+ final var seed = System.currentTimeMillis();
+ final BigInteger serial = BigInteger.valueOf(seed);
+ final Date notBefore = new Date(seed - 1000L);
+ final LocalDateTime plus100yrs = LocalDateTime.now().plusYears(100);
+ final Date notAfter =
+ Date.from(plus100yrs.atZone(ZoneId.systemDefault()).toInstant());
+
+ // Create the certificate
+ final X509v3CertificateBuilder certBuilder =
+ new JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, publicKey);
+
+ final ContentSigner signer = new JcaContentSignerBuilder(SHA_384_WITH_RSA).build(privateKey);
+ return new JcaX509CertificateConverter()
+ .setProvider(Ed25519Utils.BC_PROVIDER)
+ .getCertificate(certBuilder.build(signer));
+ }
+
+ public static X509Certificate parseCertificate(String pemFilePath) throws Exception {
+ try (PemReader pemReader = new PemReader(new FileReader(pemFilePath))) {
+ final PemObject pemObject = pemReader.readPemObject();
+ return parseCertificate(pemObject.getContent());
+ }
+ }
+
+ public static X509Certificate parseCertificate(@NonNull final byte[] certBytes) throws Exception {
+ final CertificateFactory certFactory = CertificateFactory.getInstance(X_509, Ed25519Utils.BC_PROVIDER);
+ return (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes));
+ }
+
+ public static RSAPrivateKey loadPrivateKey(@NonNull final String pemFilePath, @NonNull final String pass)
+ throws Exception {
+ try (PEMParser pemParser = new PEMParser(new FileReader(pemFilePath))) {
+ final Object object = pemParser.readObject();
+ PEMDecryptorProvider decryptorProvider = new JcePEMDecryptorProviderBuilder().build(pass.toCharArray());
+ final JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider(Ed25519Utils.BC_PROVIDER);
+
+ PrivateKey privateKey;
+
+ if (object instanceof PEMEncryptedKeyPair encryptedKeyPair) {
+ PEMKeyPair keyPair = encryptedKeyPair.decryptKeyPair(decryptorProvider);
+ privateKey = converter.getPrivateKey(keyPair.getPrivateKeyInfo());
+ } else if (object instanceof PEMKeyPair keyPair) {
+ privateKey = converter.getPrivateKey(keyPair.getPrivateKeyInfo());
+ } else if (object instanceof PrivateKeyInfo) {
+ privateKey = converter.getPrivateKey((PrivateKeyInfo) object);
+ } else {
+ throw new IllegalArgumentException("Unsupported key format");
+ }
+
+ if (privateKey instanceof RSAPrivateKey) {
+ return (RSAPrivateKey) privateKey;
+ } else {
+ throw new IllegalArgumentException("Not an RSAPrivateKey");
+ }
+ }
+ }
+
+ public static long parseIdFromPemLoc(@NonNull final Path pemLoc) {
+ final var pemFilename = pemLoc.getFileName().toString();
+ return Long.parseLong(
+ pemFilename.replace("account", "").replace("s-public-node", "").replace(".pem", ""));
+ }
+}
diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/info/DiskStartupNetworks.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/info/DiskStartupNetworks.java
index fef99355a811..6dd2bc8fc3ee 100644
--- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/info/DiskStartupNetworks.java
+++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/info/DiskStartupNetworks.java
@@ -17,15 +17,22 @@
package com.hedera.node.app.info;
import static com.hedera.hapi.util.HapiUtils.parseAccount;
-import static com.swirlds.platform.builder.PlatformBuildConstants.DEFAULT_CONFIG_FILE_NAME;
+import static com.hedera.node.app.hapi.utils.CommonPbjConverters.fromByteString;
+import static com.hedera.node.app.hapi.utils.keys.RSAUtils.parseIdFromPemLoc;
import static com.swirlds.platform.roster.RosterRetriever.buildRoster;
import static java.util.Objects.requireNonNull;
+import com.google.protobuf.ByteString;
+import com.hedera.hapi.node.base.AccountID;
import com.hedera.hapi.node.base.Key;
+import com.hedera.hapi.node.base.ServiceEndpoint;
import com.hedera.hapi.node.state.addressbook.Node;
+import com.hedera.hapi.node.state.roster.RosterEntry;
+import com.hedera.node.app.hapi.utils.keys.RSAUtils;
import com.hedera.node.app.service.addressbook.AddressBookService;
import com.hedera.node.app.service.addressbook.impl.ReadableNodeStoreImpl;
import com.hedera.node.config.ConfigProvider;
+import com.hedera.node.config.data.BootstrapConfig;
import com.hedera.node.config.data.NetworkAdminConfig;
import com.hedera.node.internal.network.Network;
import com.hedera.node.internal.network.NodeMetadata;
@@ -42,11 +49,18 @@
import com.swirlds.state.lifecycle.StartupNetworks;
import edu.umd.cs.findbugs.annotations.NonNull;
import java.io.IOException;
+import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
@@ -59,7 +73,7 @@
* working directory on disk.
*/
public class DiskStartupNetworks implements StartupNetworks {
- private static final Logger log = LogManager.getLogger(DiskStartupNetworks.class);
+ static final Logger log = LogManager.getLogger(DiskStartupNetworks.class);
public static final String ARCHIVE = ".archive";
public static final String GENESIS_NETWORK_JSON = "genesis-network.json";
@@ -87,6 +101,199 @@ private enum AssetUse {
MIGRATION,
}
+ // (FUTURE) 🔥🔥 Once the roster lifecycle is fully implemented, we can remove the dependency on the config.txt
+ // file. We should be able to delete this private class in its entirety. Let's make this a big eyesore so we remove
+ // it as soon as possible 🔥🔥
+ private static class ConfigTxtUtils {
+ private ConfigTxtUtils() {
+ // utility class
+ }
+
+ /**
+ * Creates a network from the given config.txt file. Since config.txt alone is not
+ * sufficient to fill out all the data needed in each roster entry, this code will also perform
+ * two more steps: 1) attempt to read the existing public certificates for all other nodes
+ * from disk, and 2) use this node's RSA key on disk, if present, to generate a certificate
+ * for this node.
+ *
+ * @param configTxt the contents of the config.txt file
+ * @param config the application's config
+ * @return the network
+ */
+ static Optional networkFromDisk(@NonNull final String configTxt, @NonNull final Configuration config) {
+ requireNonNull(configTxt);
+ requireNonNull(config);
+
+ final var networkCertsPath =
+ config.getConfigData(BootstrapConfig.class).networkCertsPath();
+
+ final Map certs = new HashMap<>();
+ try {
+ certs.putAll(PemCertsLoader.load(Paths.get(networkCertsPath)));
+ } catch (CertificateEncodingException e) {
+ log.warn("Couldn't load node certs", e);
+ }
+ if (certs.isEmpty()) {
+ // There's no point in loading the rest of configTxt if we don't have the certs available
+ return Optional.empty();
+ }
+
+ final var nodeMetadata = Arrays.stream(configTxt.split("\n"))
+ .filter(line -> line.contains("address, "))
+ .map(line -> {
+ final var parts = line.split(", ");
+ final long nodeId = Long.parseLong(parts[1]);
+ final long weight = Long.parseLong(parts[4]);
+ final var gossipEndpoints =
+ List.of(endpointFrom(parts[5], parts[6]), endpointFrom(parts[7], parts[8]));
+ final var nodeAcctId = asAccount(parts[9]);
+ if (!certs.containsKey(nodeId)) {
+ throw new IllegalStateException("Missing cert for node " + nodeId);
+ }
+ final var certBytes = Bytes.wrap(certs.get(nodeId));
+ final var metadata = NodeMetadata.newBuilder()
+ .rosterEntry(new RosterEntry(nodeId, weight, certBytes, gossipEndpoints));
+ metadata.node(new Node(
+ nodeId,
+ nodeAcctId,
+ "node" + (nodeId + 1),
+ gossipEndpoints,
+ List.of(),
+ certBytes,
+ // The gRPC certificate hash is irrelevant for PR checks
+ Bytes.EMPTY,
+ weight,
+ false,
+ null));
+
+ return metadata.build();
+ })
+ .toList();
+ return Optional.of(Network.newBuilder().nodeMetadata(nodeMetadata).build());
+ }
+
+ static ServiceEndpoint asServiceEndpoint(String v) {
+ String[] parts = v.split(":");
+ return ServiceEndpoint.newBuilder()
+ .ipAddressV4(fromByteString(asOctets(parts[0])))
+ .port(Integer.parseInt(parts[1]))
+ .build();
+ }
+
+ static ByteString asOctets(final String ipAddressV4) {
+ final byte[] octets = new byte[4];
+ final String[] literals = ipAddressV4.split("[.]");
+ for (int i = 0; i < 4; i++) {
+ octets[i] = (byte) Integer.parseInt(literals[i]);
+ }
+ return ByteString.copyFrom(octets);
+ }
+
+ static AccountID asAccount(String v) {
+ final long[] nativeParts = asDotDelimitedLongArray(v);
+ return AccountID.newBuilder()
+ .shardNum(nativeParts[0])
+ .realmNum(nativeParts[1])
+ .accountNum(nativeParts[2])
+ .build();
+ }
+
+ static long[] asDotDelimitedLongArray(String s) {
+ final String[] parts = s.split("[.]");
+ return Stream.of(parts).mapToLong(Long::valueOf).toArray();
+ }
+
+ static ServiceEndpoint endpointFrom(@NonNull final String hostLiteral, @NonNull final String portLiteral) {
+ return asServiceEndpoint(hostLiteral + ":" + portLiteral);
+ }
+
+ private static class PemCertsLoader {
+ private PemCertsLoader() {
+ // utility class
+ }
+
+ static Map load(@NonNull final Path certsPath) throws CertificateEncodingException {
+ final var pemCerts = maybeDiskCertFiles(certsPath);
+ final var certsByNodeAcctId = new HashMap();
+ for (final var pemPair : pemCerts.entrySet()) {
+ final var pemAcctId = pemPair.getKey();
+ final var pemLoc = pemPair.getValue();
+ final Optional maybeCert = maybeLoadDiskCert(pemLoc);
+ Optional maybeKey = Optional.empty();
+ if (maybeCert.isEmpty()) {
+ // We'll need to generate the cert for this node using its private key
+ maybeKey = maybeLoadPrivateKey(pemLoc);
+ }
+
+ Optional cert;
+ if (maybeCert.isPresent()) {
+ cert = maybeCert;
+ } else {
+ cert = maybeKey.flatMap(privateKey -> maybeGenerateCert(privateKey, 0));
+ }
+
+ final var certBytes = cert.map(a -> {
+ try {
+ return a.getEncoded();
+ } catch (CertificateEncodingException e) {
+ log.warn("Unable to encode cert", e);
+ }
+
+ return new byte[0];
+ })
+ .get();
+ certsByNodeAcctId.put(pemAcctId, certBytes);
+ }
+
+ return certsByNodeAcctId;
+ }
+
+ private static Map maybeDiskCertFiles(@NonNull final Path certsPath) {
+ final Map certFiles = new HashMap<>();
+ try (final DirectoryStream stream = Files.newDirectoryStream(certsPath, "*.pem")) {
+ for (Path entry : stream) {
+ if (entry.getFileName().toString().contains("s-public-node")) {
+ final var pemAcctId = parseIdFromPemLoc(entry);
+ certFiles.put(pemAcctId, entry);
+ }
+ }
+ } catch (IOException e) {
+ log.error("Error locating pem file(s)", e);
+ }
+
+ return certFiles;
+ }
+
+ private static Optional maybeLoadDiskCert(Path pemLoc) {
+ try {
+ return Optional.of(RSAUtils.parseCertificate(pemLoc.toString()));
+ } catch (final Exception e) {
+ log.warn("Error loading PEM as certificate from {}", pemLoc, e);
+ return Optional.empty();
+ }
+ }
+
+ private static Optional maybeLoadPrivateKey(Path pemLoc) {
+ try {
+ return Optional.of(RSAUtils.loadPrivateKey(pemLoc.toString(), "pass"));
+ } catch (Exception e) {
+ log.warn("Error loading PEM as private key from {}", pemLoc, e);
+ return Optional.empty();
+ }
+ }
+
+ private static Optional maybeGenerateCert(
+ @NonNull final RSAPrivateKey privateKey, final int nodeId) {
+ try {
+ return Optional.of(RSAUtils.generateCertificate(privateKey, nodeId));
+ } catch (Exception e) {
+ log.warn("Unable to generate certificate for node {}", nodeId, e);
+ return Optional.empty();
+ }
+ }
+ }
+ }
+
public DiskStartupNetworks(@NonNull final ConfigProvider configProvider) {
this.configProvider = requireNonNull(configProvider);
}
@@ -95,7 +302,7 @@ public DiskStartupNetworks(@NonNull final ConfigProvider configProvider) {
public Network genesisNetworkOrThrow(@NonNull final Configuration platformConfig) {
requireNonNull(platformConfig);
return loadNetwork(AssetUse.GENESIS, configProvider.getConfiguration(), GENESIS_NETWORK_JSON)
- .or(() -> genesisNetworkFromConfigTxt(platformConfig))
+ .or(() -> fromConfigTxt(platformConfig))
.orElseThrow(() -> new IllegalStateException("Genesis network not found"));
}
@@ -109,7 +316,10 @@ public Optional overrideNetworkFor(final long roundNumber) {
if (unscopedNetwork.isPresent()) {
return unscopedNetwork;
}
- return loadNetwork(AssetUse.OVERRIDE, config, "" + roundNumber, OVERRIDE_NETWORK_JSON);
+
+ return loadNetwork(AssetUse.OVERRIDE, config, "" + roundNumber, OVERRIDE_NETWORK_JSON)
+ .or(() -> fromConfigTxt(config))
+ .or(Optional::empty);
}
@Override
@@ -164,8 +374,8 @@ public void archiveStartupNetworks() {
@Override
public Network migrationNetworkOrThrow() {
- // FUTURE - look into sourcing this from a config.txt and public.pfx to ease migration
return loadNetwork(AssetUse.MIGRATION, configProvider.getConfiguration(), OVERRIDE_NETWORK_JSON)
+ .or(() -> fromConfigTxt(configProvider.getConfiguration()))
.orElseThrow(() -> new IllegalStateException("Transplant network not found"));
}
@@ -294,11 +504,12 @@ public static Optional loadNetworkFrom(@NonNull final Path path) {
* @return the loaded genesis network, if it was found and successfully loaded
*/
@Deprecated(forRemoval = true)
- private Optional genesisNetworkFromConfigTxt(@NonNull final Configuration platformConfig) {
+ private Optional genesisNetworkFromConfigTxt(
+ @NonNull final Configuration platformConfig, @NonNull final Path configTxtPath) {
try {
log.info("No genesis-network.json detected, falling back to config.txt and initNodeSecurity()");
final AddressBook legacyBook;
- final var configFile = LegacyConfigPropertiesLoader.loadConfigFile(Paths.get(DEFAULT_CONFIG_FILE_NAME));
+ final var configFile = LegacyConfigPropertiesLoader.loadConfigFile(configTxtPath);
try {
legacyBook = configFile.getAddressBook();
// Load the public keys into the address book. No private keys should be loaded!
@@ -314,6 +525,15 @@ private Optional genesisNetworkFromConfigTxt(@NonNull final Configurati
}
}
+ private static Optional fromConfigTxt(@NonNull final Configuration config) {
+ final var configTxtPath =
+ Paths.get(config.getConfigData(BootstrapConfig.class).configTxtPath());
+ final var configTxt = LegacyConfigPropertiesLoader.loadConfigFile(configTxtPath)
+ .getAddressBook()
+ .toConfigText();
+ return ConfigTxtUtils.networkFromDisk(configTxt, config);
+ }
+
/**
* Attempts to archive the given segments in the given configuration.
*
diff --git a/hedera-node/hedera-app/src/main/java/module-info.java b/hedera-node/hedera-app/src/main/java/module-info.java
index 8004f231ff12..3c70b11eee59 100644
--- a/hedera-node/hedera-app/src/main/java/module-info.java
+++ b/hedera-node/hedera-app/src/main/java/module-info.java
@@ -69,6 +69,7 @@
requires io.netty.handler;
requires io.netty.transport.classes.epoll;
requires io.netty.transport;
+ requires net.i2p.crypto.eddsa;
requires org.apache.commons.lang3;
requires static com.github.spotbugs.annotations;
requires static com.google.auto.service;
diff --git a/hedera-node/hedera-config/src/main/java/com/hedera/node/config/data/BootstrapConfig.java b/hedera-node/hedera-config/src/main/java/com/hedera/node/config/data/BootstrapConfig.java
index 6999d6998489..b0ddce703828 100644
--- a/hedera-node/hedera-config/src/main/java/com/hedera/node/config/data/BootstrapConfig.java
+++ b/hedera-node/hedera-config/src/main/java/com/hedera/node/config/data/BootstrapConfig.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023-2024 Hedera Hashgraph, LLC
+ * Copyright (C) 2023-2025 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -54,4 +54,6 @@ public record BootstrapConfig(
@ConfigProperty(value = "throttleDefsJson.resource", defaultValue = "genesis/throttles.json") @NodeProperty
String throttleDefsJsonResource,
@ConfigProperty(value = "throttleDefsJson.file", defaultValue = "data/config/throttles.json") @NodeProperty
- String throttleDefsJsonFile) {}
+ String throttleDefsJsonFile,
+ @ConfigProperty(value = "configTxt.path", defaultValue = "") String configTxtPath,
+ @ConfigProperty(value = "networkCerts.path", defaultValue = "data/keys") String networkCertsPath) {}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/EmbeddedHapiTest.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/EmbeddedHapiTest.java
index e123fd026ebd..966e156cd9f2 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/EmbeddedHapiTest.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/EmbeddedHapiTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 Hedera Hashgraph, LLC
+ * Copyright (C) 2024-2025 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,6 +21,7 @@
import com.hedera.services.bdd.junit.extensions.NetworkTargetingExtension;
import com.hedera.services.bdd.junit.extensions.SpecNamingExtension;
+import com.swirlds.platform.system.InitTrigger;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -46,4 +47,10 @@
* @return the reasons the test has to run in embedded mode
*/
EmbeddedReason[] value();
+
+ /**
+ * The trigger to use when initializing the embedded node
+ * @return the specified trigger
+ */
+ InitTrigger initTrigger() default InitTrigger.GENESIS;
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/GenesisHapiTest.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/GenesisHapiTest.java
index 2f9bccfcaff7..ea5a6ad70573 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/GenesisHapiTest.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/GenesisHapiTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 Hedera Hashgraph, LLC
+ * Copyright (C) 2024-2025 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -43,4 +43,6 @@
@Isolated
public @interface GenesisHapiTest {
ConfigOverride[] bootstrapOverrides() default {};
+
+ boolean useDiskGossipFiles() default false;
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/extensions/NetworkTargetingExtension.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/extensions/NetworkTargetingExtension.java
index ba842e27f5ad..32323dd22db2 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/extensions/NetworkTargetingExtension.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/extensions/NetworkTargetingExtension.java
@@ -47,6 +47,7 @@
import com.hedera.services.bdd.spec.HapiSpec;
import com.hedera.services.bdd.spec.SpecOperation;
import com.hedera.services.bdd.spec.keys.RepeatableKeyGenerator;
+import com.swirlds.platform.system.InitTrigger;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import java.util.Arrays;
@@ -77,16 +78,16 @@ public class NetworkTargetingExtension implements BeforeEachCallback, AfterEachC
public void beforeEach(@NonNull final ExtensionContext extensionContext) {
hapiTestMethodOf(extensionContext).ifPresent(method -> {
if (isAnnotated(method, GenesisHapiTest.class)) {
- final var targetNetwork =
- new EmbeddedNetwork(method.getName().toUpperCase(), method.getName(), CONCURRENT);
final var a = method.getAnnotation(GenesisHapiTest.class);
+ final var targetNetwork = new EmbeddedNetwork(
+ method.getName().toUpperCase(), method.getName(), CONCURRENT, InitTrigger.GENESIS);
final var bootstrapOverrides = Arrays.stream(a.bootstrapOverrides())
.collect(toMap(ConfigOverride::key, ConfigOverride::value));
- targetNetwork.startWith(bootstrapOverrides);
+ targetNetwork.startWith(bootstrapOverrides, a.useDiskGossipFiles());
HapiSpec.TARGET_NETWORK.set(targetNetwork);
} else if (isAnnotated(method, RestartHapiTest.class)) {
- final var targetNetwork =
- new EmbeddedNetwork(method.getName().toUpperCase(), method.getName(), REPEATABLE);
+ final var targetNetwork = new EmbeddedNetwork(
+ method.getName().toUpperCase(), method.getName(), REPEATABLE, InitTrigger.RESTART);
final var a = method.getAnnotation(RestartHapiTest.class);
final var setupOverrides =
@@ -106,7 +107,7 @@ public void beforeEach(@NonNull final ExtensionContext extensionContext) {
}
case SAME_VERSION, UPGRADE_BOUNDARY -> {
final var state = postGenesisStateOf(targetNetwork, a);
- targetNetwork.restart(state, restartOverrides);
+ targetNetwork.restart(state, restartOverrides, false);
}
}
HapiSpec.TARGET_NETWORK.set(targetNetwork);
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/AbstractLocalNode.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/AbstractLocalNode.java
index 70e710cf0e5b..da834eada173 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/AbstractLocalNode.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/AbstractLocalNode.java
@@ -23,11 +23,11 @@
import static com.hedera.services.bdd.junit.hedera.ExternalPath.DATA_CONFIG_DIR;
import static com.hedera.services.bdd.junit.hedera.ExternalPath.UPGRADE_ARTIFACTS_DIR;
import static com.hedera.services.bdd.junit.hedera.subprocess.ProcessUtils.conditionFuture;
-import static com.hedera.services.bdd.junit.hedera.utils.WorkingDirUtils.recreateWorkingDir;
import static java.util.Objects.requireNonNull;
import com.hedera.node.internal.network.Network;
import com.hedera.pbj.runtime.io.stream.ReadableStreamingData;
+import com.hedera.services.bdd.junit.hedera.utils.WorkingDirUtils;
import edu.umd.cs.findbugs.annotations.NonNull;
import java.io.IOException;
import java.nio.file.Files;
@@ -54,9 +54,9 @@ protected AbstractLocalNode(@NonNull final NodeMetadata metadata) {
}
@Override
- public @NonNull T initWorkingDir(@NonNull final String configTxt) {
+ public @NonNull T initWorkingDir(@NonNull final String configTxt, final boolean useTestGossipFiles) {
requireNonNull(configTxt);
- recreateWorkingDir(requireNonNull(metadata.workingDir()), configTxt);
+ WorkingDirUtils.recreateWorkingDir(requireNonNull(metadata.workingDir()), configTxt, useTestGossipFiles);
workingDirInitialized = true;
return self();
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/AbstractNode.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/AbstractNode.java
index 484a0d16d6ec..6c55735d7a3c 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/AbstractNode.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/AbstractNode.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 Hedera Hashgraph, LLC
+ * Copyright (C) 2024-2025 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -116,6 +116,9 @@ public Path getExternalPath(@NonNull final ExternalPath path) {
.resolve(Hedera.APP_NAME)
.resolve("" + getNodeId())
.resolve(Hedera.SWIRLD_NAME);
+ case DISK_CERTS_DIR, DISK_GOSSIP_KEY_DIR -> workingDir
+ .resolve(DATA_DIR)
+ .resolve("keys");
};
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/ExternalPath.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/ExternalPath.java
index e57178f5c77f..35339f096288 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/ExternalPath.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/ExternalPath.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 Hedera Hashgraph, LLC
+ * Copyright (C) 2024-2025 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -32,4 +32,6 @@ public enum ExternalPath {
DATA_CONFIG_DIR,
UPGRADE_ARTIFACTS_DIR,
SAVED_STATES_DIR,
+ DISK_GOSSIP_KEY_DIR,
+ DISK_CERTS_DIR
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/HederaNetwork.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/HederaNetwork.java
index 9986853989d0..69e58fc10b0d 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/HederaNetwork.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/HederaNetwork.java
@@ -164,6 +164,17 @@ default void startWith(@NonNull final Map bootstrapOverrides) {
throw new UnsupportedOperationException();
}
+ /**
+ * Starts all nodes in the network with the given customizations. The inclusion of each source
+ * type will ensure that the necessary corresponding key materials are all present on disk at
+ * time of node startup.
+ * @param bootstrapOverrides - configuration overrides to apply PRIOR to startup
+ * @param useDiskGossipFiles - use disk gossip files instead of the standard network JSON
+ */
+ default void startWith(@NonNull final Map bootstrapOverrides, final boolean useDiskGossipFiles) {
+ throw new UnsupportedOperationException();
+ }
+
/**
* Forcibly stops all nodes in the network.
*/
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/HederaNode.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/HederaNode.java
index 8ae2c32e0bf9..b110f1762f3c 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/HederaNode.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/HederaNode.java
@@ -80,10 +80,11 @@ public interface HederaNode {
* Initializes the working directory for the node. Must be called before the node is started.
*
* @param configTxt the address book the node should start with
+ * @param useTestGossipFiles whether or not to have legacy gossip files present on disk at startup
* @return this
*/
@NonNull
- HederaNode initWorkingDir(@NonNull String configTxt);
+ HederaNode initWorkingDir(@NonNull String configTxt, final boolean useTestGossipFiles);
/**
* Starts the node software.
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/NodeMetadata.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/NodeMetadata.java
index 8fcfe3e63edb..38ed291242c1 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/NodeMetadata.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/NodeMetadata.java
@@ -1,4 +1,19 @@
-// SPDX-License-Identifier: Apache-2.0
+/*
+ * Copyright (C) 2025 Hedera Hashgraph, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
package com.hedera.services.bdd.junit.hedera;
import static java.util.Objects.requireNonNull;
@@ -18,9 +33,34 @@ public record NodeMetadata(
int internalGossipPort,
int externalGossipPort,
int prometheusPort,
- @Nullable Path workingDir) {
+ @Nullable Path workingDir,
+ boolean generateNetworkJson) {
public static final int UNKNOWN_PORT = -1;
+ public NodeMetadata(
+ long nodeId,
+ String name,
+ AccountID accountId,
+ String host,
+ int grpcPort,
+ int grpcNodeOperatorPort,
+ int internalGossipPort,
+ int externalGossipPort,
+ int prometheusPort,
+ @Nullable Path workingDir) {
+ this(
+ nodeId,
+ name,
+ accountId,
+ host,
+ grpcPort,
+ grpcNodeOperatorPort,
+ internalGossipPort,
+ externalGossipPort,
+ prometheusPort,
+ workingDir,
+ true);
+ }
/**
* Create a new instance with the same values as this instance, but different ports.
*
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/AbstractEmbeddedHedera.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/AbstractEmbeddedHedera.java
index 5c330575b3ea..978c671f229f 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/AbstractEmbeddedHedera.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/AbstractEmbeddedHedera.java
@@ -18,6 +18,8 @@
import static com.hedera.hapi.util.HapiUtils.parseAccount;
import static com.hedera.node.app.hapi.utils.CommonPbjConverters.fromPbj;
+import static com.hedera.node.app.hapi.utils.keys.RSAUtils.generateCertificate;
+import static com.hedera.node.app.hapi.utils.keys.RSAUtils.parseIdFromPemLoc;
import static com.hedera.services.bdd.junit.hedera.ExternalPath.ADDRESS_BOOK;
import static com.hedera.services.bdd.junit.hedera.embedded.fakes.FakePlatformContext.PLATFORM_CONFIG;
import static com.swirlds.platform.roster.RosterUtils.rosterFrom;
@@ -40,6 +42,8 @@
import com.hedera.node.app.fixtures.state.FakeServiceMigrator;
import com.hedera.node.app.fixtures.state.FakeServicesRegistry;
import com.hedera.node.app.fixtures.state.FakeState;
+import com.hedera.node.app.hapi.utils.CommonUtils;
+import com.hedera.node.app.hapi.utils.keys.RSAUtils;
import com.hedera.node.app.hints.impl.HintsServiceImpl;
import com.hedera.node.app.history.impl.HistoryServiceImpl;
import com.hedera.node.app.info.DiskStartupNetworks;
@@ -79,7 +83,14 @@
import edu.umd.cs.findbugs.annotations.NonNull;
import java.io.IOException;
import java.io.UncheckedIOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
import java.nio.file.Path;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Executors;
@@ -144,7 +155,12 @@ public abstract class AbstractEmbeddedHedera implements EmbeddedHedera {
protected AbstractEmbeddedHedera(@NonNull final EmbeddedNode node) {
requireNonNull(node);
- addressBook = loadAddressBook(node.getExternalPath(ADDRESS_BOOK));
+
+ try {
+ addressBook = loadAddressBook(node.getExternalPath(ADDRESS_BOOK));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
network = node.startupNetwork().orElseThrow();
roster = rosterFrom(network);
nodeIds = network.nodeMetadata().stream()
@@ -369,18 +385,83 @@ private static byte[] usedBytesFrom(@NonNull final BufferedData responseBuffer)
return bytes;
}
- private static AddressBook loadAddressBook(@NonNull final Path path) {
+ public AddressBook getAddressBook() {
+ return addressBook;
+ }
+
+ private static AddressBook loadAddressBook(@NonNull final Path path) throws Exception {
requireNonNull(path);
final var configFile = LegacyConfigPropertiesLoader.loadConfigFile(path.toAbsolutePath());
final var randomAddressBook = RandomAddressBookBuilder.create(new Random())
- .withSize(1)
+ .withSize(configFile.getAddressBook().getSize())
.withRealKeysEnabled(true)
.build();
- final var sigCert = requireNonNull(randomAddressBook.iterator().next().getSigCert());
+
+ // Write all public certs to disk so CryptoStatic can initialize
+ final var parent = path.getParent();
+ final Path keysDir = parent.resolve("data").resolve("keys");
+ final var sigCerts = new HashMap();
final var addressBook = configFile.getAddressBook();
- return new AddressBook(stream(spliteratorUnknownSize(addressBook.iterator(), 0), false)
- .map(address -> address.copySetSigCert(sigCert))
- .toList());
+ if (Files.exists(keysDir)) {
+ // first generate the cert for the node's own gossip key
+ final var gossipPrivateKey = RSAUtils.loadPrivateKey(
+ keysDir
+ // NOTE: this key is for testing only; we don't need to worry about the password stored in
+ // the file
+ .resolve("account5.pem")
+ .toString(),
+ "pass");
+ final var gossipCert = generateCertificate(gossipPrivateKey, 0);
+ final var newAddress0 = addressBook.getAddress(NodeId.of(0)).copySetSigCert(gossipCert);
+ certToDisk("s-public-node0.pem", newAddress0, keysDir);
+ sigCerts.put(0L, gossipCert);
+
+ // now load all the other public certs
+ try (DirectoryStream stream = Files.newDirectoryStream(keysDir, "s-public-node*.pem")) {
+ for (Path file : stream) {
+ final var sigCert = RSAUtils.parseCertificate(
+ keysDir.resolve(file.getFileName()).toString());
+ final var nodeAcctId = parseIdFromPemLoc(file);
+ sigCerts.put(nodeAcctId, sigCert);
+ }
+ }
+
+ final List addresses = new ArrayList<>();
+ addresses.add(newAddress0);
+ for (int i = 1; i < 4; i++) {
+ Address oldAddress = addressBook.getAddress(NodeId.of(i));
+ Address newAddress = oldAddress.copySetSigCert(sigCerts.get((long) i));
+ addresses.add(newAddress);
+ }
+
+ return new AddressBook(addresses);
+ } else {
+ final var sigCert =
+ requireNonNull(randomAddressBook.iterator().next().getSigCert());
+ return new AddressBook(stream(spliteratorUnknownSize(addressBook.iterator(), 0), false)
+ .map(address -> address.copySetSigCert(sigCert))
+ .toList());
+ }
+ }
+
+ private static void certToDisk(
+ @NonNull final String certFilename, @NonNull final Address address, @NonNull final Path certDir) {
+ try (final var certWriter = Files.newBufferedWriter(certDir.resolve(certFilename))) {
+ certWriter.write("-----BEGIN CERTIFICATE-----\n");
+ var encoded = CommonUtils.base64encode(address.getSigCert().getEncoded());
+ int index = 0;
+ while (index < encoded.length()) {
+ int end = Math.min(index + 64, encoded.length());
+ certWriter.write(encoded.substring(index, end));
+ certWriter.write("\n");
+ index = end;
+ }
+ certWriter.write("-----END CERTIFICATE-----\n");
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ } catch (CertificateEncodingException e) {
+ throw new RuntimeException(e);
+ }
}
private static AccountID accountIdOf(@NonNull final Address address) {
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/EmbeddedNetwork.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/EmbeddedNetwork.java
index e9adf69e6910..2a59bac7a246 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/EmbeddedNetwork.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/EmbeddedNetwork.java
@@ -42,6 +42,7 @@
import com.hederahashgraph.api.proto.java.Response;
import com.hederahashgraph.api.proto.java.Transaction;
import com.hederahashgraph.api.proto.java.TransactionResponse;
+import com.swirlds.platform.system.InitTrigger;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import java.time.Duration;
@@ -75,20 +76,24 @@ public class EmbeddedNetwork extends AbstractNetwork {
public static HederaNetwork newSharedNetwork(@NonNull final EmbeddedMode mode) {
requireNonNull(mode);
return switch (mode) {
- case CONCURRENT -> new EmbeddedNetwork(CONCURRENT_NAME, CONCURRENT_WORKING_DIR, mode);
- case REPEATABLE -> new EmbeddedNetwork(REPEATABLE_NAME, REPEATABLE_WORKING_DIR, mode);
+ case CONCURRENT -> new EmbeddedNetwork(CONCURRENT_NAME, CONCURRENT_WORKING_DIR, mode, InitTrigger.GENESIS);
+ case REPEATABLE -> new EmbeddedNetwork(REPEATABLE_NAME, REPEATABLE_WORKING_DIR, mode, InitTrigger.GENESIS);
};
}
public EmbeddedNetwork(
- @NonNull final String name, @NonNull final String workingDir, @NonNull final EmbeddedMode mode) {
+ @NonNull final String name,
+ @NonNull final String workingDir,
+ @NonNull final EmbeddedMode mode,
+ @NonNull final InitTrigger trigger) {
super(
name,
IntStream.range(0, CLASSIC_HAPI_TEST_NETWORK_SIZE)
.mapToObj(nodeId -> new EmbeddedNode(
// All non-embedded node working directories are mapped to the embedded node0
classicMetadataFor(
- nodeId, name, FAKE_HOST, 0, 0, 0, 0, 0, workingDirFor(0, workingDir))))
+ nodeId, name, FAKE_HOST, 0, 0, 0, 0, 0, workingDirFor(0, workingDir)),
+ trigger))
.toList());
this.mode = requireNonNull(mode);
this.embeddedNode = (EmbeddedNode) nodes().getFirst();
@@ -103,21 +108,27 @@ public EmbeddedNetwork(
* Starts the embedded Hedera network from a saved state customized by the given specs.
*
* @param state the state to customize
+ * @param bootstrapOverrides the overrides to apply
+ * @param useDiskGossipFiles whether to have the legacy disk gossip files on disk
*/
- public void restart(@NonNull final FakeState state, @NonNull final Map bootstrapOverrides) {
+ public void restart(
+ @NonNull final FakeState state,
+ @NonNull final Map bootstrapOverrides,
+ final boolean useDiskGossipFiles) {
requireNonNull(state);
- startVia(hedera -> hedera.restart(state), bootstrapOverrides);
+ startVia(hedera -> hedera.restart(state), bootstrapOverrides, useDiskGossipFiles);
}
@Override
public void start() {
- startWith(emptyMap());
+ // Initialize with the default params
+ startWith(emptyMap(), false);
}
@Override
- public void startWith(@NonNull final Map bootstrapOverrides) {
+ public void startWith(@NonNull final Map bootstrapOverrides, final boolean useDiskGossipFiles) {
requireNonNull(bootstrapOverrides);
- startVia(EmbeddedHedera::start, bootstrapOverrides);
+ startVia(EmbeddedHedera::start, bootstrapOverrides, useDiskGossipFiles);
}
@Override
@@ -194,9 +205,11 @@ protected HapiPropertySource networkOverrides() {
}
private void startVia(
- @NonNull final Consumer start, @NonNull final Map bootstrapOverrides) {
+ @NonNull final Consumer start,
+ @NonNull final Map bootstrapOverrides,
+ final boolean useDiskGossipFiles) {
// Initialize the working directory
- embeddedNode.initWorkingDir(configTxt);
+ embeddedNode.initWorkingDir(configTxt, useDiskGossipFiles);
if (!bootstrapOverrides.isEmpty()) {
updateBootstrapProperties(embeddedNode.getExternalPath(APPLICATION_PROPERTIES), bootstrapOverrides);
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/EmbeddedNode.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/EmbeddedNode.java
index a272d87b6a39..8ec0e7205624 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/EmbeddedNode.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/EmbeddedNode.java
@@ -19,6 +19,7 @@
import static com.hedera.services.bdd.junit.hedera.ExternalPath.APPLICATION_PROPERTIES;
import static com.hedera.services.bdd.junit.hedera.ExternalPath.BLOCK_STREAMS_DIR;
import static com.hedera.services.bdd.junit.hedera.ExternalPath.DATA_CONFIG_DIR;
+import static com.hedera.services.bdd.junit.hedera.ExternalPath.DISK_CERTS_DIR;
import static com.hedera.services.bdd.junit.hedera.ExternalPath.GENESIS_PROPERTIES;
import static com.hedera.services.bdd.junit.hedera.ExternalPath.LOG4J2_XML;
import static com.hedera.services.bdd.junit.hedera.ExternalPath.NODE_ADMIN_KEYS_JSON;
@@ -28,16 +29,33 @@
import static com.hedera.services.bdd.junit.hedera.embedded.EmbeddedNetwork.REPEATABLE_WORKING_DIR;
import static com.hedera.services.bdd.junit.hedera.utils.WorkingDirUtils.ensureDir;
import static com.hedera.services.bdd.junit.hedera.utils.WorkingDirUtils.updateUpgradeArtifactsProperty;
+import static com.swirlds.common.io.utility.FileUtils.getAbsolutePath;
+import static com.swirlds.common.io.utility.FileUtils.rethrowIO;
+import static com.swirlds.platform.builder.PlatformBuildConstants.DEFAULT_OVERRIDES_YAML_FILE_NAME;
+import static com.swirlds.platform.builder.PlatformBuildConstants.DEFAULT_SETTINGS_FILE_NAME;
import com.hedera.node.app.Hedera;
+import com.hedera.node.app.config.ConfigProviderImpl;
+import com.hedera.node.app.info.DiskStartupNetworks;
+import com.hedera.node.internal.network.Network;
import com.hedera.services.bdd.junit.hedera.AbstractLocalNode;
import com.hedera.services.bdd.junit.hedera.HederaNode;
import com.hedera.services.bdd.junit.hedera.NodeMetadata;
import com.hedera.services.bdd.junit.hedera.subprocess.NodeStatus;
+import com.swirlds.common.crypto.config.CryptoConfig;
+import com.swirlds.common.metrics.noop.NoOpMetrics;
+import com.swirlds.config.api.Configuration;
+import com.swirlds.config.api.ConfigurationBuilder;
+import com.swirlds.config.extensions.sources.SystemEnvironmentConfigSource;
+import com.swirlds.config.extensions.sources.SystemPropertiesConfigSource;
+import com.swirlds.platform.config.PathsConfig;
+import com.swirlds.platform.system.InitTrigger;
import com.swirlds.platform.system.Platform;
import com.swirlds.platform.system.status.PlatformStatus;
+import com.swirlds.platform.util.BootstrapUtils;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
+import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.apache.logging.log4j.core.config.Configurator;
@@ -51,8 +69,12 @@
* are received.
*/
public class EmbeddedNode extends AbstractLocalNode implements HederaNode {
- public EmbeddedNode(@NonNull final NodeMetadata metadata) {
+
+ private final InitTrigger trigger;
+
+ public EmbeddedNode(@NonNull final NodeMetadata metadata, @NonNull final InitTrigger trigger) {
super(metadata);
+ this.trigger = trigger;
}
@Override
@@ -86,12 +108,21 @@ public HederaNode start() {
// Only initialize logging for the shared embedded network
}
}
+ System.setProperty(
+ "bootstrap.configTxt.path",
+ getExternalPath(DATA_CONFIG_DIR)
+ .getParent()
+ .getParent()
+ .resolve("config.txt")
+ .toString());
+ System.setProperty(
+ "bootstrap.networkCerts.path", getExternalPath(DISK_CERTS_DIR).toString());
return this;
}
@Override
- public @NonNull EmbeddedNode initWorkingDir(@NonNull final String configTxt) {
- super.initWorkingDir(configTxt);
+ public @NonNull EmbeddedNode initWorkingDir(@NonNull final String configTxt, final boolean useTestGossipFiles) {
+ super.initWorkingDir(configTxt, useTestGossipFiles);
updateUpgradeArtifactsProperty(getExternalPath(APPLICATION_PROPERTIES), getExternalPath(UPGRADE_ARTIFACTS_DIR));
return this;
}
@@ -115,4 +146,41 @@ protected EmbeddedNode self() {
private boolean isForShared(@NonNull final String log4jConfigLoc) {
return log4jConfigLoc.contains(CONCURRENT_WORKING_DIR) || log4jConfigLoc.contains(REPEATABLE_WORKING_DIR);
}
+
+ @Override
+ public Optional startupNetwork() {
+ final var parentNetwork = super.startupNetwork();
+ if (parentNetwork.isPresent()) {
+ return parentNetwork;
+ }
+
+ final var provider = new ConfigProviderImpl(false, new NoOpMetrics());
+ final var startupNetworks = new DiskStartupNetworks(provider);
+ final var testPlatformConfig = testPlatformConfig();
+
+ if (trigger == InitTrigger.GENESIS) {
+ final var maybeNetwork = startupNetworks.genesisNetworkOrThrow(testPlatformConfig);
+ return Optional.ofNullable(maybeNetwork);
+ } else if (trigger == InitTrigger.RESTART) {
+ final var maybeNetwork = startupNetworks.migrationNetworkOrThrow();
+ return Optional.ofNullable(maybeNetwork);
+ } else {
+ throw new IllegalStateException("InitTrigger " + trigger + "is not supported in embedded mode");
+ }
+ }
+
+ private Configuration testPlatformConfig() {
+ final ConfigurationBuilder configurationBuilder = ConfigurationBuilder.create()
+ .withSource(SystemEnvironmentConfigSource.getInstance())
+ .withSource(SystemPropertiesConfigSource.getInstance());
+
+ rethrowIO(() -> BootstrapUtils.setupConfigBuilder(
+ configurationBuilder,
+ getAbsolutePath(DEFAULT_SETTINGS_FILE_NAME),
+ getAbsolutePath(DEFAULT_OVERRIDES_YAML_FILE_NAME)));
+
+ configurationBuilder.withConfigDataType(PathsConfig.class);
+ configurationBuilder.withConfigDataType(CryptoConfig.class);
+ return configurationBuilder.build();
+ }
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/remote/RemoteNode.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/remote/RemoteNode.java
index 875fc57ef773..3d78d85ddb82 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/remote/RemoteNode.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/remote/RemoteNode.java
@@ -40,7 +40,7 @@ public Path getExternalPath(@NonNull final ExternalPath path) {
}
@Override
- public HederaNode initWorkingDir(@NonNull final String configTxt) {
+ public HederaNode initWorkingDir(@NonNull final String configTxt, final boolean useTestGossipFiles) {
throw new UnsupportedOperationException("Cannot initialize a remote node's working directory");
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/subprocess/SubProcessNetwork.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/subprocess/SubProcessNetwork.java
index 7e187d33a5c9..53fbba5c715e 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/subprocess/SubProcessNetwork.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/subprocess/SubProcessNetwork.java
@@ -201,7 +201,7 @@ public TargetNetworkType type() {
*/
@Override
public void start() {
- nodes.forEach(node -> node.initWorkingDir(configTxt).start());
+ nodes.forEach(node -> node.initWorkingDir(configTxt, false).start());
}
/**
@@ -342,7 +342,7 @@ public void addNode(final long nodeId) {
nodes.add(insertionPoint, node);
configTxt = configTxtForLocal(
networkName, nodes, nextInternalGossipPort, nextExternalGossipPort, latestCandidateWeights());
- nodes.get(insertionPoint).initWorkingDir(configTxt);
+ nodes.get(insertionPoint).initWorkingDir(configTxt, false);
refreshOverrideNetworks(ReassignPorts.NO);
}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/utils/WorkingDirUtils.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/utils/WorkingDirUtils.java
index 1fedee4fafff..374620b25da1 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/utils/WorkingDirUtils.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/utils/WorkingDirUtils.java
@@ -88,6 +88,7 @@ public class WorkingDirUtils {
public static final String OUTPUT_DIR = "output";
public static final String UPGRADE_DIR = "upgrade";
public static final String CURRENT_DIR = "current";
+ public static final String DISK_GOSSIP_KEY_PREFIX = "disk-gossip-key";
public static final String CONFIG_TXT = "config.txt";
public static final String GENESIS_PROPERTIES = "genesis.properties";
public static final String ERROR_REDIRECT_FILE = "test-clients.log";
@@ -97,6 +98,8 @@ public class WorkingDirUtils {
public static final String APPLICATION_PROPERTIES = "application.properties";
private static final List WORKING_DIR_DATA_FOLDERS = List.of(KEYS_FOLDER, CONFIG_FOLDER, UPGRADE_DIR);
+ private static final List PUBLIC_CERTS =
+ List.of("s-public-node1.pem", "s-public-node2.pem", "s-public-node3.pem");
private WorkingDirUtils() {
throw new UnsupportedOperationException("Utility Class");
@@ -123,8 +126,10 @@ public static Path workingDirFor(final long nodeId, @Nullable String scope) {
*
* @param workingDir the path to the working directory
* @param configTxt the contents of the config.txt file
+ * @param useDiskGossipFiles whether or not to have legacy gossip files present on disk at startup
*/
- public static void recreateWorkingDir(@NonNull final Path workingDir, @NonNull final String configTxt) {
+ public static void recreateWorkingDir(
+ @NonNull final Path workingDir, @NonNull final String configTxt, final boolean useDiskGossipFiles) {
requireNonNull(workingDir);
requireNonNull(configTxt);
@@ -139,11 +144,21 @@ public static void recreateWorkingDir(@NonNull final Path workingDir, @NonNull f
// Write the address book (config.txt) and genesis network (genesis-network.json) files
writeStringUnchecked(workingDir.resolve(CONFIG_TXT), configTxt);
final var network = networkFrom(configTxt, OnlyRoster.NO);
- writeStringUnchecked(
- workingDir.resolve(DATA_DIR).resolve(CONFIG_FOLDER).resolve(GENESIS_NETWORK_JSON),
- Network.JSON.toJSON(network));
+
+ // Only generate the network JSON file if the flag is set
+ final var generateNetworkJson = !useDiskGossipFiles;
+ if (generateNetworkJson) {
+ writeStringUnchecked(
+ workingDir.resolve(DATA_DIR).resolve(CONFIG_FOLDER).resolve(GENESIS_NETWORK_JSON),
+ Network.JSON.toJSON(network));
+ } else {
+ // Remove any previously-copied genesis-network.json artifact
+ rm(workingDir.resolve(DATA_DIR).resolve(CONFIG_FOLDER).resolve(GENESIS_NETWORK_JSON));
+ }
+
// Copy the bootstrap assets into the working directory
copyBootstrapAssets(bootstrapAssetsLoc(), workingDir);
+
// Update the log4j2.xml file with the correct output directory
updateLog4j2XmlOutputDir(workingDir);
}
@@ -340,6 +355,20 @@ private static void copyBootstrapAssets(@NonNull final Path assetDir, @NonNull f
} catch (IOException e) {
throw new UncheckedIOException(e);
}
+
+ final var copiedGossipKeyDir = workingDir.resolve(DATA_DIR).resolve(KEYS_FOLDER);
+ ensureDir(copiedGossipKeyDir.toString());
+ copyUnchecked(workingDir.resolve(DISK_GOSSIP_KEY_PREFIX + ".pem"), copiedGossipKeyDir.resolve("account5.pem"));
+ copyUnchecked(
+ workingDir.resolve(DISK_GOSSIP_KEY_PREFIX + ".pass"), copiedGossipKeyDir.resolve("account5.pass"));
+
+ // Also copy the public certs of the other nodes in the network
+ PUBLIC_CERTS.forEach(cert -> copyUnchecked(workingDir.resolve(cert), copiedGossipKeyDir.resolve(cert)));
+
+ // Since these files were copied to the appropriate dir immediately above, they're no longer needed
+ rm(workingDir.resolve(DISK_GOSSIP_KEY_PREFIX + ".pem"));
+ rm(workingDir.resolve(DISK_GOSSIP_KEY_PREFIX + ".pass"));
+ PUBLIC_CERTS.forEach(cert -> rm(workingDir.resolve(cert)));
}
/**
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/restart/RestartHapiTest.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/restart/RestartHapiTest.java
index ba7726add487..95a4b7a8823e 100644
--- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/restart/RestartHapiTest.java
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/restart/RestartHapiTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 Hedera Hashgraph, LLC
+ * Copyright (C) 2024-2025 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/embedded/VerifyGossipCertOp.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/embedded/VerifyGossipCertOp.java
new file mode 100644
index 000000000000..f1844f08687b
--- /dev/null
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/embedded/VerifyGossipCertOp.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2024-2025 Hedera Hashgraph, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.hedera.services.bdd.spec.utilops.embedded;
+
+import static com.hedera.node.app.service.addressbook.impl.schemas.V053AddressBookSchema.NODES_KEY;
+import static com.hedera.services.bdd.spec.TargetNetworkType.EMBEDDED_NETWORK;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.hedera.hapi.node.state.addressbook.Node;
+import com.hedera.hapi.node.state.common.EntityNumber;
+import com.hedera.node.app.hapi.utils.keys.RSAUtils;
+import com.hedera.node.app.service.addressbook.AddressBookService;
+import com.hedera.services.bdd.junit.hedera.embedded.AbstractEmbeddedHedera;
+import com.hedera.services.bdd.spec.HapiSpec;
+import com.hedera.services.bdd.spec.utilops.UtilOp;
+import com.swirlds.common.platform.NodeId;
+import com.swirlds.state.spi.ReadableKVState;
+import edu.umd.cs.findbugs.annotations.NonNull;
+
+public class VerifyGossipCertOp extends UtilOp {
+ private final int nodeId;
+
+ public VerifyGossipCertOp(final int nodeId) {
+ this.nodeId = nodeId;
+ }
+
+ @Override
+ protected boolean submitOp(@NonNull final HapiSpec spec) throws Throwable {
+ if (spec.targetNetworkType() != EMBEDDED_NETWORK) {
+ throw new IllegalStateException("This op is only compatible with embedded networks");
+ }
+
+ final var targetNetwork = ((AbstractEmbeddedHedera) spec.embeddedHederaOrThrow());
+ final var readableStates = spec.embeddedStateOrThrow().getReadableStates(AddressBookService.NAME);
+
+ final ReadableKVState nodesState = readableStates.get(NODES_KEY);
+ final var node = nodesState.get(EntityNumber.newBuilder().number(nodeId).build());
+ final var actualCert =
+ RSAUtils.parseCertificate(node.gossipCaCertificate().toByteArray());
+
+ final var expectedAddr = targetNetwork.getAddressBook().getAddress(NodeId.of(nodeId));
+ final var expectedCert = expectedAddr.getSigCert();
+
+ assertEquals(expectedCert, actualCert, "Actual cert didn't match expected cert for node " + nodeId);
+
+ return false;
+ }
+}
diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/crypto/DiskStartupNetworksSuite.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/crypto/DiskStartupNetworksSuite.java
new file mode 100644
index 000000000000..425a5fb91605
--- /dev/null
+++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/crypto/DiskStartupNetworksSuite.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2020-2025 Hedera Hashgraph, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.hedera.services.bdd.suites.crypto;
+
+import static com.hedera.services.bdd.junit.TestTags.INTEGRATION;
+import static com.hedera.services.bdd.spec.HapiSpec.hapiTest;
+
+import com.hedera.services.bdd.junit.EmbeddedHapiTest;
+import com.hedera.services.bdd.junit.EmbeddedReason;
+import com.hedera.services.bdd.junit.GenesisHapiTest;
+import com.hedera.services.bdd.spec.utilops.embedded.VerifyGossipCertOp;
+import com.swirlds.platform.system.InitTrigger;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Tag;
+
+@Tag(INTEGRATION)
+public class DiskStartupNetworksSuite {
+ @GenesisHapiTest(useDiskGossipFiles = true)
+ @EmbeddedHapiTest(value = EmbeddedReason.NEEDS_STATE_ACCESS, initTrigger = InitTrigger.RESTART)
+ @DisplayName("Loading a network with no network JSON resources falls back to config.txt and on-disk key/certs")
+ final Stream networkLoadsConfigTxtAndDiskGossipKey() {
+ return hapiTest(
+ new VerifyGossipCertOp(0),
+ new VerifyGossipCertOp(1),
+ new VerifyGossipCertOp(2),
+ new VerifyGossipCertOp(3));
+ }
+}
diff --git a/hedera-node/test-clients/src/main/java/module-info.java b/hedera-node/test-clients/src/main/java/module-info.java
index c5bac54c2b78..15e1e8f96497 100644
--- a/hedera-node/test-clients/src/main/java/module-info.java
+++ b/hedera-node/test-clients/src/main/java/module-info.java
@@ -1,3 +1,19 @@
+/*
+ * Copyright (C) 2025 Hedera Hashgraph, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
module com.hedera.node.test.clients {
exports com.hedera.services.bdd.spec.dsl;
exports com.hedera.services.bdd.spec.dsl.contracts;
@@ -82,6 +98,7 @@
requires com.swirlds.base;
requires com.swirlds.common;
requires com.swirlds.config.api;
+ requires com.swirlds.config.extensions;
requires com.swirlds.merkledb;
requires com.swirlds.metrics.api;
requires com.swirlds.platform.core.test.fixtures;