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);
}
}
对于免费证书,主要会用到这几个接口:列表查询、免费证书申请、证书下载解压、证书删除。贴一个代码实现。
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日下载了该证书,并且删除了即将时效的证书。
服务器效果图