diff --git a/acme/src/main/java/io/micronaut/acme/background/AcmeCertRefresherTask.java b/acme/src/main/java/io/micronaut/acme/background/AcmeCertRefresherTask.java index 605643d0..2abaf8bc 100644 --- a/acme/src/main/java/io/micronaut/acme/background/AcmeCertRefresherTask.java +++ b/acme/src/main/java/io/micronaut/acme/background/AcmeCertRefresherTask.java @@ -17,20 +17,24 @@ import io.micronaut.acme.AcmeConfiguration; import io.micronaut.acme.services.AcmeService; +import io.micronaut.context.event.StartupEvent; +import io.micronaut.http.server.exceptions.ServerStartupException; import io.micronaut.runtime.event.ApplicationStartupEvent; import io.micronaut.runtime.event.annotation.EventListener; import io.micronaut.runtime.exceptions.ApplicationStartupException; import io.micronaut.scheduling.annotation.Scheduled; import jakarta.inject.Singleton; +import org.shredzone.acme4j.Order; import org.shredzone.acme4j.exception.AcmeException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.security.cert.X509Certificate; -import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; +import java.util.Optional; + +import static java.time.Instant.now; +import static java.time.temporal.ChronoUnit.SECONDS; /** * Background task to automatically refresh the certificates from an ACME server on a configurable interval. @@ -42,6 +46,7 @@ public final class AcmeCertRefresherTask { private AcmeService acmeService; private final AcmeConfiguration acmeConfiguration; + private Order order; /** * Constructs a new Acme cert refresher background task. @@ -69,33 +74,53 @@ void backgroundRenewal() throws AcmeException { renewCertIfNeeded(); } - /** - * Checks to see if certificate needs renewed on app startup. - * - * @param startupEvent Startup event - */ @EventListener - void onStartup(ApplicationStartupEvent startupEvent) { + void onServerStartup(StartupEvent startupEvent) { try { if (LOG.isDebugEnabled()) { - LOG.debug("Running startup renewal process"); + LOG.debug("Running server startup setup process"); + } + if (!acmeConfiguration.isTosAgree()) { + throw new IllegalStateException(String.format("Cannot refresh certificates until terms of service is accepted. Please review the TOS for Let's Encrypt and set \"%s\" to \"%s\" in configuration once complete", "acme.tos-agree", "true")); + } + if (needsToOrderNewCertificate()) { + order = acmeService.createOrder(getDomains()); + } else { + acmeService.setupCurrentCertificate(); } - renewCertIfNeeded(); } catch (Exception e) { //NOSONAR LOG.error("Failed to initialize certificate for SSL no requests would be secure. Stopping application", e); - throw new ApplicationStartupException("Failed to start due to SSL configuration issue.", e); + throw new ServerStartupException("Failed to start due to SSL configuration issue.", e); } } /** - * Does the work to actually renew the certificate if it needs to be done. - * @throws AcmeException if any issues occur during certificate renewal + * Checks to see if certificate needs renewed on app startup. + * + * @param startupEvent Startup event */ - protected void renewCertIfNeeded() throws AcmeException { - if (!acmeConfiguration.isTosAgree()) { - throw new IllegalStateException(String.format("Cannot refresh certificates until terms of service is accepted. Please review the TOS for Let's Encrypt and set \"%s\" to \"%s\" in configuration once complete", "acme.tos-agree", "true")); + @EventListener + void onStartup(ApplicationStartupEvent startupEvent) { + if (needsToOrderNewCertificate()) { + try { + if (LOG.isDebugEnabled()) { + LOG.debug("Running application startup order/authorization process"); + } + acmeService.authorizeCertificate(getDomains(), order); + } catch (Exception e) { + LOG.error("Failed to initialize certificate for SSL no requests would be secure. Stopping application", e); + throw new ApplicationStartupException("Failed to start due to SSL configuration issue.", e); + } } + } + private boolean needsToOrderNewCertificate() { + return Optional.ofNullable(acmeService.getCurrentCertificate()) + .map(c -> SECONDS.between(now(), c.getNotAfter().toInstant()) <= acmeConfiguration.getRenewWitin().getSeconds()) + .orElse(true); + } + + private List getDomains() { List domains = new ArrayList<>(); for (String domain : acmeConfiguration.getDomains()) { domains.add(domain); @@ -107,17 +132,19 @@ protected void renewCertIfNeeded() throws AcmeException { domains.add(baseDomain); } } + return domains; + } - X509Certificate currentCertificate = acmeService.getCurrentCertificate(); - if (currentCertificate != null) { - long daysTillExpiration = ChronoUnit.SECONDS.between(Instant.now(), currentCertificate.getNotAfter().toInstant()); - - if (daysTillExpiration <= acmeConfiguration.getRenewWitin().getSeconds()) { - acmeService.orderCertificate(domains); - } else { - acmeService.setupCurrentCertificate(); - } - } else { + /** + * Does the work to actually renew the certificate if it needs to be done. + * @throws AcmeException if any issues occur during certificate renewal + */ + protected void renewCertIfNeeded() throws AcmeException { + if (!acmeConfiguration.isTosAgree()) { + throw new IllegalStateException(String.format("Cannot refresh certificates until terms of service is accepted. Please review the TOS for Let's Encrypt and set \"%s\" to \"%s\" in configuration once complete", "acme.tos-agree", "true")); + } + List domains = getDomains(); + if (needsToOrderNewCertificate()) { acmeService.orderCertificate(domains); } } diff --git a/acme/src/main/java/io/micronaut/acme/services/AcmeService.java b/acme/src/main/java/io/micronaut/acme/services/AcmeService.java index ff183ba6..fba08699 100644 --- a/acme/src/main/java/io/micronaut/acme/services/AcmeService.java +++ b/acme/src/main/java/io/micronaut/acme/services/AcmeService.java @@ -174,8 +174,20 @@ protected Optional getFullCertificateChain() { * @throws AcmeException if any issues occur during ordering of certificate */ public void orderCertificate(List domains) throws AcmeException { - AtomicInteger orderRetryAttempts = new AtomicInteger(acmeConfiguration.getOrder().getRefreshAttempts()); + final Order order = createOrder(domains); + authorizeCertificate(domains, order); + } + /** + * Orders a new certificate using ACME protocol. + * + * @param domains List of domains to order a certificate for + * @throws AcmeException if any issues occur during ordering of certificate + * + * @return order for the given list of domains + * @since 4.1 + */ + public Order createOrder(List domains) throws AcmeException { Session session = new Session(acmeServerUrl); if (timeout != null) { session.networkSettings().setTimeout(timeout); @@ -185,14 +197,27 @@ public void orderCertificate(List domains) throws AcmeException { try { accountKeyPair = getKeyPairFromConfigValue(this.accountKeyString); } catch (IOException e) { - if (LOG.isErrorEnabled()) { - LOG.error("ACME certificate order failed. Failed to read the account keys", e); - } - return; + throw new AcmeException("ACME certificate order failed. Failed to read the account keys", e); } Login login = doLogin(session, accountKeyPair); - Order order = createOrder(domains, login); + return createOrder(domains, login); + } + + /** + * Authorizes an order and if successful emits a certificate via an event. + * + * @param domains List of domains to order a certificate for + * @param order acme order for the given set of domains + * @throws AcmeException if any issues occur during authorization of a certificate order + * @since 4.1 + */ + public void authorizeCertificate(List domains, Order order) throws AcmeException { + if (order == null) { + throw new AcmeException("Order is required before you can authorize it."); + } + + AtomicInteger orderRetryAttempts = new AtomicInteger(acmeConfiguration.getOrder().getRefreshAttempts()); for (Authorization auth : order.getAuthorizations()) { try { authorize(auth); diff --git a/acme/src/test/groovy/io/micronaut/acme/AcmeCertRefresherTaskSetsTimeoutSpec.groovy b/acme/src/test/groovy/io/micronaut/acme/AcmeCertRefresherTaskSetsTimeoutSpec.groovy index 1a2c64e2..f701db8b 100644 --- a/acme/src/test/groovy/io/micronaut/acme/AcmeCertRefresherTaskSetsTimeoutSpec.groovy +++ b/acme/src/test/groovy/io/micronaut/acme/AcmeCertRefresherTaskSetsTimeoutSpec.groovy @@ -2,6 +2,7 @@ package io.micronaut.acme import io.micronaut.context.ApplicationContext import io.micronaut.core.io.socket.SocketUtils +import io.micronaut.http.server.exceptions.ServerStartupException import io.micronaut.mock.slow.SlowAcmeServer import io.micronaut.mock.slow.SlowServerConfig import io.micronaut.runtime.exceptions.ApplicationStartupException @@ -89,7 +90,7 @@ class AcmeCertRefresherTaskSetsTimeoutSpec extends Specification { ] as Map } - def "validate timeout applied if signup is #config"(SlowServerConfig config) { + def "validate timeout applied if signup is #config"(SlowServerConfig config, Class exType) { given: "we have all the ports we could ever need" expectedHttpPort = SocketUtils.findAvailableTcpPort() expectedSecurePort = SocketUtils.findAvailableTcpPort() @@ -112,7 +113,9 @@ class AcmeCertRefresherTaskSetsTimeoutSpec extends Specification { "test") then: "we get network errors b/c of the timeout" - ApplicationStartupException ex = thrown() + def ex = thrown(Throwable) + + ex.class == exType def ane = ExceptionUtils.getThrowables(ex).find { it instanceof AcmeNetworkException } ane?.message == "Network error" @@ -126,10 +129,10 @@ class AcmeCertRefresherTaskSetsTimeoutSpec extends Specification { mockAcmeServer?.stop() where: - config | _ - new ActualSlowServerConfig(slowSignup: true) | _ - new ActualSlowServerConfig(slowOrdering: true) | _ - new ActualSlowServerConfig(slowAuthorization: true) | _ + config | exType + new ActualSlowServerConfig(slowSignup: true) | ServerStartupException + new ActualSlowServerConfig(slowOrdering: true) | ServerStartupException + new ActualSlowServerConfig(slowAuthorization: true) | ApplicationStartupException } class ActualSlowServerConfig implements SlowServerConfig { diff --git a/acme/src/test/groovy/io/micronaut/acme/AcmeCertRefresherTaskUnitSpec.groovy b/acme/src/test/groovy/io/micronaut/acme/AcmeCertRefresherTaskUnitSpec.groovy index e4989581..0a28061f 100644 --- a/acme/src/test/groovy/io/micronaut/acme/AcmeCertRefresherTaskUnitSpec.groovy +++ b/acme/src/test/groovy/io/micronaut/acme/AcmeCertRefresherTaskUnitSpec.groovy @@ -2,11 +2,10 @@ package io.micronaut.acme import io.micronaut.acme.background.AcmeCertRefresherTask import io.micronaut.acme.services.AcmeService -import io.micronaut.runtime.EmbeddedApplication -import io.micronaut.runtime.event.ApplicationStartupEvent -import io.micronaut.runtime.exceptions.ApplicationStartupException +import io.micronaut.http.server.exceptions.ServerStartupException import io.netty.handler.ssl.util.SelfSignedCertificate import org.shredzone.acme4j.exception.AcmeException +import org.testcontainers.shaded.org.apache.commons.lang3.exception.ExceptionUtils import spock.lang.Specification import spock.lang.Stepwise import spock.lang.Unroll @@ -21,7 +20,22 @@ class AcmeCertRefresherTaskUnitSpec extends Specification { def task = new AcmeCertRefresherTask(Mock(AcmeService), Mock(AcmeConfiguration)) when: - task.renewCertIfNeeded() + task.onServerStartup(null) + + then: + def ex = thrown(ServerStartupException.class) + + Throwable rootEx = ExceptionUtils.getRootCause(ex) + rootEx instanceof IllegalStateException + rootEx.message == "Cannot refresh certificates until terms of service is accepted. Please review the TOS for Let's Encrypt and set \"acme.tos-agree\" to \"true\" in configuration once complete" + } + + def "throw exception if TOS has not been accepted when doing background reneal"() { + given: + def task = new AcmeCertRefresherTask(Mock(AcmeService), Mock(AcmeConfiguration)) + + when: + task.backgroundRenewal() then: def ex = thrown(IllegalStateException.class) @@ -37,7 +51,7 @@ class AcmeCertRefresherTaskUnitSpec extends Specification { def task = new AcmeCertRefresherTask(mockAcmeSerivce, config) when: - task.renewCertIfNeeded() + task.onServerStartup(null) then: 1 * mockAcmeSerivce.getCurrentCertificate() >> new SelfSignedCertificate(expectedDomain, new Date(), new Date() + 31).cert() @@ -54,11 +68,11 @@ class AcmeCertRefresherTaskUnitSpec extends Specification { def task = new AcmeCertRefresherTask(mockAcmeSerivce, config) when: - task.renewCertIfNeeded() + task.onServerStartup(null) then: 1 * mockAcmeSerivce.getCurrentCertificate() >> new SelfSignedCertificate(expectedDomain, new Date(), new Date() + 31).cert() - 1 * mockAcmeSerivce.orderCertificate([expectedDomain]) + 1 * mockAcmeSerivce.createOrder([expectedDomain]) where: daysToRenew | description @@ -75,15 +89,15 @@ class AcmeCertRefresherTaskUnitSpec extends Specification { def task = new AcmeCertRefresherTask(mockAcmeSerivce, config) when: - task.onStartup(new ApplicationStartupEvent(Mock(EmbeddedApplication))) + task.onServerStartup(null) then: - def ex = thrown(ApplicationStartupException) + def ex = thrown(ServerStartupException) ex.message == "Failed to start due to SSL configuration issue." and: 1 * mockAcmeSerivce.getCurrentCertificate() >> null - 1 * mockAcmeSerivce.orderCertificate(expectedDomains) >> { List domains -> + 1 * mockAcmeSerivce.createOrder(expectedDomains) >> { List domains -> throw new AcmeException("Failed to do some ACME related task") } }