diff options
3 files changed, 102 insertions, 38 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateMock.java index 23799f48f91..b0d45b0d7bb 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateMock.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateMock.java @@ -25,7 +25,7 @@ public class EndpointCertificateMock implements EndpointCertificateProvider { this.dnsNames.put(applicationId, dnsNames); String endpointCertificatePrefix = String.format("vespa.tls.%s.%s.%s", applicationId.tenant(), applicationId.application(), applicationId.instance()); - return new EndpointCertificateMetadata(endpointCertificatePrefix + "-key", endpointCertificatePrefix + "-cert", 0); + return new EndpointCertificateMetadata(endpointCertificatePrefix + "-key", endpointCertificatePrefix + "-cert", 0, Optional.of("mock-id-string"), Optional.of(dnsNames), Optional.of("mockCa")); } @Override diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/certificate/EndpointCertificateManager.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/certificate/EndpointCertificateManager.java index c2ea9be044f..513709aefc8 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/certificate/EndpointCertificateManager.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/certificate/EndpointCertificateManager.java @@ -123,7 +123,7 @@ public class EndpointCertificateManager { // Reprovision certificate if it is missing SANs for the zone we are deploying to var sansInCertificate = currentCertificateMetadata.get().requestedDnsSans(); - var requiredSansForZone = dnsNamesOf(instance.id(), List.of(zone)); + var requiredSansForZone = dnsNamesOf(instance.id(), zone); if (sansInCertificate.isPresent() && !sansInCertificate.get().containsAll(requiredSansForZone)) { var reprovisionedCertificateMetadata = provisionEndpointCertificate(instance, currentCertificateMetadata, zone, instanceSpec); curator.writeEndpointCertificateMetadata(instance.id(), reprovisionedCertificateMetadata); @@ -222,29 +222,30 @@ public class EndpointCertificateManager { var zoneCandidateList = zoneRegistry.zones().controllerUpgraded().zones().stream().map(ZoneApi::getId).collect(Collectors.toList()); // If not deploying to a dev or perf zone, require all prod zones in deployment spec + test and staging - if (!deploymentZone.environment().isManuallyDeployed() && instanceSpec.isPresent()) { + if (!deploymentZone.environment().isManuallyDeployed()) { zoneCandidateList.stream() - .filter(z -> instanceSpec.get().deploysTo(Environment.prod, z.region()) || z.environment().isTest()) + .filter(z -> z.environment().isTest() || instanceSpec.isPresent() && instanceSpec.get().deploysTo(Environment.prod, z.region())) .forEach(requiredZones::add); } var requiredNames = requiredZones.stream() - .flatMap(zone -> dnsNamesOf(instance.id(), List.of(zone)).stream()) - .collect(Collectors.toCollection(ArrayList::new)); + .flatMap(zone -> dnsNamesOf(instance.id(), zone).stream()) + .collect(Collectors.toCollection(LinkedHashSet::new)); // Make sure all currently present names will remain present. // Instead of just adding "currently present names", we regenerate them in case the names for a zone have changed. zoneCandidateList.stream() - .map(zone -> dnsNamesOf(instance.id(), List.of(zone))) - .filter(zoneNames -> !currentlyPresentNames.containsAll(zoneNames)) + .map(zone -> dnsNamesOf(instance.id(), zone)) + .filter(zoneNames -> zoneNames.stream().anyMatch(currentlyPresentNames::contains)) + .filter(currentlyPresentNames::containsAll) .forEach(requiredNames::addAll); // This check must be relaxed if we ever remove from the set of names generated. if (!requiredNames.containsAll(currentlyPresentNames)) - throw new RuntimeException("SANs to be requested do not cover all existing names! Missing names:" - + currentlyPresentNames.stream().filter(s -> !requiredNames.contains(s)).collect(Collectors.joining())); + throw new RuntimeException("SANs to be requested do not cover all existing names! Missing names: " + + currentlyPresentNames.stream().filter(s -> !requiredNames.contains(s)).collect(Collectors.joining(", "))); - return endpointCertificateProvider.requestCaSignedCertificate(instance.id(), requiredNames, currentMetadata); + return endpointCertificateProvider.requestCaSignedCertificate(instance.id(), List.copyOf(requiredNames), currentMetadata); } private void validateEndpointCertificate(EndpointCertificateMetadata endpointCertificateMetadata, Instance instance, ZoneId zone) { @@ -279,7 +280,7 @@ public class EndpointCertificateManager { .filter(san -> san.getType().equals(SubjectAlternativeName.Type.DNS_NAME)) .map(SubjectAlternativeName::getValue).collect(Collectors.toSet()); - var dnsNamesOfZone = dnsNamesOf(instance.id(), List.of(zone)); + var dnsNamesOfZone = dnsNamesOf(instance.id(), zone); if (!subjectAlternativeNames.containsAll(dnsNamesOfZone)) throw new EndpointCertificateException(EndpointCertificateException.Type.VERIFICATION_FAILURE, "Certificate is missing required SANs for zone " + zone.value()); @@ -295,22 +296,24 @@ public class EndpointCertificateManager { } } - private List<String> dnsNamesOf(ApplicationId applicationId, List<ZoneId> zones) { + private List<String> dnsNamesOf(ApplicationId applicationId, ZoneId zone) { List<String> endpointDnsNames = new ArrayList<>(); // We add first an endpoint name based on a hash of the applicationId, // as the certificate provider requires the first CN to be < 64 characters long. endpointDnsNames.add(commonNameHashOf(applicationId, zoneRegistry.system())); - var globalDefaultEndpoint = Endpoint.of(applicationId).named(EndpointId.defaultId()); - var rotationEndpoints = Endpoint.of(applicationId).wildcard(); + List<Endpoint.EndpointBuilder> endpoints = new ArrayList<>(); - var zoneLocalEndpoints = zones.stream().flatMap(zone -> Stream.of( - Endpoint.of(applicationId).target(ClusterSpec.Id.from("default"), zone), - Endpoint.of(applicationId).wildcard(zone) - )); + if(zone.environment().isProduction()) { + endpoints.add(Endpoint.of(applicationId).named(EndpointId.defaultId())); + endpoints.add(Endpoint.of(applicationId).wildcard()); + } + + endpoints.add(Endpoint.of(applicationId).target(ClusterSpec.Id.from("default"), zone)); + endpoints.add(Endpoint.of(applicationId).wildcard(zone)); - Stream.concat(Stream.of(globalDefaultEndpoint, rotationEndpoints), zoneLocalEndpoints) + endpoints.stream() .map(endpoint -> endpoint.routingMethod(RoutingMethod.exclusive)) .map(endpoint -> endpoint.on(Endpoint.Port.tls())) .map(endpointBuilder -> endpointBuilder.in(zoneRegistry.system())) diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/certificate/EndpointCertificateManagerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/certificate/EndpointCertificateManagerTest.java index fbdc094b240..d29a1c539bb 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/certificate/EndpointCertificateManagerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/certificate/EndpointCertificateManagerTest.java @@ -1,6 +1,7 @@ package com.yahoo.vespa.hosted.controller.certificate; import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.SystemName; import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.security.KeyAlgorithm; @@ -25,8 +26,10 @@ import java.security.cert.X509Certificate; import java.time.Clock; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -45,20 +48,49 @@ public class EndpointCertificateManagerTest { private final EndpointCertificateManager endpointCertificateManager = new EndpointCertificateManager(zoneRegistryMock, mockCuratorDb, secretStore, endpointCertificateMock, clock, inMemoryFlagSource); + private static List<String> expectedSans = List.of( + "vt2ktgkqme5zlnp4tj4ttyor7fj3v7q5o.vespa.oath.cloud", + "default.default.global.vespa.oath.cloud", + "*.default.default.global.vespa.oath.cloud", + "default.default.aws-us-east-1a.vespa.oath.cloud", + "*.default.default.aws-us-east-1a.vespa.oath.cloud", + "default.default.us-east-1.test.vespa.oath.cloud", + "*.default.default.us-east-1.test.vespa.oath.cloud", + "default.default.us-east-3.staging.vespa.oath.cloud", + "*.default.default.us-east-3.staging.vespa.oath.cloud" + ); + + private static List<String> expectedAdditionalSans = List.of( + "default.default.ap-northeast-1.vespa.oath.cloud", + "*.default.default.ap-northeast-1.vespa.oath.cloud" + ); + + private static List<String> expectedCombinedSans = new ArrayList<>() {{ + addAll(expectedSans); + addAll(expectedAdditionalSans); + }}; + + private static List<String> expectedDevSans = List.of( + "vt2ktgkqme5zlnp4tj4ttyor7fj3v7q5o.vespa.oath.cloud", + "default.default.us-east-1.dev.vespa.oath.cloud", + "*.default.default.us-east-1.dev.vespa.oath.cloud" + ); + private static final KeyPair testKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC, 192); - private static final X509Certificate testCertificate = X509CertificateBuilder - .fromKeypair( - testKeyPair, - new X500Principal("CN=test"), - Instant.now(), Instant.now().plus(5, ChronoUnit.MINUTES), - SignatureAlgorithm.SHA256_WITH_ECDSA, - X509CertificateBuilder.generateRandomSerialNumber()) - .addSubjectAlternativeName("vt2ktgkqme5zlnp4tj4ttyor7fj3v7q5o.vespa.oath.cloud") - .addSubjectAlternativeName("default.default.global.vespa.oath.cloud") - .addSubjectAlternativeName("*.default.default.global.vespa.oath.cloud") - .addSubjectAlternativeName("default.default.us-east-1.test.vespa.oath.cloud") - .addSubjectAlternativeName("*.default.default.us-east-1.test.vespa.oath.cloud") - .build(); + private static final X509Certificate testCertificate = makeTestCert(expectedSans); + private static final X509Certificate testCertificate2 = makeTestCert(expectedCombinedSans); + + private static X509Certificate makeTestCert(List<String> sans) { + X509CertificateBuilder x509CertificateBuilder = X509CertificateBuilder + .fromKeypair( + testKeyPair, + new X500Principal("CN=test"), + Instant.now(), Instant.now().plus(5, ChronoUnit.MINUTES), + SignatureAlgorithm.SHA256_WITH_ECDSA, + X509CertificateBuilder.generateRandomSerialNumber()); + for (String san : sans) x509CertificateBuilder = x509CertificateBuilder.addSubjectAlternativeName(san); + return x509CertificateBuilder.build(); + } private final Instance testInstance = new Instance(ApplicationId.defaultId()); private final String testKeyName = "testKeyName"; @@ -68,24 +100,36 @@ public class EndpointCertificateManagerTest { @Before public void setUp() { zoneRegistryMock.exclusiveRoutingIn(zoneRegistryMock.zones().all().zones()); - testZone = zoneRegistryMock.zones().directlyRouted().zones().stream().findFirst().orElseThrow().getId(); + testZone = zoneRegistryMock.zones().directlyRouted().in(Environment.prod).zones().stream().findFirst().orElseThrow().getId(); inMemoryFlagSource.withBooleanFlag(Flags.VALIDATE_ENDPOINT_CERTIFICATES.id(), true); } @Test - public void provisions_new_certificate() { + public void provisions_new_certificate_in_dev() { + ZoneId testZone = zoneRegistryMock.zones().directlyRouted().in(Environment.dev).zones().stream().findFirst().orElseThrow().getId(); + Optional<EndpointCertificateMetadata> endpointCertificateMetadata = endpointCertificateManager.getEndpointCertificateMetadata(testInstance, testZone, Optional.empty()); + assertTrue(endpointCertificateMetadata.isPresent()); + assertTrue(endpointCertificateMetadata.get().keyName().matches("vespa.tls.default.default.*-key")); + assertTrue(endpointCertificateMetadata.get().certName().matches("vespa.tls.default.default.*-cert")); + assertEquals(0, endpointCertificateMetadata.get().version()); + assertEquals(expectedDevSans, endpointCertificateMetadata.get().requestedDnsSans().orElseThrow()); + } + + @Test + public void provisions_new_certificate_in_prod() { Optional<EndpointCertificateMetadata> endpointCertificateMetadata = endpointCertificateManager.getEndpointCertificateMetadata(testInstance, testZone, Optional.empty()); assertTrue(endpointCertificateMetadata.isPresent()); assertTrue(endpointCertificateMetadata.get().keyName().matches("vespa.tls.default.default.*-key")); assertTrue(endpointCertificateMetadata.get().certName().matches("vespa.tls.default.default.*-cert")); assertEquals(0, endpointCertificateMetadata.get().version()); + assertEquals(expectedSans, endpointCertificateMetadata.get().requestedDnsSans().orElseThrow()); } @Test public void reuses_stored_certificate_metadata() { mockCuratorDb.writeEndpointCertificateMetadata(testInstance.id(), new EndpointCertificateMetadata(testKeyName, testCertName, 7)); secretStore.setSecret(testKeyName, KeyUtils.toPem(testKeyPair.getPrivate()), 7); - secretStore.setSecret(testCertName, X509CertificateUtils.toPem(testCertificate)+X509CertificateUtils.toPem(testCertificate), 7); + secretStore.setSecret(testCertName, X509CertificateUtils.toPem(testCertificate) + X509CertificateUtils.toPem(testCertificate), 7); Optional<EndpointCertificateMetadata> endpointCertificateMetadata = endpointCertificateManager.getEndpointCertificateMetadata(testInstance, testZone, Optional.empty()); assertTrue(endpointCertificateMetadata.isPresent()); assertEquals(testKeyName, endpointCertificateMetadata.get().keyName()); @@ -101,7 +145,7 @@ public class EndpointCertificateManagerTest { secretStore.setSecret(testCertName, "cert", 7); secretStore.setSecret(testKeyName, KeyUtils.toPem(testKeyPair.getPrivate()), 8); secretStore.setSecret(testKeyName, KeyUtils.toPem(testKeyPair.getPrivate()), 9); - secretStore.setSecret(testCertName, X509CertificateUtils.toPem(testCertificate)+X509CertificateUtils.toPem(testCertificate), 8); + secretStore.setSecret(testCertName, X509CertificateUtils.toPem(testCertificate) + X509CertificateUtils.toPem(testCertificate), 8); mockCuratorDb.writeEndpointCertificateMetadata(testInstance.id(), new EndpointCertificateMetadata(testKeyName, testCertName, 7)); Optional<EndpointCertificateMetadata> endpointCertificateMetadata = endpointCertificateManager.getEndpointCertificateMetadata(testInstance, testZone, Optional.empty()); assertTrue(endpointCertificateMetadata.isPresent()); @@ -114,11 +158,28 @@ public class EndpointCertificateManagerTest { public void reprovisions_certificate_when_necessary() { mockCuratorDb.writeEndpointCertificateMetadata(testInstance.id(), new EndpointCertificateMetadata(testKeyName, testCertName, -1, Optional.of("uuid"), Optional.of(List.of()), Optional.empty())); secretStore.setSecret("vespa.tls.default.default.default-key", KeyUtils.toPem(testKeyPair.getPrivate()), 0); - secretStore.setSecret("vespa.tls.default.default.default-cert", X509CertificateUtils.toPem(testCertificate)+X509CertificateUtils.toPem(testCertificate), 0); + secretStore.setSecret("vespa.tls.default.default.default-cert", X509CertificateUtils.toPem(testCertificate) + X509CertificateUtils.toPem(testCertificate), 0); Optional<EndpointCertificateMetadata> endpointCertificateMetadata = endpointCertificateManager.getEndpointCertificateMetadata(testInstance, testZone, Optional.empty()); assertTrue(endpointCertificateMetadata.isPresent()); assertEquals(0, endpointCertificateMetadata.get().version()); assertEquals(endpointCertificateMetadata, mockCuratorDb.readEndpointCertificateMetadata(testInstance.id())); } + @Test + public void reprovisions_certificate_with_added_sans_when_deploying_to_new_zone() { + ZoneId testZone = zoneRegistryMock.zones().directlyRouted().in(Environment.prod).zones().stream().skip(1).findFirst().orElseThrow().getId(); + + mockCuratorDb.writeEndpointCertificateMetadata(testInstance.id(), new EndpointCertificateMetadata(testKeyName, testCertName, -1, Optional.of("uuid"), Optional.of(expectedSans), Optional.of("mockCa"))); + secretStore.setSecret("vespa.tls.default.default.default-key", KeyUtils.toPem(testKeyPair.getPrivate()), -1); + secretStore.setSecret("vespa.tls.default.default.default-cert", X509CertificateUtils.toPem(testCertificate) + X509CertificateUtils.toPem(testCertificate), -1); + + secretStore.setSecret("vespa.tls.default.default.default-key", KeyUtils.toPem(testKeyPair.getPrivate()), 0); + secretStore.setSecret("vespa.tls.default.default.default-cert", X509CertificateUtils.toPem(testCertificate2) + X509CertificateUtils.toPem(testCertificate2), 0); + + Optional<EndpointCertificateMetadata> endpointCertificateMetadata = endpointCertificateManager.getEndpointCertificateMetadata(testInstance, testZone, Optional.empty()); + assertTrue(endpointCertificateMetadata.isPresent()); + assertEquals(0, endpointCertificateMetadata.get().version()); + assertEquals(endpointCertificateMetadata, mockCuratorDb.readEndpointCertificateMetadata(testInstance.id())); + assertEquals(Set.copyOf(expectedCombinedSans), Set.copyOf(endpointCertificateMetadata.get().requestedDnsSans().orElseThrow())); + } } |