diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java index 97449416..98497745 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java @@ -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. + *

+ * Is is completed if it reaches either {@link Status#VALID} or + * {@link Status#INVALID}. + *

+ * 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}. */ diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java index 0e79e45e..13ca8ccf 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java @@ -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 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. + *

+ * 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}. + *

+ * 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. + *

+ * Is is completed if it reaches either {@link Status#VALID} or + * {@link Status#INVALID}. + *

+ * 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. * diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/PollableResource.java b/acme4j-client/src/main/java/org/shredzone/acme4j/PollableResource.java new file mode 100644 index 00000000..b9324d75 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/PollableResource.java @@ -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. + *

+ * 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 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. + *

+ * This method is synchronous and blocks the current thread. + *

+ * 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 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"); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java index fc70afc0..a05640ef 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java @@ -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. + *

+ * Is is completed if it reaches either {@link Status#VALID} or + * {@link Status#INVALID}. + *

+ * 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); + } + } diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java index 14a40e2c..1168d1ee 100644 --- a/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java +++ b/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java @@ -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 domains) throws IOException, AcmeException { + public void fetchCertificate(Collection 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}. - *

- * This method polls the current status, respecting the retry-after header if set. It - * is synchronous and may take a considerable time for completion. - *

- * 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 statusSupplier, UpdateMethod statusUpdater) - throws AcmeException { - // A set of terminating status values - Set 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 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. diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/boulder/OrderHttpIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/boulder/OrderHttpIT.java index 25cca03b..85d5c06e 100644 --- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/boulder/OrderHttpIT.java +++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/boulder/OrderHttpIT.java @@ -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); - } - } - } diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java index 2a0cdb7a..733b3968 100644 --- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java +++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java @@ -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(); diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderWildcardIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderWildcardIT.java index 55622155..6dda5db5 100644 --- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderWildcardIT.java +++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderWildcardIT.java @@ -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(); diff --git a/pom.xml b/pom.xml index 640242b6..f185a995 100644 --- a/pom.xml +++ b/pom.xml @@ -230,12 +230,6 @@ 5.12.0 test - - org.awaitility - awaitility - 4.2.1 - test - org.wiremock diff --git a/src/doc/docs/example.md b/src/doc/docs/example.md index a77833e8..7ecd7afa 100644 --- a/src/doc/docs/example.md +++ b/src/doc/docs/example.md @@ -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 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 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 statusSupplier, - UpdateMethod statusUpdater) throws AcmeException { - // A set of terminating status values - Set 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 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. diff --git a/src/doc/docs/migration.md b/src/doc/docs/migration.md index 7e1b9b5c..95da4f41 100644 --- a/src/doc/docs/migration.md +++ b/src/doc/docs/migration.md @@ -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: