自动化续签部署腾讯云SSL证书

前往原站点查看

2024-10-10 13:27:49

    SSL证书,可以让连接变得更加安全,数据都会经过一层加密,如果使用传统的http/ws连接,如果不做处理,数据不会加密,那么在传输过程中,可以通过arp攻击或者其他拦截方式,轻易的获取到数据的明文内容,如果长时间的被监控、分析,那么会有严重的隐私泄露风险。所以随着数据日益膨胀,ssl可以说是每个网站必备的了。

    当然,本文的重点不是来演示如何通过arp攻击来拦截http请求并且解包,而是讲述的如何自动化部署ssl证书。当然本文的自动化方案基础平台是腾讯云。其他服务器提供平台可能并不适用。

为什么要自动化

    腾讯云,为每一个用户提供了50个免费的证书(白嫖主义者[[嘻嘻嘻]]),在半年前,每个免费的证书都有着长达一年的时效,证书如果即将过期,会发送短信、微信通知等方式来提醒,只需要手动的重新申请几个域名的证书,并且部署到服务器即可,其实不算太麻烦。

    但是,今年开始,腾讯云的免费证书只有短短的3个月有效期!3个月,缩水了一大大大半,如果还用上述方式,每过两个半月就要续签部署,相对来说就显得有些麻烦。所以就寻求自动化的方式来解放双手。

    好在腾讯云官方有提供完整的接口,能够完整的覆盖自动化的全部流程,所以直接开干!(我这边是以springboot作为载体实施,其他语言也类似)。

自动化的基础流程

    首先调用查询证书列表功能,查询每个证书的状态,会有几种可能的结果,需要进行不同的处理:

1. 该域名只有一个证书,且该证书未到续签临界期间(我的默认设定是7天内过期则自动续签),则无需处理,因为离过期日期相差还很远。

2. 该域名只有一个证书,不过在临界期间之中,此时,需要自动续签一个同域名的证书,但是不删除即将过期的那个证书,因为要保证ssl全覆盖。

3. 该域名存在多个证书,但是都在临界期之中,需要自动续签一个同域名的证书。

4. 该域名存在多个证书,存在审核中的证书,那么有可能是刚申请的证书还没有审核结束,那么本轮跳过,等待审核通过再进行处理。

5. 该域名存在多个证书,存在至少一个处于临界期之外的,说明有证书离过期日尚远,还有其他的证书即将到期,这通常是上面【2】或者【3】导致的结果,此时需要先下载解压该临界期之外的证书到证书目录下,随后删除其余证书即可。

    处理完成后,执行nginx的刷新指令,即可无缝替换。

基础准备

    1. 引入腾讯云处理ssl的sdk。

        <dependency>
            <groupId>com.tencentcloudapi</groupId>
            <artifactId>tencentcloud-sdk-java-ssl</artifactId>
            <version>3.1.1095</version>
        </dependency>

    2. 自定义一个配置类,用于设定一些核心的基础配置。

package top.dreamcenter.ssl.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.List;

@Data
@Component
@ConfigurationProperties(prefix = "dreamcenter.ssl")
public class SslProperties {

    /**
     * 密钥Id
     */
    private String secretId;

    /**
     * 密钥key
     */
    private String secretKey;

    /**
     * 地域
     */
    private String region;

    /**
     * 证书存储目录
     */
    private String sslPath;

    /**
     * 自动部署临界天数
     */
    private int autoPublishDays = 7;

    /**
     * 需要注册证书的域名
     */
    private List<String> required;
}

    3. 在application.properties中进行配置,ssl-path为自己全部的ssl证书存储位置,后续下载和解压的文件都会部署到这个文件夹内,auto-publish-days即为临界日。

spring.application.name=ssl
server.port=16456
# dreamcenter config
dreamcenter.ssl.secret-id=AKIDudxxxxxxxx
dreamcenter.ssl.secret-key=RdbKxxxxxxxxx
dreamcenter.ssl.region=ap-shanghai
dreamcenter.ssl.ssl-path=C:\\Users\\Administrator\\Desktop\\ssl
dreamcenter.ssl.auto-publish-days=30

    4. 注册腾讯云的SslClient。从配置类中获取id和密钥注册到容器。

package top.dreamcenter.ssl.config;

import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.ssl.v20191205.SslClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.dreamcenter.ssl.properties.SslProperties;

@Configuration
public class SslConfig {

    @Bean
    public SslClient client(SslProperties sslProperties) {
        Credential cred = new Credential(
                sslProperties.getSecretId(), sslProperties.getSecretKey()
        );
        return new SslClient(cred, sslProperties.getRegion());
    }

}

    5. 解压压缩包到指定位置的工具类(springboot貌似没有提供直接的解压方式,引入hutools又太冗余),所以就自己写一个工具类了。

package top.dreamcenter.ssl.util;

import java.io.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

public class ZipUtil {

    public static void unzip(String zipPath, String targetDir) throws Exception {
        FileInputStream file = new FileInputStream(zipPath);

        BufferedInputStream bufferedInputStream = new BufferedInputStream(file);
        ZipInputStream zipInputStream = new ZipInputStream(bufferedInputStream);

        ZipEntry nextEntry;
        while ((nextEntry = zipInputStream.getNextEntry()) != null){
            // 获取文件
            String path = targetDir + File.separator + nextEntry.getName();

            File raw = new File(path);

            if (nextEntry.isDirectory()) raw.mkdirs();
            else {
                FileOutputStream fos = new FileOutputStream(path);

                byte[] bytes = new byte[1024];
                int len;
                while ((len = zipInputStream.read(bytes,0, 1024)) != -1){
                    fos.write(bytes, 0 , len);
                }

                fos.close();
            }
        }

        bufferedInputStream.close();
        zipInputStream.close();
    }
}

    6. 日志格式化,与腾讯云接口的返回值拟合,进行日志格式化。

package top.dreamcenter.ssl.util;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class SlfUtil {

    public static void buildReqInfo(String requestId){
        String methodName = Thread.currentThread().getStackTrace()[2].getMethodName();
        log.info("req: {} ; res: suc ; requestId: {}.", methodName, requestId);
    }

    public static void buildReqError(String res, String requestId){
        String methodName = Thread.currentThread().getStackTrace()[2].getMethodName();
        log.error("req: {} ; res: {} ; requestId: {}.", methodName, res, requestId);
    }
}


腾讯云ssl接口调用

    对于免费证书,主要会用到这几个接口:列表查询、免费证书申请、证书下载解压、证书删除。贴一个代码实现。

package top.dreamcenter.ssl.service;

import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import com.tencentcloudapi.ssl.v20191205.SslClient;
import com.tencentcloudapi.ssl.v20191205.models.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.FileCopyUtils;
import top.dreamcenter.ssl.properties.SslProperties;
import top.dreamcenter.ssl.util.SlfUtil;
import top.dreamcenter.ssl.util.ZipUtil;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@Service
public class SslService {

    @Autowired
    private SslProperties sslProperties;

    @Autowired
    private SslClient client;

    /**
     * 查询所有证书
     * @return list
     */
    public List<Certificates> getAllCertificates() {
        DescribeCertificatesRequest request = new DescribeCertificatesRequest();

        try {
            DescribeCertificatesResponse response = client.DescribeCertificates(request);
            SlfUtil.buildReqInfo(response.getRequestId());
            Certificates[] certificates = response.getCertificates();
            return Arrays.asList(certificates);
        } catch (TencentCloudSDKException e) {
            SlfUtil.buildReqError(e.getErrorCode(), e.getRequestId());
            e.printStackTrace();
            return new ArrayList<>();
        }
    }

    /**
     * 申请免费证书
     * @param domain 域名
     * @return true 成功; false 失败
     */
    public boolean freeGet(String domain) {
        ApplyCertificateRequest request = new ApplyCertificateRequest();
        request.setDvAuthMethod("DNS_AUTO"); // 自动DNS验证
        request.setDomainName(domain);  // 申请的域名
        request.setDeleteDnsAutoRecord(true); // 自动删除域名验证记录

        try {
            ApplyCertificateResponse response = client.ApplyCertificate(request);
            SlfUtil.buildReqInfo(response.getRequestId());
            return true;
        } catch (TencentCloudSDKException e) {
            SlfUtil.buildReqError(e.getErrorCode(), e.getRequestId());
            e.printStackTrace();
            return false;
        }

    }

    /**
     * 下载证书并且解压
     * @param certificateId -
     * @return true if success
     */
    public boolean downloadAndUnzip(String certificateId) {
        DescribeDownloadCertificateUrlRequest request = new DescribeDownloadCertificateUrlRequest();
        request.setCertificateId(certificateId);
        request.setServiceType("nginx");
        try {
            DescribeDownloadCertificateUrlResponse response = client.DescribeDownloadCertificateUrl(request);
            String downloadCertificateUrl = response.getDownloadCertificateUrl();
            String fileName = response.getDownloadFilename();

            // 拼接路径
            String path = sslProperties.getSslPath() + File.separator + fileName;

            // 证书下载
            URL url = new URL(downloadCertificateUrl);
            InputStream inputStream = url.openStream();
            FileOutputStream file = new FileOutputStream(path);
            FileCopyUtils.copy(inputStream, file);

            // 证书压缩包解压
            ZipUtil.unzip(path, sslProperties.getSslPath());

            return true;
        } catch (TencentCloudSDKException e) {
            SlfUtil.buildReqError(e.getErrorCode(), e.getRequestId());
            e.printStackTrace();
            return false;
        } catch (Exception e) {
            SlfUtil.buildReqError(e.getMessage(), "");
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 列表删除证书
     * @param certificates -
     * @return true if all delete
     */
    public boolean deleteList(List<Certificates> certificates) {
        boolean allSuccess = true;

        for (Certificates certificateRaw: certificates) {
            String certificate = certificateRaw.getCertificateId();

            DeleteCertificateRequest request = new DeleteCertificateRequest();
            request.setCertificateId(certificate);

            DeleteCertificateResponse response = null;
            try {
                response = client.DeleteCertificate(request);
                if (!response.getDeleteResult()) allSuccess = false;
            } catch (TencentCloudSDKException e) {
                SlfUtil.buildReqError(e.getErrorCode(), e.getRequestId());
                e.printStackTrace();
            }
        }

        return allSuccess;
    }

}


自动任务启动流程

    最后一步就是设置自动任务来执行自动化的基础流程了(我这里的代码把nginx的reload指令给漏了,可自行添加exec执行重新装载),我这边设置的执行时间为每天的凌晨一点。

package top.dreamcenter.ssl.schedule;

import com.tencentcloudapi.ssl.v20191205.models.Certificates;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import top.dreamcenter.ssl.properties.SslProperties;
import top.dreamcenter.ssl.service.SslService;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;

@Component
public class SslSchedule {

    @Autowired
    private SslProperties sslProperties;

    @Autowired
    private SslService sslService;

    @Scheduled(cron = "0 0 1 * * ?")
    public void downloadAndPublish(){
        LocalDate now = LocalDate.now();
        System.out.println("\n===="+ now + " =====");

        // 查询现有的证书
        List<Certificates> allCertificates = sslService.getAllCertificates();

        /*  情况
                A 一个同域名 未过期
                    - 不做任何处理
                B 一个同域名 即将过期
                    - 申请新证书
                    - 不做其他处理
                C 多个同域名 存在一个未过期
                    - 下载未过期,并且解压
                    - 删除对即将过期
         */

        // 数据分组
        HashMap<String, List<Certificates>> resolveMap = new HashMap<>();
        allCertificates.forEach(certificates -> {
            if(resolveMap.containsKey(certificates.getDomain())){
                resolveMap.get(certificates.getDomain()).add(certificates);
            } else {
                // 首次找到,创建list并且插入数据
                LinkedList<Certificates> list = new LinkedList<>();
                list.add(certificates);
                resolveMap.put(certificates.getDomain(), list);
            }
        });

        int autoPublishDays = sslProperties.getAutoPublishDays();
        resolveMap.forEach((k, v) -> {
            if (v.size() == 1 ) {
                LocalDate endDate = LocalDate.parse(v.get(0).getCertEndTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                long subDays = ChronoUnit.DAYS.between(now, endDate);
                if (subDays <= autoPublishDays) {
                    // 处理情况 B
                    System.out.println(k + " 需要申请:");
                    boolean res = sslService.freeGet(k);
                    System.out.println(k + (res ? " 申请成功." : " 申请失败."));
                } else {
                    // 处理情况 A
                    System.out.println(k + " 无需申请:.");
                }

            } else if (v.size() > 1) {
                // 处理情况 C
                System.out.println(k + " 多个同名证书:");
                // 查找未过期和即将过期的数据
                List<Certificates> fine = new LinkedList<>();
                List<Certificates> toDel = new LinkedList<>();

                boolean skip = false;
                for (Certificates certificates : v) {
                    try {
                        if (certificates.getStatus() == 0) skip = true;
                        LocalDate endDate = LocalDate.parse(certificates.getCertEndTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                        long subDays = ChronoUnit.DAYS.between(now, endDate);
                        if (subDays <= autoPublishDays) {
                            toDel.add(certificates);
                        } else {
                            fine.add(certificates);
                        }
                    } catch (Exception ignore) {
                    }
                }

                if (skip) {
                    // 存在审核中证书,本次跳过
                    System.out.println("\t存在审核中证书,本次跳过.");
                } if (fine.size() == 0) {
                    // 全部即将过期,需要续签
                    System.out.println("\t全部即将过期,需要续签.");
                    boolean res = sslService.freeGet(k);
                    System.out.println((res ? "\t申请成功." : "\t申请失败."));
                } else {
                    // 下载证书,保留一个fine,删除其他(如果下载失败,报错)
                    System.out.println("\t下载证书,保留一个fine,删除其他(如果下载失败,报错).");

                    Certificates effect = fine.get(0);

                    boolean res = sslService.downloadAndUnzip(effect.getCertificateId());

                    if (res) {
                        // 成功, 删除其他的证书
                        System.out.println("\t证书下载解压成功,待删除其他无效证书.");

                        List<Certificates> toRemove = new LinkedList<>();
                        toRemove.addAll(fine.subList(1, fine.size()));
                        toRemove.addAll(toDel);

                        boolean delRes = sslService.deleteList(toRemove);
                        System.out.println((delRes ? "\t完成无效证书清除." : "\t部分无效证书清除失败."));
                    } else {
                        // 失败,报错,不做处理
                        System.out.println("\t证书下载解压失败.");
                    }
                }
            }
        });

    }

}


结果展示

    当然,现在执行已然奏效,前天就收到了自动更新的短信,今天早上发现证书就已经更新下载了,服务器上也看到了日志记录。十月8日,都在临界期之外,十月9日auth域名证书重新申请了,十月10日下载了该证书,并且删除了即将时效的证书。


服务器效果图





上一篇: springboot连接apollo与DES解密
下一篇: 字体图标ttf合并