Skip to content

Commit aa95497

Browse files
authored
Initiates renewal of certificates during last third of lifetime (#15)
Also * Upgraded kubernetes-client * Configurable solver role to locate * Option to dry-run * Development option to override issuer * Improved concurrent access of account retrieval
1 parent 520a758 commit aa95497

12 files changed

+142
-71
lines changed

build.gradle

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ plugins {
22
id 'org.springframework.boot' version '2.7.3'
33
id 'io.spring.dependency-management' version '1.0.13.RELEASE'
44
id 'java'
5-
id "io.github.itzg.simple-boot-image" version "0.5.0"
5+
id 'io.github.itzg.simple-boot-image' version '0.5.1'
66
// https://github.com/qoomon/gradle-git-versioning-plugin
77
id 'me.qoomon.git-versioning' version '6.3.0'
88
}
@@ -41,8 +41,7 @@ dependencies {
4141
implementation 'org.springframework.boot:spring-boot-starter-actuator'
4242
implementation 'org.springframework.boot:spring-boot-starter-webflux'
4343
implementation 'org.springframework.boot:spring-boot-starter-validation'
44-
implementation 'com.google.code.findbugs:jsr305:3.0.2'
45-
implementation 'io.fabric8:kubernetes-client:5.12.2'
44+
implementation 'io.fabric8:kubernetes-client:6.0.0'
4645
implementation 'com.nimbusds:nimbus-jose-jwt:9.24.2'
4746
implementation 'org.bouncycastle:bcpkix-jdk18on:1.71.1'
4847

k8s/deployment.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ spec:
2222
env:
2323
- name: LOGGING_LEVEL_APP
2424
value: DEBUG
25+
- name: KITA_SOLVER_ROLE
26+
value: solver-dev
2527
volumeMounts:
2628
- mountPath: /application/config
2729
name: configs

k8s/service.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ metadata:
44
name: kita-dev
55
labels:
66
app: kita-dev
7-
acme.itzg.github.io/role: solver
7+
acme.itzg.github.io/role: solver-dev
88
spec:
99
selector:
1010
app: kita-dev

src/main/java/app/K8sIngressTlsAcmeApplication.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
package app;
22

3-
import app.config.AppProperties;
43
import java.security.Security;
54
import org.springframework.boot.SpringApplication;
65
import org.springframework.boot.autoconfigure.SpringBootApplication;
7-
import org.springframework.boot.context.properties.EnableConfigurationProperties;
6+
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
87
import org.springframework.scheduling.annotation.EnableScheduling;
9-
import org.springframework.web.reactive.config.EnableWebFlux;
108

119
@SpringBootApplication
1210
@EnableScheduling
13-
@EnableConfigurationProperties(AppProperties.class)
11+
@ConfigurationPropertiesScan
12+
//@EnableConfigurationProperties(AppProperties.class)
1413
public class K8sIngressTlsAcmeApplication {
1514

1615
public static void main(String[] args) {

src/main/java/app/config/AppProperties.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
import java.util.Map;
55
import javax.validation.Valid;
66
import javax.validation.constraints.Min;
7+
import javax.validation.constraints.NotBlank;
78
import javax.validation.constraints.NotEmpty;
89
import javax.validation.constraints.NotNull;
910
import org.springframework.boot.context.properties.ConfigurationProperties;
1011
import org.springframework.boot.context.properties.bind.DefaultValue;
11-
import org.springframework.stereotype.Component;
1212
import org.springframework.validation.annotation.Validated;
1313

1414
@ConfigurationProperties("kita")
@@ -27,7 +27,14 @@ public record AppProperties(
2727
long maxAuthFinalizeAttempts,
2828

2929
@DefaultValue("2s") @NotNull
30-
Duration authFinalizeRetryDelay
30+
Duration authFinalizeRetryDelay,
31+
32+
boolean dryRun,
33+
34+
@DefaultValue("solver") @NotBlank
35+
String solverRole,
36+
37+
String overrideIssuer
3138
) {
3239

3340
}

src/main/java/app/services/AcmeAccountService.java

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,29 @@
22

33
import app.config.Issuer;
44
import app.messages.AccountRequest;
5-
import app.model.AcmeAccount;
65
import app.messages.AccountResponse;
6+
import app.model.AcmeAccount;
77
import com.nimbusds.jose.JOSEException;
88
import com.nimbusds.jose.JWSAlgorithm;
99
import com.nimbusds.jose.jwk.KeyUse;
1010
import com.nimbusds.jose.jwk.RSAKey;
1111
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
1212
import java.net.URI;
13-
import java.nio.charset.StandardCharsets;
14-
import java.security.MessageDigest;
15-
import java.security.NoSuchAlgorithmException;
16-
import java.util.Base64;
17-
import java.util.Base64.Encoder;
18-
import java.util.Collections;
19-
import java.util.HashMap;
2013
import java.util.Map;
2114
import java.util.Objects;
2215
import java.util.UUID;
16+
import java.util.concurrent.ConcurrentHashMap;
2317
import lombok.extern.slf4j.Slf4j;
2418
import org.springframework.stereotype.Service;
25-
import org.springframework.web.reactive.function.client.WebClient;
2619
import reactor.core.publisher.Mono;
2720

2821
@Service
2922
@Slf4j
3023
public class AcmeAccountService {
3124

32-
private AcmeBaseRequestService baseRequestService;
25+
private final AcmeBaseRequestService baseRequestService;
3326
private final AcmeDirectoryService directoryService;
34-
private final Map<String, AcmeAccount> accounts = Collections.synchronizedMap(new HashMap<>());
27+
private final Map<String, Mono<AcmeAccount>> accounts = new ConcurrentHashMap<>();
3528

3629
public AcmeAccountService(
3730
AcmeDirectoryService directoryService,
@@ -54,22 +47,17 @@ private static RSAKey generateJwk() {
5447
}
5548

5649
public Mono<AcmeAccount> accountForIssuer(String issuerId) {
57-
synchronized (accounts) {
58-
final AcmeAccount acmeAccount = accounts.get(issuerId);
59-
if (acmeAccount != null) {
60-
return Mono.just(acmeAccount);
61-
}
62-
63-
log.debug("Retrieving account for issuerId={}", issuerId);
50+
return accounts.computeIfAbsent(issuerId, key -> {
51+
final Issuer issuer = directoryService.issuerFor(key);
6452

65-
final Issuer issuer = directoryService.issuerFor(issuerId);
66-
67-
return retrieveAccount(issuerId, issuer)
68-
.doOnNext(account -> accounts.put(issuerId, account));
69-
}
53+
return retrieveAccount(key, issuer)
54+
.cache();
55+
});
7056
}
7157

7258
private Mono<AcmeAccount> retrieveAccount(String issuerId, Issuer issuer) {
59+
log.debug("Retrieving account for issuerId={}", issuerId);
60+
7361
final RSAKey jwk = generateJwk();
7462

7563
final URI newAccountUrl = directoryService.directoryFor(issuerId).newAccount();

src/main/java/app/services/AcmeBaseRequestService.java

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,14 @@
44
import app.model.SignableValue;
55
import com.nimbusds.jose.jwk.RSAKey;
66
import java.net.URI;
7-
import java.security.cert.X509Certificate;
87
import lombok.extern.slf4j.Slf4j;
9-
import org.jetbrains.annotations.NotNull;
108
import org.springframework.http.HttpStatus;
11-
import org.springframework.http.MediaType;
129
import org.springframework.http.ResponseEntity;
10+
import org.springframework.lang.NonNull;
1311
import org.springframework.lang.Nullable;
1412
import org.springframework.stereotype.Service;
1513
import org.springframework.web.reactive.function.client.WebClient;
1614
import org.springframework.web.reactive.function.client.WebClient.ResponseSpec;
17-
import reactor.core.publisher.Flux;
1815
import reactor.core.publisher.Mono;
1916

2017
@Service
@@ -39,18 +36,16 @@ public <T> Mono<ResponseEntity<T>> request(String issuerId, RSAKey jwk, @Nullabl
3936
) {
4037
log.debug("Creating POST for issuerId={} to url={} payload={}", issuerId, requestUrl, payload);
4138

42-
return preEntityRequest(issuerId, jwk, kid, requestUrl, payload, responseClass)
39+
return preEntityRequest(issuerId, jwk, kid, requestUrl, payload)
4340
.toEntity(responseClass)
4441
.doOnNext(directoryService.latchNonce(issuerId))
4542
.doOnNext(entity -> log.debug("Response status={} from url={} for issuerId={} body={}",
4643
entity.getStatusCode(), requestUrl, issuerId, entity.getBody()
4744
));
4845
}
4946

50-
@NotNull
51-
private <T> ResponseSpec preEntityRequest(String issuerId, RSAKey jwk, String kid, URI requestUrl, Object payload,
52-
Class<T> responseClass
53-
) {
47+
@NonNull
48+
private ResponseSpec preEntityRequest(String issuerId, RSAKey jwk, String kid, URI requestUrl, Object payload) {
5449
return webClient.post()
5550
.uri(requestUrl)
5651
.contentType(JwsMessageWriter.JOSE_JSON)

src/main/java/app/services/ApplicationIngressesService.java

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package app.services;
22

3+
import app.config.AppProperties;
34
import io.fabric8.kubernetes.api.model.Secret;
45
import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
56
import io.fabric8.kubernetes.api.model.networking.v1.IngressList;
@@ -8,14 +9,26 @@
89
import io.fabric8.kubernetes.client.Watch;
910
import io.fabric8.kubernetes.client.Watcher;
1011
import io.fabric8.kubernetes.client.WatcherException;
12+
import java.io.ByteArrayInputStream;
1113
import java.io.Closeable;
1214
import java.io.IOException;
15+
import java.io.StringReader;
16+
import java.nio.charset.StandardCharsets;
17+
import java.security.cert.CertificateException;
18+
import java.security.cert.CertificateFactory;
19+
import java.security.cert.X509Certificate;
20+
import java.time.Duration;
21+
import java.time.Instant;
22+
import java.util.Base64;
23+
import java.util.Base64.Decoder;
1324
import java.util.Collections;
1425
import java.util.HashSet;
1526
import java.util.Map;
1627
import java.util.Objects;
1728
import java.util.Set;
1829
import lombok.extern.slf4j.Slf4j;
30+
import org.bouncycastle.util.io.pem.PemObject;
31+
import org.bouncycastle.util.io.pem.PemReader;
1932
import org.springframework.lang.NonNull;
2033
import org.springframework.scheduling.annotation.Scheduled;
2134
import org.springframework.stereotype.Service;
@@ -26,13 +39,18 @@ public class ApplicationIngressesService implements Closeable {
2639

2740
private final KubernetesClient k8s;
2841
private final CertificateProcessingService certificateProcessingService;
42+
private final AppProperties appProperties;
2943
private final Watch ingressWatches;
3044
private final Watch tlsSecretWatches;
3145
private final Set<String/*ingress name*/> activeReconciles = Collections.synchronizedSet(new HashSet<>());
3246

33-
public ApplicationIngressesService(KubernetesClient k8s, CertificateProcessingService certificateProcessingService) {
47+
public ApplicationIngressesService(KubernetesClient k8s,
48+
CertificateProcessingService certificateProcessingService,
49+
AppProperties appProperties
50+
) {
3451
this.k8s = k8s;
3552
this.certificateProcessingService = certificateProcessingService;
53+
this.appProperties = appProperties;
3654

3755
this.ingressWatches = setupIngressWatch();
3856
this.tlsSecretWatches = setupTlsSecretWatch();
@@ -74,7 +92,11 @@ public void onClose(WatcherException cause) {
7492
});
7593
}
7694

77-
@Scheduled(fixedDelayString = "#{@'kita-app.config.AppProperties'.certRenewalCheckInterval}")
95+
@Scheduled(
96+
// initial ingress listing will handle reconciling at startup, so delay for given interval
97+
initialDelayString = "#{@'kita-app.config.AppProperties'.certRenewalCheckInterval}",
98+
fixedDelayString = "#{@'kita-app.config.AppProperties'.certRenewalCheckInterval}"
99+
)
78100
public void checkCertRenewals() {
79101
final IngressList ingresses = k8s.network().v1().ingresses()
80102
.withLabel(Metadata.ISSUER_LABEL)
@@ -99,33 +121,80 @@ private void reconcileIngressTls(Ingress ingress) {
99121
.withName(tls.getSecretName())
100122
.get();
101123

102-
final String requestedIssuerId = ingress.getMetadata().getLabels().get(Metadata.ISSUER_LABEL);
124+
final String requestedIssuerId =
125+
appProperties.overrideIssuer() != null ?
126+
appProperties.overrideIssuer()
127+
: ingress.getMetadata().getLabels().get(Metadata.ISSUER_LABEL);
128+
103129
if (tlsSecret == null) {
104-
initiateCertCreation(ingress, name, tls, requestedIssuerId);
130+
initiateCertCreation(ingress, tls, requestedIssuerId);
105131
} else {
106132
final String tlsSecretIssuer = nullSafe(tlsSecret.getMetadata().getLabels()).get(Metadata.ISSUER_LABEL);
107-
if (!Objects.equals(tlsSecretIssuer, requestedIssuerId)) {
108-
initiateCertCreation(ingress, name, tls, requestedIssuerId);
133+
if (!Objects.equals(tlsSecretIssuer, requestedIssuerId)
134+
|| needsRenewal(tlsSecret)) {
135+
initiateCertCreation(ingress, tls, requestedIssuerId);
109136
} else {
110-
// TODO is cert needing refresh
111137
activeReconciles.remove(name);
112138
}
113139
}
114140
}
115141

116142
}
117143

118-
private void initiateCertCreation(Ingress ingress, String name, IngressTLS tls, String requestedIssuerId) {
144+
private boolean needsRenewal(Secret tlsSecret) {
145+
final String certContentEncoded = tlsSecret.getData().get("tls.crt");
146+
if (certContentEncoded != null) {
147+
final Decoder decoder = Base64.getDecoder();
148+
149+
try (PemReader pemReader = new PemReader(new StringReader(
150+
new String(decoder.decode(certContentEncoded), StandardCharsets.UTF_8)
151+
))) {
152+
final PemObject pemObject = pemReader.readPemObject();
153+
154+
CertificateFactory cf = CertificateFactory.getInstance("X.509");
155+
final X509Certificate cert = (X509Certificate) cf.generateCertificate(
156+
new ByteArrayInputStream(pemObject.getContent()));
157+
final Instant notAfter = cert.getNotAfter().toInstant();
158+
final Instant notBefore = cert.getNotBefore().toInstant();
159+
final Duration lifetime = Duration.between(notBefore,
160+
// since it sets expiration just before and between's argument is exclusive
161+
notAfter.plusSeconds(1)
162+
);
163+
// LetsEncrypt recommends renewing when there is a 3rd of lifetime left
164+
// https://letsencrypt.org/docs/integration-guide/#when-to-renew
165+
if (Instant.now().isAfter(notAfter.minus(lifetime.dividedBy(3)))) {
166+
log.info("TLS secret {} is due to be renewed since its lifetime is {} days and expires at {}",
167+
tlsSecret.getMetadata().getName(), lifetime.toDays(), notAfter
168+
);
169+
return true;
170+
}
171+
} catch (IOException e) {
172+
log.error("Failed to read/close PEM reader", e);
173+
} catch (CertificateException e) {
174+
log.error("Failed to get X.509 cert factory", e);
175+
}
176+
} else {
177+
log.error("TLS secret {} is missing tls.crt data", tlsSecret.getMetadata().getName());
178+
}
179+
return false;
180+
}
181+
182+
private void initiateCertCreation(Ingress ingress, IngressTLS tls, String requestedIssuerId) {
183+
final String ingressName = ingress.getMetadata().getName();
184+
if (appProperties.dryRun()) {
185+
log.info("Skipping cert creation of {} for ingress {} since dry-run is enabled",
186+
tls.getSecretName(), ingressName
187+
);
188+
return;
189+
}
190+
119191
certificateProcessingService.initiateCertCreation(ingress, tls, requestedIssuerId)
120-
.subscribe(secret -> {
192+
.subscribe(secret ->
121193
log.info("Cert creation complete for tls entry with secret={} hosts={} in ingress={}",
122-
secret.getMetadata().getName(), tls.getHosts(), name
123-
);
124-
},
125-
throwable -> {
126-
log.warn("Problem while processing cert creation");
127-
},
128-
() -> activeReconciles.remove(name)
194+
secret.getMetadata().getName(), tls.getHosts(), ingressName
195+
),
196+
throwable -> log.warn("Problem while processing cert creation"),
197+
() -> activeReconciles.remove(ingressName)
129198
);
130199
}
131200

@@ -135,7 +204,7 @@ private Map<String, String> nullSafe(Map<String, String> value) {
135204
}
136205

137206
@Override
138-
public void close() throws IOException {
207+
public void close() {
139208
ingressWatches.close();
140209
tlsSecretWatches.close();
141210
}

src/main/java/app/services/CertificateProcessingService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ private Secret storeSecret(String issuerId, List<String> hosts, String certChain
143143
log.debug("Stored secret={}", secret.getMetadata().getName());
144144

145145
return k8s.secrets()
146-
.createOrReplace(secret);
146+
.resource(secret)
147+
.createOrReplace();
147148
}
148149

149150
private CertAndKey buildCertAndKey(String certChain, PrivateKey privateKey) {

0 commit comments

Comments
 (0)