diff --git a/build.gradle b/build.gradle index e22ff88..f69c0bb 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'org.springframework.boot' version '2.7.3' id 'io.spring.dependency-management' version '1.0.13.RELEASE' id 'java' - id "io.github.itzg.simple-boot-image" version "0.5.0" + id 'io.github.itzg.simple-boot-image' version '0.5.1' // https://github.com/qoomon/gradle-git-versioning-plugin id 'me.qoomon.git-versioning' version '6.3.0' } @@ -41,8 +41,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'com.google.code.findbugs:jsr305:3.0.2' - implementation 'io.fabric8:kubernetes-client:5.12.2' + implementation 'io.fabric8:kubernetes-client:6.0.0' implementation 'com.nimbusds:nimbus-jose-jwt:9.24.2' implementation 'org.bouncycastle:bcpkix-jdk18on:1.71.1' diff --git a/k8s/deployment.yml b/k8s/deployment.yml index d80b28a..f1e2d91 100644 --- a/k8s/deployment.yml +++ b/k8s/deployment.yml @@ -22,6 +22,8 @@ spec: env: - name: LOGGING_LEVEL_APP value: DEBUG + - name: KITA_SOLVER_ROLE + value: solver-dev volumeMounts: - mountPath: /application/config name: configs diff --git a/k8s/service.yml b/k8s/service.yml index 1163488..acca8ec 100644 --- a/k8s/service.yml +++ b/k8s/service.yml @@ -4,7 +4,7 @@ metadata: name: kita-dev labels: app: kita-dev - acme.itzg.github.io/role: solver + acme.itzg.github.io/role: solver-dev spec: selector: app: kita-dev diff --git a/src/main/java/app/K8sIngressTlsAcmeApplication.java b/src/main/java/app/K8sIngressTlsAcmeApplication.java index 46aa62c..ae35a19 100644 --- a/src/main/java/app/K8sIngressTlsAcmeApplication.java +++ b/src/main/java/app/K8sIngressTlsAcmeApplication.java @@ -1,16 +1,15 @@ package app; -import app.config.AppProperties; import java.security.Security; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.web.reactive.config.EnableWebFlux; @SpringBootApplication @EnableScheduling -@EnableConfigurationProperties(AppProperties.class) +@ConfigurationPropertiesScan +//@EnableConfigurationProperties(AppProperties.class) public class K8sIngressTlsAcmeApplication { public static void main(String[] args) { diff --git a/src/main/java/app/config/AppProperties.java b/src/main/java/app/config/AppProperties.java index 31bc695..eae4fa5 100644 --- a/src/main/java/app/config/AppProperties.java +++ b/src/main/java/app/config/AppProperties.java @@ -4,11 +4,11 @@ import java.util.Map; import javax.validation.Valid; import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.DefaultValue; -import org.springframework.stereotype.Component; import org.springframework.validation.annotation.Validated; @ConfigurationProperties("kita") @@ -27,7 +27,14 @@ public record AppProperties( long maxAuthFinalizeAttempts, @DefaultValue("2s") @NotNull - Duration authFinalizeRetryDelay + Duration authFinalizeRetryDelay, + + boolean dryRun, + + @DefaultValue("solver") @NotBlank + String solverRole, + + String overrideIssuer ) { } diff --git a/src/main/java/app/services/AcmeAccountService.java b/src/main/java/app/services/AcmeAccountService.java index f3f80be..db7427c 100644 --- a/src/main/java/app/services/AcmeAccountService.java +++ b/src/main/java/app/services/AcmeAccountService.java @@ -2,36 +2,29 @@ import app.config.Issuer; import app.messages.AccountRequest; -import app.model.AcmeAccount; import app.messages.AccountResponse; +import app.model.AcmeAccount; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; -import java.util.Base64.Encoder; -import java.util.Collections; -import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; @Service @Slf4j public class AcmeAccountService { - private AcmeBaseRequestService baseRequestService; + private final AcmeBaseRequestService baseRequestService; private final AcmeDirectoryService directoryService; - private final Map accounts = Collections.synchronizedMap(new HashMap<>()); + private final Map> accounts = new ConcurrentHashMap<>(); public AcmeAccountService( AcmeDirectoryService directoryService, @@ -54,22 +47,17 @@ private static RSAKey generateJwk() { } public Mono accountForIssuer(String issuerId) { - synchronized (accounts) { - final AcmeAccount acmeAccount = accounts.get(issuerId); - if (acmeAccount != null) { - return Mono.just(acmeAccount); - } - - log.debug("Retrieving account for issuerId={}", issuerId); + return accounts.computeIfAbsent(issuerId, key -> { + final Issuer issuer = directoryService.issuerFor(key); - final Issuer issuer = directoryService.issuerFor(issuerId); - - return retrieveAccount(issuerId, issuer) - .doOnNext(account -> accounts.put(issuerId, account)); - } + return retrieveAccount(key, issuer) + .cache(); + }); } private Mono retrieveAccount(String issuerId, Issuer issuer) { + log.debug("Retrieving account for issuerId={}", issuerId); + final RSAKey jwk = generateJwk(); final URI newAccountUrl = directoryService.directoryFor(issuerId).newAccount(); diff --git a/src/main/java/app/services/AcmeBaseRequestService.java b/src/main/java/app/services/AcmeBaseRequestService.java index 7469eb2..e1a4d2c 100644 --- a/src/main/java/app/services/AcmeBaseRequestService.java +++ b/src/main/java/app/services/AcmeBaseRequestService.java @@ -4,17 +4,14 @@ import app.model.SignableValue; import com.nimbusds.jose.jwk.RSAKey; import java.net.URI; -import java.security.cert.X509Certificate; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient.ResponseSpec; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Service @@ -39,7 +36,7 @@ public Mono> request(String issuerId, RSAKey jwk, @Nullabl ) { log.debug("Creating POST for issuerId={} to url={} payload={}", issuerId, requestUrl, payload); - return preEntityRequest(issuerId, jwk, kid, requestUrl, payload, responseClass) + return preEntityRequest(issuerId, jwk, kid, requestUrl, payload) .toEntity(responseClass) .doOnNext(directoryService.latchNonce(issuerId)) .doOnNext(entity -> log.debug("Response status={} from url={} for issuerId={} body={}", @@ -47,10 +44,8 @@ public Mono> request(String issuerId, RSAKey jwk, @Nullabl )); } - @NotNull - private ResponseSpec preEntityRequest(String issuerId, RSAKey jwk, String kid, URI requestUrl, Object payload, - Class responseClass - ) { + @NonNull + private ResponseSpec preEntityRequest(String issuerId, RSAKey jwk, String kid, URI requestUrl, Object payload) { return webClient.post() .uri(requestUrl) .contentType(JwsMessageWriter.JOSE_JSON) diff --git a/src/main/java/app/services/ApplicationIngressesService.java b/src/main/java/app/services/ApplicationIngressesService.java index df70f42..d504c47 100644 --- a/src/main/java/app/services/ApplicationIngressesService.java +++ b/src/main/java/app/services/ApplicationIngressesService.java @@ -1,5 +1,6 @@ package app.services; +import app.config.AppProperties; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; import io.fabric8.kubernetes.api.model.networking.v1.IngressList; @@ -8,14 +9,26 @@ import io.fabric8.kubernetes.client.Watch; import io.fabric8.kubernetes.client.Watcher; import io.fabric8.kubernetes.client.WatcherException; +import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Base64.Decoder; import java.util.Collections; import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; import org.springframework.lang.NonNull; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -26,13 +39,18 @@ public class ApplicationIngressesService implements Closeable { private final KubernetesClient k8s; private final CertificateProcessingService certificateProcessingService; + private final AppProperties appProperties; private final Watch ingressWatches; private final Watch tlsSecretWatches; private final Set activeReconciles = Collections.synchronizedSet(new HashSet<>()); - public ApplicationIngressesService(KubernetesClient k8s, CertificateProcessingService certificateProcessingService) { + public ApplicationIngressesService(KubernetesClient k8s, + CertificateProcessingService certificateProcessingService, + AppProperties appProperties + ) { this.k8s = k8s; this.certificateProcessingService = certificateProcessingService; + this.appProperties = appProperties; this.ingressWatches = setupIngressWatch(); this.tlsSecretWatches = setupTlsSecretWatch(); @@ -74,7 +92,11 @@ public void onClose(WatcherException cause) { }); } - @Scheduled(fixedDelayString = "#{@'kita-app.config.AppProperties'.certRenewalCheckInterval}") + @Scheduled( + // initial ingress listing will handle reconciling at startup, so delay for given interval + initialDelayString = "#{@'kita-app.config.AppProperties'.certRenewalCheckInterval}", + fixedDelayString = "#{@'kita-app.config.AppProperties'.certRenewalCheckInterval}" + ) public void checkCertRenewals() { final IngressList ingresses = k8s.network().v1().ingresses() .withLabel(Metadata.ISSUER_LABEL) @@ -99,15 +121,19 @@ private void reconcileIngressTls(Ingress ingress) { .withName(tls.getSecretName()) .get(); - final String requestedIssuerId = ingress.getMetadata().getLabels().get(Metadata.ISSUER_LABEL); + final String requestedIssuerId = + appProperties.overrideIssuer() != null ? + appProperties.overrideIssuer() + : ingress.getMetadata().getLabels().get(Metadata.ISSUER_LABEL); + if (tlsSecret == null) { - initiateCertCreation(ingress, name, tls, requestedIssuerId); + initiateCertCreation(ingress, tls, requestedIssuerId); } else { final String tlsSecretIssuer = nullSafe(tlsSecret.getMetadata().getLabels()).get(Metadata.ISSUER_LABEL); - if (!Objects.equals(tlsSecretIssuer, requestedIssuerId)) { - initiateCertCreation(ingress, name, tls, requestedIssuerId); + if (!Objects.equals(tlsSecretIssuer, requestedIssuerId) + || needsRenewal(tlsSecret)) { + initiateCertCreation(ingress, tls, requestedIssuerId); } else { - // TODO is cert needing refresh activeReconciles.remove(name); } } @@ -115,17 +141,60 @@ private void reconcileIngressTls(Ingress ingress) { } - private void initiateCertCreation(Ingress ingress, String name, IngressTLS tls, String requestedIssuerId) { + private boolean needsRenewal(Secret tlsSecret) { + final String certContentEncoded = tlsSecret.getData().get("tls.crt"); + if (certContentEncoded != null) { + final Decoder decoder = Base64.getDecoder(); + + try (PemReader pemReader = new PemReader(new StringReader( + new String(decoder.decode(certContentEncoded), StandardCharsets.UTF_8) + ))) { + final PemObject pemObject = pemReader.readPemObject(); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + final X509Certificate cert = (X509Certificate) cf.generateCertificate( + new ByteArrayInputStream(pemObject.getContent())); + final Instant notAfter = cert.getNotAfter().toInstant(); + final Instant notBefore = cert.getNotBefore().toInstant(); + final Duration lifetime = Duration.between(notBefore, + // since it sets expiration just before and between's argument is exclusive + notAfter.plusSeconds(1) + ); + // LetsEncrypt recommends renewing when there is a 3rd of lifetime left + // https://letsencrypt.org/docs/integration-guide/#when-to-renew + if (Instant.now().isAfter(notAfter.minus(lifetime.dividedBy(3)))) { + log.info("TLS secret {} is due to be renewed since its lifetime is {} days and expires at {}", + tlsSecret.getMetadata().getName(), lifetime.toDays(), notAfter + ); + return true; + } + } catch (IOException e) { + log.error("Failed to read/close PEM reader", e); + } catch (CertificateException e) { + log.error("Failed to get X.509 cert factory", e); + } + } else { + log.error("TLS secret {} is missing tls.crt data", tlsSecret.getMetadata().getName()); + } + return false; + } + + private void initiateCertCreation(Ingress ingress, IngressTLS tls, String requestedIssuerId) { + final String ingressName = ingress.getMetadata().getName(); + if (appProperties.dryRun()) { + log.info("Skipping cert creation of {} for ingress {} since dry-run is enabled", + tls.getSecretName(), ingressName + ); + return; + } + certificateProcessingService.initiateCertCreation(ingress, tls, requestedIssuerId) - .subscribe(secret -> { + .subscribe(secret -> log.info("Cert creation complete for tls entry with secret={} hosts={} in ingress={}", - secret.getMetadata().getName(), tls.getHosts(), name - ); - }, - throwable -> { - log.warn("Problem while processing cert creation"); - }, - () -> activeReconciles.remove(name) + secret.getMetadata().getName(), tls.getHosts(), ingressName + ), + throwable -> log.warn("Problem while processing cert creation"), + () -> activeReconciles.remove(ingressName) ); } @@ -135,7 +204,7 @@ private Map nullSafe(Map value) { } @Override - public void close() throws IOException { + public void close() { ingressWatches.close(); tlsSecretWatches.close(); } diff --git a/src/main/java/app/services/CertificateProcessingService.java b/src/main/java/app/services/CertificateProcessingService.java index 02509ad..f7c0f42 100644 --- a/src/main/java/app/services/CertificateProcessingService.java +++ b/src/main/java/app/services/CertificateProcessingService.java @@ -143,7 +143,8 @@ private Secret storeSecret(String issuerId, List hosts, String certChain log.debug("Stored secret={}", secret.getMetadata().getName()); return k8s.secrets() - .createOrReplace(secret); + .resource(secret) + .createOrReplace(); } private CertAndKey buildCertAndKey(String certChain, PrivateKey privateKey) { diff --git a/src/main/java/app/services/JwsMessageWriter.java b/src/main/java/app/services/JwsMessageWriter.java index 5f79670..ee73eca 100644 --- a/src/main/java/app/services/JwsMessageWriter.java +++ b/src/main/java/app/services/JwsMessageWriter.java @@ -14,14 +14,17 @@ import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; import org.reactivestreams.Publisher; import org.springframework.core.ResolvableType; import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpOutputMessage; import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; +/** + * Applies a JSON Web Signature and encoding to an outgoing {@link SignableValue} + */ @Slf4j public class JwsMessageWriter implements HttpMessageWriter { @@ -41,7 +44,7 @@ public JwsMessageWriter(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } - @NotNull + @NonNull @Override public List getWritableMediaTypes() { return List.of(JOSE_JSON); diff --git a/src/main/java/app/services/Metadata.java b/src/main/java/app/services/Metadata.java index 30be64d..cca53f4 100644 --- a/src/main/java/app/services/Metadata.java +++ b/src/main/java/app/services/Metadata.java @@ -4,7 +4,6 @@ public class Metadata { public static final String NAMESPACE = "acme.itzg.github.io"; public static final String ROLE_LABEL = NAMESPACE + "/role"; - public static final String SOLVER_ROLE = "solver"; public static final String HOST_ANNOTATION = NAMESPACE + "/host"; diff --git a/src/main/java/app/services/SolverService.java b/src/main/java/app/services/SolverService.java index 4612329..9ee9a73 100644 --- a/src/main/java/app/services/SolverService.java +++ b/src/main/java/app/services/SolverService.java @@ -1,5 +1,6 @@ package app.services; +import app.config.AppProperties; import app.controllers.AcmeChallengeController; import app.controllers.AcmeChallengeController.PreparedChallenge; import io.fabric8.kubernetes.api.model.LoadBalancerIngress; @@ -36,22 +37,26 @@ public class SolverService { private final KubernetesClient k8s; private final AcmeChallengeController acmeChallengeController; + private final AppProperties appProperties; - public SolverService(KubernetesClient k8s, AcmeChallengeController acmeChallengeController) { + public SolverService(KubernetesClient k8s, AcmeChallengeController acmeChallengeController, + AppProperties appProperties + ) { this.k8s = k8s; this.acmeChallengeController = acmeChallengeController; + this.appProperties = appProperties; } Mono solverService() { final One sink = Sinks.one(); log.debug("Locating solver service resource with label {}={} via watch", - Metadata.ROLE_LABEL, Metadata.SOLVER_ROLE + Metadata.ROLE_LABEL, appProperties.solverRole() ); //noinspection resource closed in mono below final Watch watch = k8s.services() - .withLabel(Metadata.ROLE_LABEL, Metadata.SOLVER_ROLE) + .withLabel(Metadata.ROLE_LABEL, appProperties.solverRole()) .watch(new Watcher<>() { @Override @@ -88,7 +93,9 @@ public Mono setupSolverIngress(String issuerId, String ingressClas final Ingress ingress = createSolverIngress(issuerId, ingressClassName, ingressName, host, service, preparedChallenge ); - log.debug("Created ingress={} for solving challenge for host={}. Waiting for ingress to be ready...", ingressName, host); + log.debug("Created ingress={} for solving challenge for host={}. Waiting for ingress to be ready...", ingressName, + host + ); return emitWhenIngressReady(ingress) .map(readyIngress -> IngressSetup.builder() @@ -132,12 +139,12 @@ private Ingress createSolverIngress( ) { log.debug("Creating solver ingress={} with ingressClass={}", ingressName, ingressClassName); return k8s.network().v1().ingresses() - .createOrReplace( + .resource( new IngressBuilder() .withMetadata(new ObjectMetaBuilder() .withName(ingressName) .withLabels(Map.of( - Metadata.ROLE_LABEL, Metadata.SOLVER_ROLE, + Metadata.ROLE_LABEL, appProperties.solverRole(), Metadata.ISSUER_LABEL, issuerId )) .withAnnotations(Map.of( @@ -172,7 +179,8 @@ private Ingress createSolverIngress( .build() ) .build() - ); + ) + .createOrReplace(); } private ServiceBackendPort portForIngressFromService(io.fabric8.kubernetes.api.model.Service service) { @@ -214,7 +222,8 @@ private String buildIngressName(String serviceName, String host) { public void removeSolverIngress(Ingress ingress, String token) { log.debug("Deleting solver ingress named={}", ingress.getMetadata().getName()); k8s.network().v1().ingresses() - .delete(ingress); + .resource(ingress) + .delete(); acmeChallengeController.removeChallenge(token); }