开源实现SSL证书的监听和自动续期

902 阅读10分钟

开始之前

在我们的项目中(无论是个人项目还是其他的小型项目),通常都会搭配一个 SSL 证书,如此一来网站便会显得更具保障性。然而,这种免费的证书愈发难以使用,我们常常需要去处理 SSL 证书过期的问题。那么,是否存在一种办法能够更为方便快捷地运用这个功能呢?

首先,的确有相当数量的网站提供免费的 SSL 证书,其中有的甚至还有各种各样生成的脚本。那么现在让我们来进行一番分析。首先得定时去监控证书是否已经过期,其次还需要在这个时间即将临近之时去创建免费的证书,并将现有的证书予以替换。好的,接下来我们看看哪一家是合适的……抱歉,符合这种条件的是需要收费的,那么到了该开始编写我们代码的时候了。

不巧的是,当下市场上的诸多免费证书,有很大一部分都是基于 Let’s Encrypt 的服务而进行开发的。尽管 Let’s Encrypt 所提供的是一项全球性的免费服务,然而却抵挡不住国内众多类似的商家在开发之后进行收费。我们同样是依据 Let’s Encrypt 来构建我们自身的能力。不过我们现在是免费开放源代码给大家使用。

整体的项目计划使用NodeJS开放,其他语言有需求可以同样自己开发,或者提出要求,由社区提供开发后的服务。本项目代码放在GITHUB,有需要可以直接复制使用。项目源代码

开发一个自己的SSL更新服务

项目设计功能包含:自动监控、SSL服务、Nginx更新服务,几个部分都有独立设计可以单独使用。项目主要文件内容如下:

  • src 主要代码入口。
  • src/dingding.js钉钉通知服务,包含一个接收markdown格式的通知接口。
  • src/index.js主要使用场景的示例。
  • src/ssl.jsSSL证书服务,包含保存创建更新的逻辑和执行nginx的命令。
  • package.json包信息等。

创建自动监控任务

在一切的开始,我们首先是需要一个自动监控现有域名是否到期的能力。这里我们使用cron来做我们的定时任务。需要注意的是,如果一个项目设置的执行周期特别长,需要考虑在项目第一次启动的时候就要执行一次定时任务的函数。

const CronJob = require('cron');
// 启动定时任务
new CronJob('0 0 1 * * *', async function () {
    // 检查域名是否过期
    try {
        // 获取剩余天数
        const days = await CheckSSLTime(DOMAIN);
        if (days < 2) {
            // 域名过期逻辑
        }
    } catch (error) {
        console.log(error);
    }
}).start();

域名是否过期的检查其实很简单,使用NodeJS自带的https库就可以实现。这里我们使用hostname来定义我们项目自身的域名,然后让函数输出域名距离到期还有多少天。这里我们使用dayjs这个日期处理库来计算时间日期,如果逻辑确实非常简单,我们同样可以不适用这个库,自己计算下时间。

const https = require('https');
const dayjs = require('dayjs');

const options = {
		hostname: hostname,
		port: 443,
		method: 'GET',
	};
	const req = https.request(options, (res) => {
		const certificate = res.socket.getPeerCertificate();
		if (certificate.valid_from && certificate.valid_to) {
			// const start = dayjs(certificate.valid_from);
			const end = dayjs(certificate.valid_to);
			const timespan = end.diff(dayjs(), 'day');
			console.log(timespan);
		} else {
			console.log(0);
		}
	});

	req.on('error', (e) => {
		console.log(e);
	});

	req.end();

整合上面2个内容,我们的自动监控任务就做好了。这里只是针对我们自身项目自己的域名做的例子。如果我们需要监控更多的域名,可以做一个任务队列,然后挨个去检查,也可以做一个并发,一次性请求并返回结果。这里就不再写具体的代码了。

// 计算2个Date对象之间的天数
function getTimeDay(date1,date2){
    return Math.floor((date2-date1)/(24*60*60*1000));
}

开发一个钉钉通知函数

我们的SSL证书到期了,第一时间我们应该让服务自动发通知给我们。这里可选的服务包含Email钉钉飞书BarkTelegram企业微信微信QQIGotPushPlus等等,这里基于演示我们使用钉钉机器人的通知能力。

钉钉机器人的通知其实很简单,我们只需要选定消息格式组成一个消息变量,然后加签再模拟Post请求,一条通知就会发送到我们的钉钉群了。

const crypto = require('crypto');
const axios = require('axios');

//替换成自己的钉钉KEY
const DD_KEY = 'SEC123456789';
const DD_Token = 'b6f123456789';

async function postToDD(data) {
    const timestamp = Date.now();
    const sha = crypto.createHmac('SHA256', DD_KEY);
    sha.update(timestamp + '\n' + DD_KEY, 'utf8');
    const sign = encodeURI(sha.digest('base64'));
    await axios.post(`https://oapi.dingtalk.com/robot/send?access_token=${DD_Token}&timestamp=${timestamp}&sign=${sign}`, data);
}

钉钉通知有并发限制,尽量不要频发发送。可以选择将消息内容组成一大段消息体。或者使用多个机器人,但是多个机器人会导致消息堆积,增加看不到消息的可能性。

SSL证书创建和更新

Let’s Encrypt 的证书真的非常良心。它提供的是一个安全可用的证书能力,而且是在全球范围内提供安全认证。同时还提供了各种语言的客户端实现(大部分都是社区提供),整体不仅好用而且免费。所以希望能用到这篇文章内容的同行也尽量不要收费,我们就提供一个良好的社区氛围好了。

SSL认证方式

在开始写代码之前,我们了解一下SSL证书的认证方式。

其中一种是非常常用的DNS校验形式。我们在申请的时候官方给我们一个cname值,让我们配置在域名对应的解析上。这样认证方就知道这个域名确实是我们所有,然后就认证通过了。

第二种是文件校验,这个普遍存在于各种校验场景下。我们需要再域名对应的目录创建一个文件,文件内容使用官方给定的内容。这样官方就知道这个域名是我们所有了。

这里由于修改DNS需要适配各个平台的API接口。我们先不考虑这个方式。我们使用文件校验的形式,这样在校验的时候只要我们拦截路由,然后返回对应的值,整个校验过程就可以做成一个自动化的程序。

创建一个SSL的服务

SSL的协议有很多库都实现了。我们这里使用greenlock三方库来实现。首先需要做的是实现一个SSL管理对象,配置内容的存储和校验目录。

const Greenlock = require('greenlock');
const path = require('path');
const GreenlockStoreFs = require('greenlock-store-fs');
const LeChallengeFs = require('le-challenge-fs');

// SSL根目录
const ROOT_PATH = path.join(process.cwd(), 'ssl');
// 创建存储对象
const leStore = GreenlockStoreFs.create({
   configDir: path.join(ROOT_PATH, 'letsencrypt'),
});
// 创建验证对象
const leHttpChallenge = LeChallengeFs.create({
   webrootPath: path.join(ROOT_PATH, 'lechallenge'),
});
// 是否同意协议
function leAgree(opts, agreeCb) {
   agreeCb(null, opts.tosUrl);
}

// 证书申请对象
const greenlock = Greenlock.create({
   version: 'draft-12',
   // 测试环境
   // server: 'https://acme-staging-v02.api.letsencrypt.org/directory',
   // 生产环境
   server: 'https://acme-v02.api.letsencrypt.org/directory',
   store: leStore,
   challenges: {
       'http-01': leHttpChallenge,
   },
   challengeType: 'http-01',
   agreeToTerms: leAgree,
   debug: false,
   renewBy: 10 * 24 * 60 * 60 * 1000,// 10倒计时开始续期
});

需要注意的是Greenlock的配置有生产和测试2套地址。这是由于免费的证书所有好用,但是如果频繁调用,对于官方来说这个就完全是浪费了。所有在测试期间还是使用测试使用的接口地址。线上再使用正式的接口。也是为了避免由于频繁调用导致的接口不可用。

创建和开始校验

我们在准备要创建一个SSL证书的时候,我们就可以调用greenlock.register函数来完成后面的操作。这里的流程如下:

  1. 将域名、邮件等信息组成一个对象,发送给官方。
  2. 官方根据情况返回一个校验内容。
  3. 开始校验文件,模拟请求循环调用,判断校验是否成功。
  4. 校验成功返回3个证书文件。
// 创建SSL证书申请请求
async function CreateSSL(domain, email = '') {
   const results = await greenlock.register({
       domains: [domain],
       email,
       agreeTos: true,
       rsaKeySize: 2048,
   });
   // 如果生成证书就保存一次证书
   const dir = path.join(PEM_PATH, domain);
   if (!fs.existsSync(dir)) {
       fs.mkdirSync(dir);
   }
   if (results.cert && results.chain) {
       fs.writeFileSync(path.join(dir, 'csr.pem'), results.cert + results.chain, 'utf-8');
   }
   if (results.privkey) {
       fs.writeFileSync(path.join(dir, 'key.pem'), results.privkey, 'utf-8');
   }
   return results;
}

agreeTos参数基本默认true就可以了。这个地方是为了填是否同意xxx协议的。

需要注意的是,这里我们将返回的3个证书直接合成了2个证书。这是因为我们后面要使用的是ngxin格式的,3个整数的模式不太适用。我们将中间证书直接合并在一起,后面配置使用的时候就可以直接使用了。

校验文件

如果上面的代码测试之后就会知道,其中一步是官方校验我们的域名配置文件。这个还需要我们自己来完成。

由于上面配置leHttpChallenge的时候,我们已经设置了校验文件的位置是ssl/lechallenge文件夹,所有三方库在使用的过程中就已经将文件存储在这里。我们要做的就是在需要的时候读文件并返回。

我们需要拦截固定的路由地址。Let’s Encrypt设置的校验路由是/.well-known/acme-challenge/:name这个地址,所有我们在配置的时候同样要将这个地址配置在路由里。如果nginx有涉及这个目录的处理,我们还要将这个目录注释掉,由我们自己处理逻辑。

const Koa = require('koa');
const app = new Koa();
// 监听证书文件的校验
router.use('/.well-known/acme-challenge/:name', function (ctx) {
   const name = ctx.params.name;
   ctx.body = GetSSLTxt(name);
});

GetSSLTxt函数就是直接读取文件内容。由于这个校验基本也就一次,所以我们的设计也非常的简单,直接读文件就可以了。

// 获取验证文件内容
function GetSSLTxt(name) {
   const str = fs.readFileSync(path.join(ROOT_PATH, 'lechallenge', name), 'utf-8');
   return str;
}

剩余部分

我们到这里基本就把需要的内容补充完整了。剩余部分需要补充的就是在自动监控中增加SSL文件的自动申请和设置。比如ngxin配置的SSL文件地址,上面的那个目录就需要我们自己修改,或者是nginx中修改路径到上面的那个地址。

其次就是ngxin的自动重载配置。我们在申请结束并配置好项目证书之后,我们还需要重载一次nginx的配置文件。这里我们使用子线程的方式执行nginx命令。

const { execSync } = require('child_process');
// 执行重启命令
function RestartNginx() {
   execSync('nginx -s reload', {
       encoding: 'utf-8',
       // cwd: "~/",
       shell: '/bin/bash',
   });
}

greenlock官方的文档中还有一个配置是可以自动需求SSL证书的时间,这个我还没有测试。如果有测试过的同学也可以给我留言,如果可用的话就不用每次都要重新申请了。

结束

至此,我们的项目就完成了。我们写了一个监控的逻辑,可以自动监控SSL证书的到期时间,同时也可以扩展一些其他的监控项。我们也根据协议写了一个自动申请SSL证书的逻辑,可以自己申请证书,自己给项目替换。整体逻辑简单好用,项目代码开源使用。让我们一起来构建一个开源的世界吧。

项目源代码

Let’s Encrypt官网