| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| package org.apache.meecrowave.letencrypt; |
| |
| import static java.util.Optional.ofNullable; |
| |
| import java.io.BufferedWriter; |
| import java.io.File; |
| import java.io.FileReader; |
| import java.io.FileWriter; |
| import java.io.IOException; |
| import java.io.Writer; |
| import java.security.KeyPair; |
| import java.security.KeyPairGenerator; |
| import java.security.NoSuchAlgorithmException; |
| import java.util.Objects; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.ScheduledExecutorService; |
| import java.util.concurrent.ScheduledFuture; |
| import java.util.concurrent.ThreadFactory; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.atomic.AtomicReference; |
| import java.util.function.BiConsumer; |
| import java.util.stream.Stream; |
| |
| import org.apache.coyote.http11.AbstractHttp11Protocol; |
| import org.apache.meecrowave.logging.tomcat.LogFacade; |
| import org.apache.meecrowave.runner.Cli; |
| import org.apache.meecrowave.runner.cli.CliOption; |
| import org.bouncycastle.openssl.PEMKeyPair; |
| import org.bouncycastle.openssl.PEMParser; |
| import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; |
| import org.bouncycastle.openssl.jcajce.JcaPEMWriter; |
| import org.shredzone.acme4j.Account; |
| import org.shredzone.acme4j.AccountBuilder; |
| import org.shredzone.acme4j.Authorization; |
| 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.Challenge; |
| import org.shredzone.acme4j.challenge.Http01Challenge; |
| import org.shredzone.acme4j.exception.AcmeException; |
| import org.shredzone.acme4j.util.CSRBuilder; |
| |
| // we depend on bouncycastle but user myst add it to be able to use that |
| // todo: check we can get rid of it and use jaxrs client instead of acme lib |
| public class LetsEncryptReloadLifecycle implements AutoCloseable, Runnable { |
| |
| private final AtomicReference<LogFacade> logger = new AtomicReference<>(); |
| |
| private final AbstractHttp11Protocol<?> protocol; |
| |
| private final ScheduledExecutorService thread; |
| |
| private final ScheduledFuture<?> refreshTask; |
| |
| private final LetsEncryptConfig config; |
| |
| private final BiConsumer<String, String> challengeUpdater; |
| |
| public LetsEncryptReloadLifecycle(final LetsEncryptConfig config, final AbstractHttp11Protocol<?> protocol, |
| final BiConsumer<String, String> challengeUpdater) { |
| this.config = config; |
| this.config.init(); |
| this.protocol = protocol; |
| this.challengeUpdater = challengeUpdater; |
| |
| final SecurityManager s = System.getSecurityManager(); |
| final ThreadGroup group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); |
| this.thread = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { |
| |
| @Override |
| public Thread newThread(final Runnable r) { |
| final Thread newThread = new Thread(group, r, LetsEncryptReloadLifecycle.class.getName() + "_" + hashCode()); |
| newThread.setDaemon(false); |
| newThread.setPriority(Thread.NORM_PRIORITY); |
| newThread.setContextClassLoader(getClass().getClassLoader()); |
| return newThread; |
| } |
| }); |
| refreshTask = this.thread.scheduleAtFixedRate(this, 0L, config.getRefreshInterval(), TimeUnit.SECONDS); |
| } |
| |
| @Override |
| public synchronized void run() { |
| final KeyPair userKeyPair = loadOrCreateKeyPair(config.getUserKeySize(), config.getUserKeyLocation()); |
| final KeyPair domainKeyPair = loadOrCreateKeyPair(config.getDomainKeySize(), config.getDomainKey()); |
| |
| final Session session = new Session(config.getEndpoint()); |
| try { |
| final Account account = new AccountBuilder().agreeToTermsOfService().useKeyPair(userKeyPair).create(session); |
| final Order order = account.newOrder().domains(config.getDomains().trim().split(",")).create(); |
| final boolean updated = order.getAuthorizations().stream().map(authorization -> { |
| try { |
| return authorize(authorization); |
| } catch (final AcmeException e) { |
| getLogger().error(e.getMessage(), e); |
| return false; |
| } |
| }).reduce(false, (previous, val) -> previous || val); |
| if (!updated) { |
| return; |
| } |
| |
| final CSRBuilder csrBuilder = new CSRBuilder(); |
| csrBuilder.addDomains(config.getDomains()); |
| csrBuilder.sign(domainKeyPair); |
| |
| try (final Writer writer = new BufferedWriter(new FileWriter(config.getDomainCertificate()))) { |
| csrBuilder.write(writer); |
| } |
| |
| order.execute(csrBuilder.getEncoded()); |
| |
| try { |
| int attempts = config.getRetryCount(); |
| while (order.getStatus() != Status.VALID && attempts-- > 0) { |
| if (order.getStatus() == Status.INVALID) { |
| throw new AcmeException("Order failed... Giving up."); |
| } |
| Thread.sleep(config.getRetryTimeoutMs()); |
| order.update(); |
| } |
| } catch (final InterruptedException ex) { |
| getLogger().error(ex.getMessage()); |
| Thread.currentThread().interrupt(); |
| return; |
| } |
| |
| final Certificate certificate = order.getCertificate(); |
| getLogger().info("Got new certificate " + certificate.getLocation() + " for domain(s) " + config.getDomains()); |
| |
| try (final Writer writer = new BufferedWriter(new FileWriter(config.getDomainChain()))) { |
| certificate.writeCertificate(writer); |
| } |
| |
| protocol.reloadSslHostConfigs(); |
| } catch (final AcmeException | IOException ex) { |
| getLogger().error(ex.getMessage(), ex); |
| } |
| } |
| |
| private LogFacade getLogger() { |
| LogFacade logFacade = logger.get(); |
| if (logFacade == null) { |
| logFacade = new LogFacade(getClass().getName()); |
| // ok to use 2 instances since the backing instance will be the same, so ignore returned value |
| logger.compareAndSet(null, logFacade); |
| } |
| return logFacade; |
| } |
| |
| @Override |
| public void close() { |
| ofNullable(refreshTask).ifPresent(t -> t.cancel(true)); |
| ofNullable(thread).ifPresent(ExecutorService::shutdownNow); |
| try { |
| thread.awaitTermination(5, TimeUnit.SECONDS); |
| } catch (final InterruptedException e) { |
| Thread.currentThread().interrupt(); |
| } |
| } |
| |
| private boolean authorize(final Authorization authorization) throws AcmeException { |
| final Challenge challenge = httpChallenge(authorization); |
| if (challenge == null) { |
| throw new AcmeException("HTTP challenge is null"); |
| } |
| if (challenge.getStatus() == Status.VALID) { |
| return false; |
| } |
| |
| challenge.trigger(); |
| |
| try { |
| int attempts = config.getRetryCount(); |
| while (challenge.getStatus() != Status.VALID && attempts-- > 0) { |
| if (challenge.getStatus() == Status.INVALID) { |
| throw new AcmeException("Invalid challenge status, exiting refresh iteration"); |
| } |
| |
| Thread.sleep(config.getRetryTimeoutMs()); |
| challenge.update(); |
| } |
| } catch (final InterruptedException ex) { |
| Thread.currentThread().interrupt(); |
| } |
| |
| if (challenge.getStatus() != Status.VALID) { |
| throw new AcmeException("Challenge for domain " + authorization.getDomain() + ", is invalid, exiting iteration"); |
| } |
| return true; |
| } |
| |
| private Challenge httpChallenge(final Authorization auth) throws AcmeException { |
| final Http01Challenge challenge = auth.findChallenge(Http01Challenge.TYPE); |
| if (challenge == null) { |
| throw new AcmeException("Challenge is null"); |
| } |
| |
| challengeUpdater.accept("/.well-known/acme-challenge/" + challenge.getToken(), challenge.getAuthorization()); |
| return challenge; |
| } |
| |
| private KeyPair loadOrCreateKeyPair(final int keySize, final File file) { |
| if (file.exists()) { |
| try (final PEMParser parser = new PEMParser(new FileReader(file))) { |
| return new JcaPEMKeyConverter().getKeyPair(PEMKeyPair.class.cast(parser.readObject())); |
| } catch (final IOException ex) { |
| throw new IllegalStateException("Can't read PEM file: " + file, ex); |
| } |
| } else { |
| try { |
| final KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); |
| keyGen.initialize(keySize); |
| final KeyPair keyPair = keyGen.generateKeyPair(); |
| try (final JcaPEMWriter writer = new JcaPEMWriter(new FileWriter(file))) { |
| writer.writeObject(keyPair); |
| } catch (final IOException ex) { |
| throw new IllegalStateException("Can't read PEM file: " + file, ex); |
| } |
| return keyPair; |
| } catch (final NoSuchAlgorithmException ex) { |
| throw new IllegalStateException(ex); |
| } |
| } |
| } |
| |
| public static class LetsEncryptConfig implements Cli.Options { |
| |
| @CliOption(name = "letsencrypt-refresh-interval", description = "Number of second between let'sencrypt refreshes") |
| private long refreshInterval = 60; |
| |
| @CliOption(name = "letsencrypt-domains", description = "Comma separated list of domains to manage") |
| private String domains; |
| |
| @CliOption(name = "letsencrypt-key-user-location", description = "Where the user key must be stored") |
| private File userKeyLocation; |
| |
| @CliOption(name = "letsencrypt-key-user-size", description = "User key size") |
| private int userKeySize = 2048; |
| |
| @CliOption(name = "letsencrypt-key-domain-location", description = "Where the domain key must be stored") |
| private File domainKey; |
| |
| @CliOption(name = "letsencrypt-key-domain-size", description = "Domain key size") |
| private int domainKeySize = 2048; |
| |
| @CliOption(name = "letsencrypt-certificate-domain-location", description = "Where the domain certificate must be stored") |
| private File domainCertificate; |
| |
| @CliOption(name = "letsencrypt-chain-domain-location", description = "Where the domain chain must be stored") |
| private File domainChain; |
| |
| @CliOption(name = "letsencrypt-endpoint", description = "Endpoint to use to get the certificates") |
| private String endpoint; |
| |
| @CliOption(name = "letsencrypt-endpoint-staging", description = "Ignore if endpoint is set, otherwise it set the endpoint accordingly") |
| private boolean staging = false; |
| |
| @CliOption(name = "letsencrypt-retry-timeout-ms", description = "How long to wait before retrying to get the certificate, default is 3s") |
| private long retryTimeoutMs = 3000; |
| |
| @CliOption(name = "letsencrypt-retry-count", description = "How many retries to do") |
| private int retryCount = 20; |
| |
| public void init() { |
| if (userKeyLocation == null) { |
| userKeyLocation = new File(System.getProperty("catalina.base"), "conf/letsencrypt/user.key"); |
| } |
| if (domainKey == null) { |
| domainKey = new File(System.getProperty("catalina.base"), "conf/letsencrypt/domain.key"); |
| } |
| if (domainCertificate == null) { |
| domainCertificate = new File(System.getProperty("catalina.base"), "conf/letsencrypt/domain.csr"); |
| } |
| if (domainChain == null) { |
| domainChain = new File(System.getProperty("catalina.base"), "conf/letsencrypt/domain.chain.csr"); |
| } |
| if (endpoint == null) { |
| if (isStaging()) { |
| endpoint = "https://acme-staging-v02.api.letsencrypt.org/directory"; |
| } else { |
| endpoint = "https://acme-v02.api.letsencrypt.org/directory"; |
| } |
| } |
| Stream.of(userKeyLocation, domainKey, domainCertificate, domainChain).map(File::getAbsoluteFile) |
| .map(File::getParentFile).filter(Objects::nonNull).distinct().forEach(File::mkdirs); |
| } |
| |
| public boolean isStaging() { |
| return staging; |
| } |
| |
| public int getRetryCount() { |
| return retryCount; |
| } |
| |
| public int getDomainKeySize() { |
| return domainKeySize; |
| } |
| |
| public String getEndpoint() { |
| return endpoint; |
| } |
| |
| public long getRefreshInterval() { |
| return refreshInterval; |
| } |
| |
| public String getDomains() { |
| return domains; |
| } |
| |
| public File getUserKeyLocation() { |
| return userKeyLocation; |
| } |
| |
| public int getUserKeySize() { |
| return userKeySize; |
| } |
| |
| public File getDomainKey() { |
| return domainKey; |
| } |
| |
| public File getDomainCertificate() { |
| return domainCertificate; |
| } |
| |
| public File getDomainChain() { |
| return domainChain; |
| } |
| |
| public long getRetryTimeoutMs() { |
| return retryTimeoutMs; |
| } |
| } |
| } |