acme4j是自动证书管理环境(ACME)协议的java客户端,可以自动执行验证和证书的颁发,该客户端是通过连接到ACME服务器,并执行所必要的步骤来管理证书。

1.引入依赖

<dependency>
    <groupId>org.shredzone.acme4j</groupId>
    <artifactId>acme4j-client</artifactId>
    <version>3.4.0</version>
</dependency>

2.自动申请证书

import club.symx.api.common.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.shredzone.acme4j.*;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.util.KeyPairUtils;

import java.io.*;
import java.security.KeyPair;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Duration;

/**
 * @Description
 * @Author srx
 * @date 2024/10/14 11:07
 */
@Slf4j
public class SslUtil {

    /**
     * acme账号的key,由该key确认身份,请勿遗失或泄露该key
     */
    private static final String ACCOUNT_KEY_PATH = "/home/frp/frp_client/ssl/account.key";
    /**
     * 证书
     */
    private static final String CERT_KEY_PATH = "/home/frp/frp_client/ssl/cert.key";
    /**
     * 证书
     */
    private static final String FULLCHAIN_CER_PATH = "/home/frp/frp_client/ssl/fullchain.cer";
    /**
     * http验证证书目录
     */
    private static final String SSL_VALIDATION_PATH = "/home/domain/.well-known/acme-challenge/";


    /**
     * 新增ssl证书
     * @param domain 域名
     * @throws Exception
     */
    public static void addSsl(String domain) throws Exception {
        // 获取账号秘钥
        KeyPair keyPair = getAccountKeyPair();
        // 创建会话并连接到 ACME 服务器
        Session session = new Session("https://acme-v02.api.letsencrypt.org/directory");
        // 登录
        Login login = new AccountBuilder()
                .addContact("mailto:your email")
                .agreeToTermsOfService()
                .useKeyPair(keyPair)
                .createLogin(session);
        Account account = login.getAccount();
        // 加载域名秘钥
        KeyPair domainKeyPair = KeyPairUtils.createKeyPair(2048);
        KeyPairUtils.writeKeyPair(domainKeyPair, new FileWriter(CERT_KEY_PATH));
        // 创建订单
        Order order = account.newOrder().domain(domain).create();
        log.info("订单创建完成");
        // 轮询挑战
        for (Authorization auth : order.getAuthorizations()) {
            // 授权域
            authorize(auth);
        }

        // 等待订单变为 READY
        log.info("等待订单状态改变");
        order.waitUntilReady(Duration.ofSeconds(15));
        // 订购证书
        log.info("开始订购证书");
        order.execute(domainKeyPair, csr -> {
            csr.setCountry("CN");
            csr.setState("Guangxi");
            csr.setLocality("Nanning");
            csr.setOrganization("山有木兮");
            csr.setCommonName(domain);
        });
        // 等待订单完成
        log.info("等待订单完成");
        Status status = order.waitForCompletion(Duration.ofSeconds(15));
        if (status != Status.VALID) {
            log.error("订单失败,原因: {}", order.getError().map(Problem::toString).orElse("unknown"));
            throw new BusinessException("申请SSL证书失败");
        }

        // 获取证书
        log.info("开始下载证书");
        Certificate certificate = order.getCertificate();

        log.info("成功!域 {} 的证书已生成!", domain);
        log.info("证书 URL: {}", certificate.getLocation());

        // 编写包含证书和链的组合文件。
        try (FileWriter fw = new FileWriter(FULLCHAIN_CER_PATH)) {
            certificate.writeCertificate(fw);
            log.info("证书已成功写入{}", FULLCHAIN_CER_PATH);
        }
    }

    /**
     * 获取账号key
     * @return
     * @throws IOException
     */
    private static KeyPair getAccountKeyPair() throws IOException {
        log.info("开始读取账号key");
        File file = new File(ACCOUNT_KEY_PATH);
        if (file.exists()) {
            return KeyPairUtils.readKeyPair(new FileReader(file));
        }
        KeyPair keyPair = KeyPairUtils.createKeyPair(2048);
        KeyPairUtils.writeKeyPair(keyPair, new FileWriter(ACCOUNT_KEY_PATH));
        return keyPair;
    }

    /**
     * 授权域
     * @param auth
     * @throws AcmeException
     * @throws InterruptedException
     */
    private static void authorize(Authorization auth) throws AcmeException, InterruptedException, IOException {
        log.info("授权状态:{}", auth.getStatus());
        // 授权已经有效, 无需处理质询
        if (auth.getStatus() == Status.VALID) {
            log.info("授权已经有效, 无需处理质询");
            return;
        }

        // 找到所需的挑战并做好准备
        Http01Challenge challenge = auth.findChallenge(Http01Challenge.class).orElseThrow(() -> new BusinessException("域名SSL证书申请失败"));
        // 如果质询已经过验证,则无需再次执行
        if (challenge.getStatus() == Status.VALID) {
            log.info("质询已经过验证,无需再次执行");
            return;
        }

        String token = challenge.getToken();
        String authorization = challenge.getAuthorization();
        // 创建验证文件
        log.info("创建验证文件");
        createVerificationFile(token, authorization);

        // 停止一秒钟后再提醒acme服务验证
        Thread.sleep(1000);
        challenge.trigger();

        // 轮询要完成的挑战
        Status status = challenge.waitForCompletion(Duration.ofSeconds(15));
        if (status != Status.VALID) {
            log.error("质询失败,原因: {}", challenge.getError().map(Problem::toString).orElse("unknown"));
            throw new BusinessException("质询失败...放弃。");
        }

        log.info("质询已完成!");
    }

    /**
     * 创建证书验证文件
     * @param token
     * @param authorization
     */
    private static void createVerificationFile(String token, String authorization) throws IOException{
        FileWriter fileWriter = new FileWriter(SSL_VALIDATION_PATH + token);
        fileWriter.write(authorization);
        fileWriter.close();
    }

}

3.吊销证书

public static boolean revoke() {
    try {
        // 读取证书文件
        InputStream inStream = new FileInputStream(FULLCHAIN_CER_PATH);
        CertificateFactory cf = CertificateFactory.getInstance("X.509");
        X509Certificate cert = (X509Certificate) cf.generateCertificate(inStream);
        inStream.close();
        
        // 连接acme服务
        Session session = new Session("https://acme-v02.api.letsencrypt.org/directory");
        KeyPair keyPair = getAccountKeyPair();
        Login login = new AccountBuilder()
                .addContact("mailto:your email")
                .agreeToTermsOfService()
                .useKeyPair(keyPair)
                .createLogin(session);
        // 吊销证书,原因为null
        Certificate.revoke(login, cert, null);
    } catch (Exception e) {
        log.error("吊销证书出错:{}", e.getMessage());
    }
    return false;
}

4.查询证书过期时间

public static boolean checkExpire() {
    try {
        // 读取证书文件
        InputStream inStream = new FileInputStream(FULLCHAIN_CER_PATH);
        CertificateFactory cf = CertificateFactory.getInstance("X.509");
        X509Certificate cert = (X509Certificate) cf.generateCertificate(inStream);
        inStream.close();

        // 获取证书过期时间
        long expire = cert.getNotAfter().getTime();
        System.out.println(expire);
    } catch (Exception e) {
        log.error("查询ssl过期时间出错:{}", e.getMessage());
    }
    return false;
}