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 java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
@ -32,7 +34,7 @@ import org.slf4j.LoggerFactory;
/**
* 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 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#REVOKED}.
*/
@Override
public Status getStatus() {
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}.
*/

View File

@ -22,6 +22,7 @@ import java.net.URL;
import java.security.KeyPair;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
@ -41,7 +42,7 @@ import org.slf4j.LoggerFactory;
/**
* 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 Logger LOG = LoggerFactory.getLogger(Order.class);
@ -60,6 +61,7 @@ public class Order extends AcmeJsonResource {
* {@link Status#PROCESSING}, {@link Status#VALID}, {@link Status#INVALID}.
* If the server supports STAR, another possible value is {@link Status#CANCELED}.
*/
@Override
public Status getStatus() {
return getJSON().get("status").asStatus();
}
@ -186,6 +188,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair, Consumer)
* @see #execute(PKCS10CertificationRequest)
* @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0
*/
public void execute(KeyPair domainKeyPair) throws AcmeException {
@ -208,6 +212,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair)
* @see #execute(PKCS10CertificationRequest)
* @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0
*/
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, Consumer)
* @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0
*/
public void execute(PKCS10CertificationRequest csr) throws AcmeException {
@ -256,6 +264,8 @@ public class Order extends AcmeJsonResource {
* @param csr
* Binary representation of a CSR containing the parameters for the
* certificate being requested, in DER format
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
*/
public void execute(byte[] csr) throws AcmeException {
LOG.debug("finalize");
@ -268,6 +278,43 @@ public class Order extends AcmeJsonResource {
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.
*

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;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.Optional;
import org.shredzone.acme4j.AcmeJsonResource;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.PollableResource;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Status;
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
* 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 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
* {@link Status#INVALID}.
*/
@Override
public Status getStatus() {
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
* validation. This can be useful if the client state has changed, for example after a
* firewall rule has been updated.
*
* @see #waitForCompletion(Duration)
*/
public void trigger() throws AcmeException {
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.security.KeyPair;
import java.security.Security;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import javax.swing.JOptionPane;
@ -101,8 +98,8 @@ public class ClientTest {
//Challenge type to be used
private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP;
// Maximum attempts of status polling until VALID/INVALID is expected
private static final int MAX_ATTEMPTS = 50;
// Maximum time to wait until VALID/INVALID is expected
private static final Duration TIMEOUT = Duration.ofSeconds(60L);
private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
@ -115,7 +112,7 @@ public class ClientTest {
* @param domains
* 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.
KeyPair userKeyPair = loadOrCreateUserKeyPair();
@ -137,16 +134,18 @@ public class ClientTest {
authorize(auth);
}
// Wait for the order to become READY
order.waitUntilReady(TIMEOUT);
// Order the certificate
order.execute(domainKeyPair);
// Wait for the order to complete
Status status = waitForCompletion(order::getStatus, order::fetch);
Status status = order.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString)
.orElse("unknown")
);
.orElse("unknown"));
throw new AcmeException("Order failed... Giving up.");
}
@ -259,7 +258,7 @@ public class ClientTest {
* @param auth
* {@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());
// The authorization is already valid. No need to process a challenge.
@ -292,7 +291,7 @@ public class ClientTest {
challenge.trigger();
// Poll for the challenge to complete.
Status status = waitForCompletion(challenge::getStatus, challenge::fetch);
Status status = challenge.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError()
.map(Problem::toString)
@ -382,70 +381,6 @@ public class ClientTest {
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
* dismissal. If the user cancelled the dialog, an exception is thrown.

View File

@ -13,22 +13,17 @@
*/
package org.shredzone.acme4j.it.boulder;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.net.URI;
import java.security.KeyPair;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.AccountBuilder;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.Order;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.Status;
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.util.KeyPairUtils;
@ -38,6 +33,7 @@ import org.shredzone.acme4j.util.KeyPairUtils;
public class OrderHttpIT {
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");
@ -66,25 +62,20 @@ public class OrderHttpIT {
client.httpAddToken(challenge.getToken(), challenge.getAuthorization());
challenge.trigger();
challenge.waitForCompletion(TIMEOUT);
await()
.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);
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
client.httpRemoveToken(challenge.getToken());
}
order.execute(domainKeyPair);
order.waitUntilReady(TIMEOUT);
assertThat(order.getStatus()).isEqualTo(Status.READY);
await()
.pollInterval(1, SECONDS)
.timeout(30, SECONDS)
.conditionEvaluationListener(cond -> updateOrder(order))
.untilAsserted(() -> assertThat(order.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
order.execute(domainKeyPair);
order.waitForCompletion(TIMEOUT);
assertThat(order.getStatus()).isEqualTo(Status.VALID);
var cert = order.getCertificate().getCertificate();
assertThat(cert.getNotAfter()).isNotNull();
@ -108,32 +99,4 @@ public class OrderHttpIT {
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;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.URI;
@ -46,6 +44,7 @@ import org.shredzone.acme4j.exception.AcmeServerException;
public class OrderIT extends PebbleITBase {
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.
@ -169,24 +168,20 @@ public class OrderIT extends PebbleITBase {
var challenge = validator.prepare(auth);
challenge.trigger();
await()
.pollInterval(1, SECONDS)
.timeout(30, SECONDS)
.conditionEvaluationListener(cond -> updateAuth(auth))
.untilAsserted(() -> assertThat(auth.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
challenge.waitForCompletion(TIMEOUT);
assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
auth.fetch();
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
}
order.waitUntilReady(TIMEOUT);
assertThat(order.getStatus()).isEqualTo(Status.READY);
order.execute(domainKeyPair);
await()
.pollInterval(1, SECONDS)
.timeout(30, SECONDS)
.conditionEvaluationListener(cond -> updateOrder(order))
.untilAsserted(() -> assertThat(order.getStatus())
.isNotIn(Status.PENDING, Status.PROCESSING, Status.READY));
order.waitForCompletion(TIMEOUT);
assertThat(order.getStatus()).isEqualTo(Status.VALID);
var certificate = order.getCertificate();

View File

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

View File

@ -230,12 +230,6 @@
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.1</version>
<scope>test</scope>
</dependency>
<dependency>
<!-- Fixed to 3.2.0 until https://github.com/wiremock/wiremock/issues/2480 is resolved -->
<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_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.
* `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
@ -81,7 +81,7 @@ The `fetchCertificate()` method contains the main workflow. It expects a collect
```java
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.
KeyPair userKeyPair = loadOrCreateUserKeyPair();
@ -104,16 +104,18 @@ public void fetchCertificate(Collection<String> domains)
authorize(auth);
}
// Wait for the order to become READY
order.waitUntilReady(TIMEOUT);
// Order the certificate
order.execute(domainKeyPair);
// Wait for the order to complete
Status status = waitForCompletion(order::getStatus, order::fetch);
Status status = order.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString)
.orElse("unknown")
);
.orElse("unknown"));
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
private void authorize(Authorization auth)
throws AcmeException {
throws AcmeException, InterruptedException {
LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain());
// The authorization is already valid.
@ -265,7 +267,7 @@ private void authorize(Authorization auth)
challenge.trigger();
// Poll for the challenge to complete.
Status status = waitForCompletion(challenge::getStatus, challenge::fetch);
Status status = challenge.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError()
.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.
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.
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;
}
```
An enterprise level implementation would do an asynchronous polling instead, by storing the recheck time in a database or a queue with scheduled delivery.
!!! 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.

View File

@ -2,6 +2,11 @@
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
- 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.
- 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:
```java
Security.addProvider(new BouncyCastleProvider());
```
- `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())`.
What you might also need to know: