Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Moving and shakin #23

Open
wants to merge 12 commits into
base: 5.1.x
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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<String> getDomains() {
List<String> domains = new ArrayList<>();
for (String domain : acmeConfiguration.getDomains()) {
domains.add(domain);
Expand All @@ -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<String> domains = getDomains();
if (needsToOrderNewCertificate()) {
acmeService.orderCertificate(domains);
}
}
Expand Down
37 changes: 31 additions & 6 deletions acme/src/main/java/io/micronaut/acme/services/AcmeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,20 @@ protected Optional<X509Certificate[]> getFullCertificateChain() {
* @throws AcmeException if any issues occur during ordering of certificate
*/
public void orderCertificate(List<String> 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<String> domains) throws AcmeException {
Session session = new Session(acmeServerUrl);
if (timeout != null) {
session.networkSettings().setTimeout(timeout);
Expand All @@ -185,14 +197,27 @@ public void orderCertificate(List<String> 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<String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,7 +90,7 @@ class AcmeCertRefresherTaskSetsTimeoutSpec extends Specification {
] as Map<String, Object>
}

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()
Expand All @@ -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"
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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<String> domains ->
1 * mockAcmeSerivce.createOrder(expectedDomains) >> { List<String> domains ->
throw new AcmeException("Failed to do some ACME related task")
}
}
Expand Down
Loading