diff --git a/src/zutil/math/ZMath.java b/src/zutil/math/ZMath.java index df7db51..ae7ff76 100755 --- a/src/zutil/math/ZMath.java +++ b/src/zutil/math/ZMath.java @@ -27,7 +27,7 @@ package zutil.math; import java.math.BigInteger; /** - * Very Simple math functions + * Very Simple math functions * * @author Ziver */ diff --git a/src/zutil/net/acme/AcmeChallengeFactory.java b/src/zutil/net/acme/AcmeChallengeFactory.java new file mode 100644 index 0000000..f2d99c5 --- /dev/null +++ b/src/zutil/net/acme/AcmeChallengeFactory.java @@ -0,0 +1,22 @@ +package zutil.net.acme; + +import org.shredzone.acme4j.Authorization; +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.exception.AcmeException; + +public interface AcmeChallengeFactory { + + /** + * Create a new Challenge object and do any needed actions for the challenge to proceed. + * + * @param authorization + * @return a Challenge object that will be used to authorize a domain. + * @throws AcmeException in case of any issues + */ + Challenge createChallenge(Authorization authorization) throws AcmeException; + + /** + * Do any needed cleanup after challenge has completed successfully or failed. + */ + default void postChallengeAction(Challenge challenge) {} +} diff --git a/src/zutil/net/acme/AcmeClient.java b/src/zutil/net/acme/AcmeClient.java index 6c53463..f4cfe4b 100644 --- a/src/zutil/net/acme/AcmeClient.java +++ b/src/zutil/net/acme/AcmeClient.java @@ -17,14 +17,13 @@ import org.shredzone.acme4j.Certificate; 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.challenge.Challenge; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.util.CSRBuilder; import org.shredzone.acme4j.util.KeyPairUtils; import zutil.StringUtil; import zutil.log.LogUtil; import zutil.net.http.HttpServer; -import zutil.net.http.page.HttpStaticContentPage; /** * A class implementing the ACME protocol (Automatic Certificate Management Environment) used by the LetsEncrypt service. @@ -43,31 +42,35 @@ public class AcmeClient { private String acmeServerUrl; private AcmeDataStore dataStore; + private AcmeChallengeFactory challengeFactory; + + public AcmeClient(AcmeDataStore dataStore, HttpServer httpServer) { + this(dataStore, new AcmeHttpChallengeFactory(httpServer), ACME_SERVER_LETSENCRYPT_PRODUCTION); + } + public AcmeClient(AcmeDataStore dataStore, AcmeChallengeFactory challengeFactory) { + this(dataStore, challengeFactory, ACME_SERVER_LETSENCRYPT_PRODUCTION); + } /** * Create a new instance of the ACME Client */ - public AcmeClient(AcmeDataStore dataStore, String acmeServerUrl) { + public AcmeClient(AcmeDataStore dataStore, AcmeChallengeFactory challengeFactory, String acmeServerUrl) { Security.addProvider(new BouncyCastleProvider()); this.dataStore = dataStore; + this.challengeFactory = challengeFactory; this.acmeServerUrl = acmeServerUrl; } - public AcmeClient(AcmeDataStore dataStore) { - this(dataStore, ACME_SERVER_LETSENCRYPT_PRODUCTION); - } - /** * Generates a certificate for the given domains. Also takes care for the registration * process. * - * @param httpServer the web server where the challenge and response will be performed on and where the certificate will be applied to. * @param domains the domains to get a certificates for * @return a certificate for the given domains. */ - public X509Certificate fetchCertificate(HttpServer httpServer, String... domains) throws IOException, AcmeException { + public X509Certificate fetchCertificate(String... domains) throws IOException, AcmeException { // ------------------------------------------------ // Read in keys // ------------------------------------------------ @@ -94,7 +97,10 @@ public class AcmeClient { // Perform all required authorizations for (Authorization auth : order.getAuthorizations()) { - execHttpChallenge(auth, httpServer); + if (auth.getStatus() == Status.VALID) + continue; // The authorization is already valid. No need to process a challenge. + + execDomainChallenge(auth); } // Generate a "Certificate Signing Request" for all of the domains, and sign it with the domain key pair. @@ -106,7 +112,7 @@ public class AcmeClient { // Wait for the order to complete try { - for (int attempts = 0; attempts < 10; attempts--) { + for (int attempts = 0; attempts < 10; attempts++) { // Did the order pass or fail? if (order.getStatus() == Status.VALID) { break; @@ -115,7 +121,9 @@ public class AcmeClient { } // Wait for a few seconds - Thread.sleep(100L + 500L * attempts); + long sleep = 100L + 1000L * attempts; + logger.fine("Challenge not yet completed, sleeping for: " + StringUtil.formatTimeToString(sleep)); + Thread.sleep(sleep); // Then update the status order.update(); @@ -166,49 +174,38 @@ public class AcmeClient { * Authorize a domain. It will be associated with your account, so you will be able to * retrieve a signed certificate for the domain later. * - * @param auth {@link Authorization} to perform + * @param authorization {@link Authorization} to perform */ - private void execHttpChallenge(Authorization auth, HttpServer httpServer) throws AcmeException { - logger.info("Authorization for domain: " + auth.getIdentifier().getDomain()); + private void execDomainChallenge(Authorization authorization) throws AcmeException { + logger.info("Authorization for domain: " + authorization.getIdentifier().getDomain()); + Challenge challenge = null; - // The authorization is already valid. No need to process a challenge. - if (auth.getStatus() == Status.VALID) { - return; - } - - // Find the desired challenge and prepare it. - Http01Challenge challenge = auth.findChallenge(Http01Challenge.class); - if (challenge == null) { - throw new AcmeException("Found no " + Http01Challenge.TYPE + " challenge."); - } - - // If the challenge is already verified, there's no need to execute it again. - if (challenge.getStatus() == Status.VALID) - return; - - String url = "http://" + auth.getIdentifier().getDomain(); - String path = "/.well-known/acme-challenge/" + challenge.getToken(); - String content = challenge.getAuthorization(); - - // Output the challenge, wait for acknowledge... - logger.fine("Adding challenge HttpPage at: " + url + path); - httpServer.setPage(path, new HttpStaticContentPage(content)); - - // Now trigger the challenge. - challenge.trigger(); - - // Poll for the challenge to complete. try { - for (int attempts = 0; attempts < 10; attempts--) { + challenge = challengeFactory.createChallenge(authorization); + + // If the challenge is already verified, there's no need to execute it again. + if (challenge.getStatus() == Status.VALID) + return; + + // Now trigger the challenge. + challenge.trigger(); + + // Poll for the challenge to complete. + + for (int attempts = 0; attempts < 30; attempts++) { // Did the authorization fail? if (challenge.getStatus() == Status.VALID) { break; } else if (challenge.getStatus() == Status.INVALID) { - throw new AcmeException("Certificate challenge failed: " + challenge.getError()); + //throw new AcmeException("Certificate challenge failed: " + challenge.getError()); + logger.severe("Certificate challenge failed: " + challenge.getError()); + challenge.trigger(); } // Wait for a few seconds - Thread.sleep(100L + 500L * attempts); + long sleep = 100L + 5000L * attempts; + logger.fine("Challenge not yet completed, sleeping for: " + StringUtil.formatTimeToString(sleep)); + Thread.sleep(sleep); // Then update the status challenge.update(); @@ -216,12 +213,14 @@ public class AcmeClient { // All reattempts are used up and there is still no valid authorization? if (challenge.getStatus() != Status.VALID) - throw new AcmeException("Failed to pass the challenge for domain " + auth.getIdentifier().getDomain()); + throw new AcmeException("Failed to pass the challenge for domain " + authorization.getIdentifier().getDomain()); } catch (InterruptedException ex) { logger.log(Level.SEVERE, "Interrupted", ex); } finally { // Cleanup - httpServer.removePage(path); + challengeFactory.postChallengeAction(challenge); } + + logger.fine("Domain challenge executed successfully."); } } diff --git a/src/zutil/net/acme/AcmeFileDataStore.java b/src/zutil/net/acme/AcmeFileDataStore.java index 2805580..670e93d 100644 --- a/src/zutil/net/acme/AcmeFileDataStore.java +++ b/src/zutil/net/acme/AcmeFileDataStore.java @@ -9,8 +9,6 @@ public class AcmeFileDataStore implements AcmeDataStore { private final File userKeyFile; private final File domainKeyFile; - private final File domainCsrFile; - private final File domainChainFile; /** @@ -21,8 +19,6 @@ public class AcmeFileDataStore implements AcmeDataStore { public AcmeFileDataStore(File folder) { this.userKeyFile = new File(folder, "user.key"); this.domainKeyFile = new File(folder, "domain.key"); - this.domainCsrFile = new File(folder, "domain.csr"); - this.domainChainFile = new File(folder, "domain-chain.crt"); } diff --git a/src/zutil/net/acme/AcmeHttpChallengeFactory.java b/src/zutil/net/acme/AcmeHttpChallengeFactory.java new file mode 100644 index 0000000..21d3da1 --- /dev/null +++ b/src/zutil/net/acme/AcmeHttpChallengeFactory.java @@ -0,0 +1,55 @@ +package zutil.net.acme; + +import org.shredzone.acme4j.Authorization; +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.challenge.Http01Challenge; +import org.shredzone.acme4j.exception.AcmeException; +import zutil.log.LogUtil; +import zutil.net.http.HttpServer; +import zutil.net.http.HttpURL; +import zutil.net.http.page.HttpStaticContentPage; + +import java.util.logging.Logger; + +/** + * Class implementing HTTP based challenge logic of the ACME protocol through the Zutil HttpServer. + */ +public class AcmeHttpChallengeFactory implements AcmeChallengeFactory { + private static final Logger logger = LogUtil.getLogger(); + + private HttpServer httpServer; + private HttpURL url; + + + public AcmeHttpChallengeFactory(HttpServer httpServer) { + this.httpServer = httpServer; + } + + + @Override + public Challenge createChallenge(Authorization authorization) throws AcmeException { + Http01Challenge challenge = authorization.findChallenge(Http01Challenge.class); + if (challenge == null) { + throw new AcmeException("Found no " + Http01Challenge.TYPE + " challenge."); + } + + url = new HttpURL(); + url.setProtocol("http"); + url.setHost(authorization.getIdentifier().getDomain()); + url.setPort(httpServer.getPort()); + url.setPath("/.well-known/acme-challenge/" + challenge.getToken()); + + // Output the challenge, wait for acknowledge... + logger.fine("Adding challenge HttpPage at: " + url); + httpServer.setPage(url.getPath(), new HttpStaticContentPage(challenge.getAuthorization())); + + return challenge; + } + + @Override + public void postChallengeAction(Challenge challenge) { + if (url != null) { + httpServer.removePage(url.getPath()); + } + } +} diff --git a/src/zutil/net/acme/AcmeManualDnsChallengeFactory.java b/src/zutil/net/acme/AcmeManualDnsChallengeFactory.java new file mode 100644 index 0000000..c4fbc49 --- /dev/null +++ b/src/zutil/net/acme/AcmeManualDnsChallengeFactory.java @@ -0,0 +1,41 @@ +package zutil.net.acme; + +import org.shredzone.acme4j.Authorization; +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.challenge.Dns01Challenge; +import org.shredzone.acme4j.exception.AcmeException; +import zutil.log.LogUtil; + +import java.util.HashMap; +import java.util.logging.Logger; + +/** + * Class implementing HTTP based challenge logic of the ACME protocol through the Zutil HttpServer. + */ +public class AcmeManualDnsChallengeFactory implements AcmeChallengeFactory { + private static final Logger logger = LogUtil.getLogger(); + + private HashMap challengeCache = new HashMap<>(); + + @Override + public Challenge createChallenge(Authorization authorization) throws AcmeException { + if (challengeCache.containsKey(authorization.getIdentifier().getDomain())) + return challengeCache.get(authorization.getIdentifier().getDomain()); + + Dns01Challenge challenge = authorization.findChallenge(Dns01Challenge.class); + if (challenge == null) { + throw new AcmeException("Found no " + Dns01Challenge.TYPE + " challenge."); + } + + // Notify user of the required manual intervention + + logger.warning( + "---------------------------- ATTENTION ----------------------------\n" + + "For certificate challenge to pass please create a DNS TXT record:\n" + + "_acme-challenge." + authorization.getIdentifier().getDomain() + ". IN TXT " + challenge.getDigest() + "\n" + + "--------------------------------------------------------------------"); + + challengeCache.put(authorization.getIdentifier().getDomain(), challenge); + return challenge; + } +} diff --git a/src/zutil/net/http/HttpServer.java b/src/zutil/net/http/HttpServer.java index 7d104f5..49a4e9b 100755 --- a/src/zutil/net/http/HttpServer.java +++ b/src/zutil/net/http/HttpServer.java @@ -34,6 +34,8 @@ import java.io.BufferedInputStream; import java.io.File; import java.io.IOException; import java.net.Socket; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; @@ -78,11 +80,23 @@ public class HttpServer extends ThreadedTCPNetworkServer{ logger.info("HTTP Server ready and listening to port: " + port); } /** - * Creates a new instance of the sever + * Creates a new instance of the sever which accepts SSL connections * * @param port The port that the server should listen to - * @param keyStore If this is not null then the server will use SSL connection with this keyStore file path - * @param keyStorePass If this is not null then the server will use a SSL connection with the given certificate + * @param certificate The certificate that should be used for the servers SSL connections + */ + public HttpServer(int port, Certificate certificate) throws IOException { + super(port, certificate); + initGarbageCollector(); + + logger.info("HTTPS Server ready and listening to port: " + port); + } + /** + * Creates a new instance of the sever which accepts SSL connections + * + * @param port The port that the server should listen to + * @param keyStore The keyStore containing the certificate to use for the servers SSL connections + * @param keyStorePass The password to unlock the key store. */ public HttpServer(int port, File keyStore, char[] keyStorePass) throws IOException { super(port, keyStore, keyStorePass); diff --git a/src/zutil/net/threaded/ThreadedTCPNetworkServer.java b/src/zutil/net/threaded/ThreadedTCPNetworkServer.java index 4037146..16b45a0 100755 --- a/src/zutil/net/threaded/ThreadedTCPNetworkServer.java +++ b/src/zutil/net/threaded/ThreadedTCPNetworkServer.java @@ -36,6 +36,7 @@ import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; import java.security.*; +import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -81,7 +82,7 @@ public abstract class ThreadedTCPNetworkServer extends Thread { * @param port the port that the server should listen to. * @param certificate the certificate for the servers domain. */ - public ThreadedTCPNetworkServer(int port, X509Certificate certificate) throws IOException { + public ThreadedTCPNetworkServer(int port, Certificate certificate) throws IOException { this.port = port; this.serverSocket = createSSLSocket(port, certificate); } @@ -112,7 +113,7 @@ public abstract class ThreadedTCPNetworkServer extends Thread { * @param certificate the certificate for the servers domain. * @return a SSLServerSocket object */ - private static ServerSocket createSSLSocket(int port, X509Certificate certificate) throws IOException{ + private static ServerSocket createSSLSocket(int port, Certificate certificate) throws IOException{ try { KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); keyStore.load(null); // Create empty keystore @@ -144,10 +145,16 @@ public abstract class ThreadedTCPNetworkServer extends Thread { return socketFactory.createServerSocket(port); } + /** + * @return the port that this TCP server is listening to. + */ + public int getPort() { + return port; + } public void run() { try { - logger.info("Listening for TCP Connections on port: " + port); + logger.info("Accepting TCP Connections on port: " + port); while (true) { Socket connectionSocket = serverSocket.accept();