Add new methods for status change busy waiting

pull/168/head
Richard Körber 2024-08-17 17:20:52 +02:00
parent ae60431a79
commit b897dc277d
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
11 changed files with 258 additions and 217 deletions

View File

@ -16,7 +16,9 @@ package org.shredzone.acme4j;
import static java.util.stream.Collectors.toUnmodifiableList; import static java.util.stream.Collectors.toUnmodifiableList;
import java.net.URL; import java.net.URL;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -32,7 +34,7 @@ import org.slf4j.LoggerFactory;
/** /**
* Represents an authorization request at the ACME server. * Represents an authorization request at the ACME server.
*/ */
public class Authorization extends AcmeJsonResource { public class Authorization extends AcmeJsonResource implements PollableResource {
private static final long serialVersionUID = -3116928998379417741L; private static final long serialVersionUID = -3116928998379417741L;
private static final Logger LOG = LoggerFactory.getLogger(Authorization.class); private static final Logger LOG = LoggerFactory.getLogger(Authorization.class);
@ -60,6 +62,7 @@ public class Authorization extends AcmeJsonResource {
* {@link Status#INVALID}, {@link Status#DEACTIVATED}, {@link Status#EXPIRED}, * {@link Status#INVALID}, {@link Status#DEACTIVATED}, {@link Status#EXPIRED},
* {@link Status#REVOKED}. * {@link Status#REVOKED}.
*/ */
@Override
public Status getStatus() { public Status getStatus() {
return getJSON().get("status").asStatus(); return getJSON().get("status").asStatus();
} }
@ -151,6 +154,24 @@ public class Authorization extends AcmeJsonResource {
}); });
} }
/**
* Waits until the authorization is completed.
* <p>
* Is is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
* <p>
* This method is synchronous and blocks the current thread.
*
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
* @since 3.4.0
*/
public Status waitForCompletion(Duration timeout)
throws AcmeException, InterruptedException {
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
}
/** /**
* Permanently deactivates the {@link Authorization}. * Permanently deactivates the {@link Authorization}.
*/ */

View File

@ -22,6 +22,7 @@ import java.net.URL;
import java.security.KeyPair; import java.security.KeyPair;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
@ -41,7 +42,7 @@ import org.slf4j.LoggerFactory;
/** /**
* A representation of a certificate order at the CA. * A representation of a certificate order at the CA.
*/ */
public class Order extends AcmeJsonResource { public class Order extends AcmeJsonResource implements PollableResource {
private static final long serialVersionUID = 5435808648658292177L; private static final long serialVersionUID = 5435808648658292177L;
private static final Logger LOG = LoggerFactory.getLogger(Order.class); private static final Logger LOG = LoggerFactory.getLogger(Order.class);
@ -60,6 +61,7 @@ public class Order extends AcmeJsonResource {
* {@link Status#PROCESSING}, {@link Status#VALID}, {@link Status#INVALID}. * {@link Status#PROCESSING}, {@link Status#VALID}, {@link Status#INVALID}.
* If the server supports STAR, another possible value is {@link Status#CANCELED}. * If the server supports STAR, another possible value is {@link Status#CANCELED}.
*/ */
@Override
public Status getStatus() { public Status getStatus() {
return getJSON().get("status").asStatus(); return getJSON().get("status").asStatus();
} }
@ -186,6 +188,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair, Consumer) * @see #execute(KeyPair, Consumer)
* @see #execute(PKCS10CertificationRequest) * @see #execute(PKCS10CertificationRequest)
* @see #execute(byte[]) * @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0 * @since 3.0.0
*/ */
public void execute(KeyPair domainKeyPair) throws AcmeException { public void execute(KeyPair domainKeyPair) throws AcmeException {
@ -208,6 +212,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair) * @see #execute(KeyPair)
* @see #execute(PKCS10CertificationRequest) * @see #execute(PKCS10CertificationRequest)
* @see #execute(byte[]) * @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0 * @since 3.0.0
*/ */
public void execute(KeyPair domainKeyPair, Consumer<CSRBuilder> builderConsumer) throws AcmeException { public void execute(KeyPair domainKeyPair, Consumer<CSRBuilder> builderConsumer) throws AcmeException {
@ -235,6 +241,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair) * @see #execute(KeyPair)
* @see #execute(KeyPair, Consumer) * @see #execute(KeyPair, Consumer)
* @see #execute(byte[]) * @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0 * @since 3.0.0
*/ */
public void execute(PKCS10CertificationRequest csr) throws AcmeException { public void execute(PKCS10CertificationRequest csr) throws AcmeException {
@ -256,6 +264,8 @@ public class Order extends AcmeJsonResource {
* @param csr * @param csr
* Binary representation of a CSR containing the parameters for the * Binary representation of a CSR containing the parameters for the
* certificate being requested, in DER format * certificate being requested, in DER format
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
*/ */
public void execute(byte[] csr) throws AcmeException { public void execute(byte[] csr) throws AcmeException {
LOG.debug("finalize"); LOG.debug("finalize");
@ -268,6 +278,43 @@ public class Order extends AcmeJsonResource {
invalidate(); invalidate();
} }
/**
* Waits until the order is ready for finalization.
* <p>
* Is is ready if it reaches {@link Status#READY}. The method will also return if the
* order already has another terminal state, which is either {@link Status#VALID} or
* {@link Status#INVALID}.
* <p>
* This method is synchronous and blocks the current thread.
*
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
* @since 3.4.0
*/
public Status waitUntilReady(Duration timeout)
throws AcmeException, InterruptedException {
return waitForStatus(EnumSet.of(Status.READY, Status.VALID, Status.INVALID), timeout);
}
/**
* Waits until the order finalization is completed.
* <p>
* Is is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
* <p>
* This method is synchronous and blocks the current thread.
*
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
* @since 3.4.0
*/
public Status waitForCompletion(Duration timeout)
throws AcmeException, InterruptedException {
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
}
/** /**
* Checks if this order is auto-renewing, according to the ACME STAR specifications. * Checks if this order is auto-renewing, according to the ACME STAR specifications.
* *

View File

@ -0,0 +1,107 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static java.time.Instant.now;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.shredzone.acme4j.exception.AcmeException;
/**
* Marks an ACME Resource with a pollable status.
* <p>
* The resource provides a status, and a method for updating the internal cache to read
* the current status from the server.
*
* @since 3.4.0
*/
public interface PollableResource {
/**
* Default delay between status polls if there is no Retry-After header.
*/
Duration DEFAULT_RETRY_AFTER = Duration.ofSeconds(3L);
/**
* Returns the current status of the resource.
*/
Status getStatus();
/**
* Fetches the current status from the server.
*
* @return Retry-After time, if given by the CA, otherwise empty.
*/
Optional<Instant> fetch() throws AcmeException;
/**
* Waits until a terminal status has been reached, by polling until one of the given
* status or the given timeout has been reached. This call honors the Retry-After
* header if set by the CA.
* <p>
* This method is synchronous and blocks the current thread.
* <p>
* If the resource is already in a terminal status, the method returns immediately.
*
* @param statusSet
* Set of {@link Status} that are accepted as terminal
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
*/
default Status waitForStatus(Set<Status> statusSet, Duration timeout)
throws AcmeException, InterruptedException {
Objects.requireNonNull(timeout, "timeout");
Objects.requireNonNull(statusSet, "statusSet");
if (statusSet.isEmpty()) {
throw new IllegalArgumentException("At least one Status is required");
}
var currentStatus = getStatus();
if (statusSet.contains(currentStatus)) {
return currentStatus;
}
var timebox = now().plus(timeout);
Instant now;
while ((now = now()).isBefore(timebox)) {
// Poll status and get the time of the next poll
var retryAfter = fetch()
.orElse(now.plus(DEFAULT_RETRY_AFTER));
currentStatus = getStatus();
if (statusSet.contains(currentStatus)) {
return currentStatus;
}
// Preemptively end the loop if the next iteration would be after timebox
if (retryAfter.isAfter(timebox)) {
break;
}
// Wait until retryAfter is reached
Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
}
throw new AcmeException("Timeout has been reached");
}
}

View File

@ -13,11 +13,14 @@
*/ */
package org.shredzone.acme4j.challenge; package org.shredzone.acme4j.challenge;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.EnumSet;
import java.util.Optional; import java.util.Optional;
import org.shredzone.acme4j.AcmeJsonResource; import org.shredzone.acme4j.AcmeJsonResource;
import org.shredzone.acme4j.Login; import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.PollableResource;
import org.shredzone.acme4j.Problem; import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Status; import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeException;
@ -37,7 +40,7 @@ import org.slf4j.LoggerFactory;
* own type. {@link Challenge#prepareResponse(JSONBuilder)} can be overridden to put all * own type. {@link Challenge#prepareResponse(JSONBuilder)} can be overridden to put all
* required data to the challenge response. * required data to the challenge response.
*/ */
public class Challenge extends AcmeJsonResource { public class Challenge extends AcmeJsonResource implements PollableResource {
private static final long serialVersionUID = 2338794776848388099L; private static final long serialVersionUID = 2338794776848388099L;
private static final Logger LOG = LoggerFactory.getLogger(Challenge.class); private static final Logger LOG = LoggerFactory.getLogger(Challenge.class);
@ -76,6 +79,7 @@ public class Challenge extends AcmeJsonResource {
* A challenge is only completed when it reaches either status {@link Status#VALID} or * A challenge is only completed when it reaches either status {@link Status#VALID} or
* {@link Status#INVALID}. * {@link Status#INVALID}.
*/ */
@Override
public Status getStatus() { public Status getStatus() {
return getJSON().get(KEY_STATUS).asStatus(); return getJSON().get(KEY_STATUS).asStatus();
} }
@ -155,6 +159,8 @@ public class Challenge extends AcmeJsonResource {
* If this method is invoked a second time, the ACME server is requested to retry the * If this method is invoked a second time, the ACME server is requested to retry the
* validation. This can be useful if the client state has changed, for example after a * validation. This can be useful if the client state has changed, for example after a
* firewall rule has been updated. * firewall rule has been updated.
*
* @see #waitForCompletion(Duration)
*/ */
public void trigger() throws AcmeException { public void trigger() throws AcmeException {
LOG.debug("trigger"); LOG.debug("trigger");
@ -167,4 +173,22 @@ public class Challenge extends AcmeJsonResource {
} }
} }
/**
* Waits until the challenge is completed.
* <p>
* Is is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
* <p>
* This method is synchronous and blocks the current thread.
*
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
* @since 3.4.0
*/
public Status waitForCompletion(Duration timeout)
throws AcmeException, InterruptedException {
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
}
} }

View File

@ -21,13 +21,10 @@ import java.net.URI;
import java.net.URL; import java.net.URL;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.Security; import java.security.Security;
import java.time.Instant; import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.EnumSet;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier; import java.util.function.Supplier;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
@ -101,8 +98,8 @@ public class ClientTest {
//Challenge type to be used //Challenge type to be used
private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP; private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP;
// Maximum attempts of status polling until VALID/INVALID is expected // Maximum time to wait until VALID/INVALID is expected
private static final int MAX_ATTEMPTS = 50; private static final Duration TIMEOUT = Duration.ofSeconds(60L);
private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class); private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
@ -115,7 +112,7 @@ public class ClientTest {
* @param domains * @param domains
* Domains to get a common certificate for * Domains to get a common certificate for
*/ */
public void fetchCertificate(Collection<String> domains) throws IOException, AcmeException { public void fetchCertificate(Collection<String> domains) throws IOException, AcmeException, InterruptedException {
// Load the user key file. If there is no key file, create a new one. // Load the user key file. If there is no key file, create a new one.
KeyPair userKeyPair = loadOrCreateUserKeyPair(); KeyPair userKeyPair = loadOrCreateUserKeyPair();
@ -137,16 +134,18 @@ public class ClientTest {
authorize(auth); authorize(auth);
} }
// Wait for the order to become READY
order.waitUntilReady(TIMEOUT);
// Order the certificate // Order the certificate
order.execute(domainKeyPair); order.execute(domainKeyPair);
// Wait for the order to complete // Wait for the order to complete
Status status = waitForCompletion(order::getStatus, order::fetch); Status status = order.waitForCompletion(TIMEOUT);
if (status != Status.VALID) { if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError() LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString) .map(Problem::toString)
.orElse("unknown") .orElse("unknown"));
);
throw new AcmeException("Order failed... Giving up."); throw new AcmeException("Order failed... Giving up.");
} }
@ -259,7 +258,7 @@ public class ClientTest {
* @param auth * @param auth
* {@link Authorization} to perform * {@link Authorization} to perform
*/ */
private void authorize(Authorization auth) throws AcmeException { private void authorize(Authorization auth) throws AcmeException, InterruptedException {
LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain()); LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain());
// The authorization is already valid. No need to process a challenge. // The authorization is already valid. No need to process a challenge.
@ -292,7 +291,7 @@ public class ClientTest {
challenge.trigger(); challenge.trigger();
// Poll for the challenge to complete. // Poll for the challenge to complete.
Status status = waitForCompletion(challenge::getStatus, challenge::fetch); Status status = challenge.waitForCompletion(TIMEOUT);
if (status != Status.VALID) { if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError() LOG.error("Challenge has failed, reason: {}", challenge.getError()
.map(Problem::toString) .map(Problem::toString)
@ -382,70 +381,6 @@ public class ClientTest {
return challenge; return challenge;
} }
/**
* Waits for completion of a resource. A resource is completed if the status is either
* {@link Status#VALID} or {@link Status#INVALID}.
* <p>
* This method polls the current status, respecting the retry-after header if set. It
* is synchronous and may take a considerable time for completion.
* <p>
* It is meant as a simple example! For production services, it is recommended to do
* an asynchronous processing here.
*
* @param statusSupplier
* Method of the resource that returns the current status
* @param statusUpdater
* Method of the resource that updates the internal state and fetches the
* current status from the server. It returns the instant of an optional
* retry-after header.
* @return The final status, either {@link Status#VALID} or {@link Status#INVALID}
* @throws AcmeException
* If an error occured, or if the status did not reach one of the accepted
* result values after a certain number of checks.
*/
private Status waitForCompletion(Supplier<Status> statusSupplier, UpdateMethod statusUpdater)
throws AcmeException {
// A set of terminating status values
Set<Status> acceptableStatus = EnumSet.of(Status.VALID, Status.INVALID);
// Limit the number of checks, to avoid endless loops
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
Instant now = Instant.now();
// Update the status property
Instant retryAfter = statusUpdater.updateAndGetRetryAfter()
.orElse(now.plusSeconds(3L));
// Check the status
Status currentStatus = statusSupplier.get();
if (acceptableStatus.contains(currentStatus)) {
// Reached VALID or INVALID, we're done here
return currentStatus;
}
// Wait before checking again
try {
Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new AcmeException("interrupted");
}
}
throw new AcmeException("Too many update attempts, status did not change");
}
/**
* Functional interface that refers to a resource update method that returns an
* optional retry-after instant and is able to throw an {@link AcmeException}.
*/
@FunctionalInterface
private interface UpdateMethod {
Optional<Instant> updateAndGetRetryAfter() throws AcmeException;
}
/** /**
* Presents the instructions for preparing the challenge validation, and waits for * Presents the instructions for preparing the challenge validation, and waits for
* dismissal. If the user cancelled the dialog, an exception is thrown. * dismissal. If the user cancelled the dialog, an exception is thrown.

View File

@ -13,22 +13,17 @@
*/ */
package org.shredzone.acme4j.it.boulder; package org.shredzone.acme4j.it.boulder;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.net.URI; import java.net.URI;
import java.security.KeyPair; import java.security.KeyPair;
import java.time.Duration;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.AccountBuilder; import org.shredzone.acme4j.AccountBuilder;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.Order;
import org.shredzone.acme4j.Session; import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.Status; import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
import org.shredzone.acme4j.it.BammBammClient; import org.shredzone.acme4j.it.BammBammClient;
import org.shredzone.acme4j.util.KeyPairUtils; import org.shredzone.acme4j.util.KeyPairUtils;
@ -38,6 +33,7 @@ import org.shredzone.acme4j.util.KeyPairUtils;
public class OrderHttpIT { public class OrderHttpIT {
private static final String TEST_DOMAIN = "example.com"; private static final String TEST_DOMAIN = "example.com";
private static final Duration TIMEOUT = Duration.ofSeconds(30L);
private final String bammbammUrl = System.getProperty("bammbammUrl", "http://localhost:14001"); private final String bammbammUrl = System.getProperty("bammbammUrl", "http://localhost:14001");
@ -66,25 +62,20 @@ public class OrderHttpIT {
client.httpAddToken(challenge.getToken(), challenge.getAuthorization()); client.httpAddToken(challenge.getToken(), challenge.getAuthorization());
challenge.trigger(); challenge.trigger();
challenge.waitForCompletion(TIMEOUT);
await() assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
.pollInterval(1, SECONDS)
.timeout(30, SECONDS)
.conditionEvaluationListener(cond -> updateAuth(auth))
.untilAsserted(() -> assertThat(auth.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
assertThat(auth.getStatus()).isEqualTo(Status.VALID); assertThat(auth.getStatus()).isEqualTo(Status.VALID);
client.httpRemoveToken(challenge.getToken()); client.httpRemoveToken(challenge.getToken());
} }
order.execute(domainKeyPair); order.waitUntilReady(TIMEOUT);
assertThat(order.getStatus()).isEqualTo(Status.READY);
await() order.execute(domainKeyPair);
.pollInterval(1, SECONDS) order.waitForCompletion(TIMEOUT);
.timeout(30, SECONDS) assertThat(order.getStatus()).isEqualTo(Status.VALID);
.conditionEvaluationListener(cond -> updateOrder(order))
.untilAsserted(() -> assertThat(order.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
var cert = order.getCertificate().getCertificate(); var cert = order.getCertificate().getCertificate();
assertThat(cert.getNotAfter()).isNotNull(); assertThat(cert.getNotAfter()).isNotNull();
@ -108,32 +99,4 @@ public class OrderHttpIT {
return KeyPairUtils.createKeyPair(2048); return KeyPairUtils.createKeyPair(2048);
} }
/**
* Safely updates the authorization, catching checked exceptions.
*
* @param auth
* {@link Authorization} to update
*/
private void updateAuth(Authorization auth) {
try {
auth.update();
} catch (AcmeException ex) {
throw new AcmeLazyLoadingException(auth, ex);
}
}
/**
* Safely updates the order, catching checked exceptions.
*
* @param order
* {@link Order} to update
*/
private void updateOrder(Order order) {
try {
order.update();
} catch (AcmeException ex) {
throw new AcmeLazyLoadingException(order, ex);
}
}
} }

View File

@ -13,9 +13,7 @@
*/ */
package org.shredzone.acme4j.it.pebble; package org.shredzone.acme4j.it.pebble;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.URI; import java.net.URI;
@ -46,6 +44,7 @@ import org.shredzone.acme4j.exception.AcmeServerException;
public class OrderIT extends PebbleITBase { public class OrderIT extends PebbleITBase {
private static final String TEST_DOMAIN = "example.com"; private static final String TEST_DOMAIN = "example.com";
private static final Duration TIMEOUT = Duration.ofSeconds(30L);
/** /**
* Test if a certificate can be ordered via http-01 challenge. * Test if a certificate can be ordered via http-01 challenge.
@ -169,24 +168,20 @@ public class OrderIT extends PebbleITBase {
var challenge = validator.prepare(auth); var challenge = validator.prepare(auth);
challenge.trigger(); challenge.trigger();
await() challenge.waitForCompletion(TIMEOUT);
.pollInterval(1, SECONDS)
.timeout(30, SECONDS)
.conditionEvaluationListener(cond -> updateAuth(auth))
.untilAsserted(() -> assertThat(auth.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
auth.fetch();
assertThat(auth.getStatus()).isEqualTo(Status.VALID); assertThat(auth.getStatus()).isEqualTo(Status.VALID);
} }
order.waitUntilReady(TIMEOUT);
assertThat(order.getStatus()).isEqualTo(Status.READY);
order.execute(domainKeyPair); order.execute(domainKeyPair);
await() order.waitForCompletion(TIMEOUT);
.pollInterval(1, SECONDS)
.timeout(30, SECONDS)
.conditionEvaluationListener(cond -> updateOrder(order))
.untilAsserted(() -> assertThat(order.getStatus())
.isNotIn(Status.PENDING, Status.PROCESSING, Status.READY));
assertThat(order.getStatus()).isEqualTo(Status.VALID); assertThat(order.getStatus()).isEqualTo(Status.VALID);
var certificate = order.getCertificate(); var certificate = order.getCertificate();

View File

@ -13,10 +13,8 @@
*/ */
package org.shredzone.acme4j.it.pebble; package org.shredzone.acme4j.it.pebble;
import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toList;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
@ -37,6 +35,7 @@ public class OrderWildcardIT extends PebbleITBase {
private static final String TEST_DOMAIN = "example.com"; private static final String TEST_DOMAIN = "example.com";
private static final String TEST_WILDCARD_DOMAIN = "*.example.com"; private static final String TEST_WILDCARD_DOMAIN = "*.example.com";
private static final Duration TIMEOUT = Duration.ofSeconds(30L);
/** /**
* Test if a wildcard certificate can be ordered via dns-01 challenge. * Test if a wildcard certificate can be ordered via dns-01 challenge.
@ -82,27 +81,22 @@ public class OrderWildcardIT extends PebbleITBase {
try { try {
challenge.trigger(); challenge.trigger();
await().pollInterval(1, SECONDS) challenge.waitForCompletion(TIMEOUT);
.timeout(30, SECONDS) assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
.conditionEvaluationListener(cond -> updateAuth(auth))
.untilAsserted(() -> assertThat(
auth.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
} finally { } finally {
performCleanup(); performCleanup();
} }
auth.fetch();
assertThat(auth.getStatus()).isEqualTo(Status.VALID); assertThat(auth.getStatus()).isEqualTo(Status.VALID);
} }
order.waitUntilReady(TIMEOUT);
assertThat(order.getStatus()).isEqualTo(Status.READY);
order.execute(domainKeyPair); order.execute(domainKeyPair);
order.waitForCompletion(TIMEOUT);
await() assertThat(order.getStatus()).isEqualTo(Status.VALID);
.pollInterval(1, SECONDS)
.timeout(30, SECONDS)
.conditionEvaluationListener(cond -> updateOrder(order))
.untilAsserted(() -> assertThat(
order.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
var cert = order.getCertificate().getCertificate(); var cert = order.getCertificate().getCertificate();
assertThat(cert).isNotNull(); assertThat(cert).isNotNull();

View File

@ -230,12 +230,6 @@
<version>5.12.0</version> <version>5.12.0</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.1</version>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<!-- Fixed to 3.2.0 until https://github.com/wiremock/wiremock/issues/2480 is resolved --> <!-- Fixed to 3.2.0 until https://github.com/wiremock/wiremock/issues/2480 is resolved -->
<groupId>org.wiremock</groupId> <groupId>org.wiremock</groupId>

View File

@ -37,7 +37,7 @@ The other constants should work with their default values, but can still be chan
* `DOMAIN_KEY_FILE`: File name where the generated domain key is stored. Default is `domain.key`. * `DOMAIN_KEY_FILE`: File name where the generated domain key is stored. Default is `domain.key`.
* `DOMAIN_CHAIN_FILE`: File name where the ordered domain certificate chain is stored. Default is `domain-chain.crt`. * `DOMAIN_CHAIN_FILE`: File name where the ordered domain certificate chain is stored. Default is `domain-chain.crt`.
* `CHALLENGE_TYPE`: The challenge type you want to perform for domain validation. The default is `ChallengeType.HTTP` for [http-01](challenge/http-01.md) validation, but you can also use `ChallengeType.DNS` to perform a [dns-01](challenge/dns-01.md) validation. The example does not support other kind of challenges. * `CHALLENGE_TYPE`: The challenge type you want to perform for domain validation. The default is `ChallengeType.HTTP` for [http-01](challenge/http-01.md) validation, but you can also use `ChallengeType.DNS` to perform a [dns-01](challenge/dns-01.md) validation. The example does not support other kind of challenges.
* `MAX_ATTEMPTS`: Maximum number of poll attempts until a status poll is aborted. * `TIMEOUT`: Maximum time until an expected resource status must be reached. Default is 60 seconds. If you get frequent timeouts with your CA, increase the timeout.
## Running the Example ## Running the Example
@ -81,7 +81,7 @@ The `fetchCertificate()` method contains the main workflow. It expects a collect
```java ```java
public void fetchCertificate(Collection<String> domains) public void fetchCertificate(Collection<String> domains)
throws IOException, AcmeException { throws IOException, AcmeException, InterruptedException {
// Load the user key file. If there is no key file, create a new one. // Load the user key file. If there is no key file, create a new one.
KeyPair userKeyPair = loadOrCreateUserKeyPair(); KeyPair userKeyPair = loadOrCreateUserKeyPair();
@ -104,16 +104,18 @@ public void fetchCertificate(Collection<String> domains)
authorize(auth); authorize(auth);
} }
// Wait for the order to become READY
order.waitUntilReady(TIMEOUT);
// Order the certificate // Order the certificate
order.execute(domainKeyPair); order.execute(domainKeyPair);
// Wait for the order to complete // Wait for the order to complete
Status status = waitForCompletion(order::getStatus, order::fetch); Status status = order.waitForCompletion(TIMEOUT);
if (status != Status.VALID) { if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError() LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString) .map(Problem::toString)
.orElse("unknown") .orElse("unknown"));
);
throw new AcmeException("Order failed... Giving up."); throw new AcmeException("Order failed... Giving up.");
} }
@ -230,7 +232,7 @@ In order to get a certificate, you need to prove ownership of the domains. In th
```java ```java
private void authorize(Authorization auth) private void authorize(Authorization auth)
throws AcmeException { throws AcmeException, InterruptedException {
LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain()); LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain());
// The authorization is already valid. // The authorization is already valid.
@ -265,7 +267,7 @@ private void authorize(Authorization auth)
challenge.trigger(); challenge.trigger();
// Poll for the challenge to complete. // Poll for the challenge to complete.
Status status = waitForCompletion(challenge::getStatus, challenge::fetch); Status status = challenge.waitForCompletion(TIMEOUT);
if (status != Status.VALID) { if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError() LOG.error("Challenge has failed, reason: {}", challenge.getError()
.map(Problem::toString) .map(Problem::toString)
@ -362,54 +364,11 @@ public Challenge dnsChallenge(Authorization auth) throws AcmeException {
The ACME protocol does not specify the sending of events. For this reason, resource status changes must be actively polled by the client. The ACME protocol does not specify the sending of events. For this reason, resource status changes must be actively polled by the client.
This example does a very simple polling in a synchronous busy loop. It updates the local copy of the resource and checks if the status is either `VALID` or `INVALID`. If it is not, it just sleeps for a certain amount of time, and then rechecks the current status. _acme4j_ offers very simple polling methods called `waitForStatus()`, `waitUntilReady()`, and `waitForCompletion()`. These methods check the status in a synchronous busy loop. It updates the local copy of the resource using the `fetch()` method, and then checks if the status is either `VALID` or `INVALID` (or `READY` on `waitUntilReady()`). If none of these states have been reached, it just sleeps for a certain amount of time, and then rechecks the resource status.
Some CAs respond with a `Retry-After` HTTP header, which provides a recommendation when to check for a status change again. If this header is present, the updater function will return the given instant. If this header is not present, we will just wait a reasonable amount of time before checking again. Some CAs respond with a `Retry-After` HTTP header, which provides a recommendation when to check for a status change again. If this header is present, the `fetch()` method will return the given instant. If this header is not present, we will just wait a reasonable amount of time before checking again.
An enterprise level implementation would do an asynchronous polling by storing the recheck time in a database or a queue with scheduled delivery. An enterprise level implementation would do an asynchronous polling instead, by storing the recheck time in a database or a queue with scheduled delivery.
The following method will check if a resource reaches completion (by reaching either `VALID` or `INVALID` status). The first parameter provides the method that fetches the current status (e.g. `Order::getStatus`). The second parameter provides the method that updates the resource status (e.g. `Order::fetch`). It returned the terminating status once it has been reached, or will throw an exception if something went wrong.
```java
private Status waitForCompletion(Supplier<Status> statusSupplier,
UpdateMethod statusUpdater) throws AcmeException {
// A set of terminating status values
Set<Status> acceptableStatus = EnumSet.of(Status.VALID, Status.INVALID);
// Limit the number of checks, to avoid endless loops
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
Instant now = Instant.now();
// Update the status property
Instant retryAfter = statusUpdater.updateAndGetRetryAfter()
.orElse(now.plusSeconds(3L));
// Check the status
Status currentStatus = statusSupplier.get();
if (acceptableStatus.contains(currentStatus)) {
// Reached VALID or INVALID, we're done here
return currentStatus;
}
// Wait before checking again
try {
Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new AcmeException("interrupted");
}
}
throw new AcmeException("Too many update attempts, status did not change");
}
@FunctionalInterface
private interface UpdateMethod {
Optional<Instant> updateAndGetRetryAfter() throws AcmeException;
}
```
!!! note !!! note
Some CAs might provide a `Retry-After` even if the resource has reached a terminal state. For this reason, always check the status _before_ waiting for the recommended time, and leave the loop if a terminal status has been reached. Some CAs might provide a `Retry-After` even if the resource has reached a terminal state. For this reason, always check the status _before_ waiting for the recommended time, and leave the loop if a terminal status has been reached.

View File

@ -2,6 +2,11 @@
This document will help you migrate your code to the latest _acme4j_ version. This document will help you migrate your code to the latest _acme4j_ version.
## Migration to Version 3.4.0
- To be futureproof, you should wait for your `Order` resource's state to become `READY` before invoking `Order.execute()`. Most CAs change to the `READY` state immediately, but this behavior is not specified in RFC8555. Future CA implementations may stay in `PENDING` state for a short while, and would return an error if `execute()` is invoked too early. Also see the [example](example.md#the-main-workflow) for how wait for the `READY` state.
- There are new methods `waitForCompletion()` and `waitUntilReady()` that will do the synchronous busy wait for the resource state for you. It will remove a lot of boilerplate code that is also bug prone if implemented individually. If you use synchronous polling and waiting (like shown in the example code), I recommend to change to these methods instead of waiting for the correct state yourself. See the [example](example.md) for how to use the new methods.
## Migration to Version 3.3.0 ## Migration to Version 3.3.0
- This version is unable to deserialize resource objects that were serialized by a previous version using Java's serialization mechanism. This shouldn't be a problem, as [it was not allowed](usage/persistence.md#serialization) to share serialized data between different versions anyway. - This version is unable to deserialize resource objects that were serialized by a previous version using Java's serialization mechanism. This shouldn't be a problem, as [it was not allowed](usage/persistence.md#serialization) to share serialized data between different versions anyway.
@ -21,10 +26,7 @@ Although acme4j has made a major version bump, the migration of your code should
- The `acme4j-utils` module has been removed, and its classes moved into `acme4j-client` module. If you have used it before, just remove the dependency. If your project has a `module-info.java` file, remember to remove the `requires org.shredzone.acme4j.utils` line there as well. - The `acme4j-utils` module has been removed, and its classes moved into `acme4j-client` module. If you have used it before, just remove the dependency. If your project has a `module-info.java` file, remember to remove the `requires org.shredzone.acme4j.utils` line there as well.
- All `@Nullable` return values have been removed where possible. Returned collections may now be empty, but are never `null`. Most of the other return values are now either `Optional`, or are throwing an exception if more reasonable. If your code fails to compile because the return type has changed to `Optional`, you could simply add `.orElse(null)` to emulate the old behavior. But often your code will reveal a better way to handle the former `null` pointer instead. - All `@Nullable` return values have been removed where possible. Returned collections may now be empty, but are never `null`. Most of the other return values are now either `Optional`, or are throwing an exception if more reasonable. If your code fails to compile because the return type has changed to `Optional`, you could simply add `.orElse(null)` to emulate the old behavior. But often your code will reveal a better way to handle the former `null` pointer instead.
- `acme4j-client` now depends on Bouncy Castle, so you might need to register it as security provider at the start of your code: - `acme4j-client` now depends on Bouncy Castle, so you might need to register it as security provider at the start of your code: `Security.addProvider(new BouncyCastleProvider())`.
```java
Security.addProvider(new BouncyCastleProvider());
```
What you might also need to know: What you might also need to know: