mirror of https://github.com/shred/acme4j
Add new methods for status change busy waiting
parent
ae60431a79
commit
b897dc277d
|
@ -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}.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
6
pom.xml
6
pom.xml
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
Loading…
Reference in New Issue