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;