Added factory for ACME protocol
This commit is contained in:
parent
4955731abc
commit
708577debf
8 changed files with 192 additions and 58 deletions
22
src/zutil/net/acme/AcmeChallengeFactory.java
Normal file
22
src/zutil/net/acme/AcmeChallengeFactory.java
Normal file
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
try {
|
||||
challenge = challengeFactory.createChallenge(authorization);
|
||||
|
||||
// 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--) {
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
55
src/zutil/net/acme/AcmeHttpChallengeFactory.java
Normal file
55
src/zutil/net/acme/AcmeHttpChallengeFactory.java
Normal file
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/zutil/net/acme/AcmeManualDnsChallengeFactory.java
Normal file
41
src/zutil/net/acme/AcmeManualDnsChallengeFactory.java
Normal file
|
|
@ -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<String,Dns01Challenge> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue