Skip to content

Commit

Permalink
Moving and shakin
Browse files Browse the repository at this point in the history
Instead of doing everything post app startup we can move some things
into pre-app startup.

Things moved:
1. Login and ordering a certificate if needed
2. If cert already downloaded and ready set it up

Things that cannot be moved:
1. Authorizations, reason this cannot be moved is because it needs to be
able to respond from requests from the ACME server.
  • Loading branch information
zendern committed Jun 24, 2020
1 parent 34b0a9f commit df56ab0
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@

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 org.shredzone.acme4j.Order;
import org.shredzone.acme4j.exception.AcmeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -42,6 +45,7 @@ public final class AcmeCertRefresherTask {

private AcmeService acmeService;
private final AcmeConfiguration acmeConfiguration;
private Order order;

/**
* Constructs a new Acme cert refresher background task.
Expand All @@ -66,36 +70,71 @@ void backgroundRenewal() throws AcmeException {
if (LOG.isDebugEnabled()) {
LOG.debug("Running background/scheduled renewal process");
}
renewCertIfNeeded();
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()) {
Order order = acmeService.orderCertificate(domains);
acmeService.authorizeCertificate(domains, order);
}
}

/**
* 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.orderCertificate(getDomains());
} else {
acmeService.setupCurrentCertificate();
}
renewCertIfNeeded();
} 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);
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() {
boolean orderCertificate = false;
X509Certificate currentCertificate = acmeService.getCurrentCertificate();
if (currentCertificate != null) {
long daysTillExpiration = ChronoUnit.SECONDS.between(Instant.now(), currentCertificate.getNotAfter().toInstant());
if (daysTillExpiration <= acmeConfiguration.getRenewWitin().getSeconds()) {
orderCertificate = true;
}
} else {
orderCertificate = true;
}
return orderCertificate;
}

private List<String> getDomains() {
List<String> domains = new ArrayList<>();
for (String domain : acmeConfiguration.getDomains()) {
domains.add(domain);
Expand All @@ -107,18 +146,6 @@ protected void renewCertIfNeeded() throws AcmeException {
domains.add(baseDomain);
}
}

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 {
acmeService.orderCertificate(domains);
}
return domains;
}
}
28 changes: 20 additions & 8 deletions acme/src/main/java/io/micronaut/acme/services/AcmeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ public X509Certificate getCurrentCertificate() {
*
* @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
*/
public void orderCertificate(List<String> domains) throws AcmeException {
AtomicInteger orderRetryAttempts = new AtomicInteger(acmeConfiguration.getOrder().getRefreshAttempts());

public Order orderCertificate(List<String> domains) throws AcmeException {
Session session = new Session(acmeServerUrl);
if (timeout != null) {
session.networkSettings().setTimeout(timeout);
Expand All @@ -156,14 +156,26 @@ 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
*/
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 @@ -98,7 +99,7 @@ class AcmeCertRefresherTaskSetsTimeoutSpec extends Specification {
}

@Unroll
def "validate timeout applied if signup is slow"(SlowServerConfig config) {
def "validate timeout applied if signup is slow"(SlowServerConfig config, Class exType) {
given: "we have all the ports we could ever need"
expectedHttpPort = SocketUtils.findAvailableTcpPort()
expectedSecurePort = SocketUtils.findAvailableTcpPort()
Expand All @@ -121,7 +122,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 @@ -135,10 +138,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,6 +2,7 @@ package io.micronaut.acme

import io.micronaut.acme.background.AcmeCertRefresherTask
import io.micronaut.acme.services.AcmeService
import io.micronaut.http.server.exceptions.ServerStartupException
import io.micronaut.runtime.EmbeddedApplication
import io.micronaut.runtime.event.ApplicationStartupEvent
import io.micronaut.runtime.exceptions.ApplicationStartupException
Expand All @@ -23,7 +24,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 @@ -39,7 +55,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 @@ -56,7 +72,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 @@ -77,10 +93,10 @@ 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:
Expand Down

0 comments on commit df56ab0

Please sign in to comment.