前言

https是一种互联网趋势,说到https就离不开ssl证书的申请,那么国外有个组织为了解决ssl证书申请的问题开放了互联网申请api接口,而且是全免费的,这就是letsencrypt,那么有了这个api接口接下来做的就是要去看方法调用对吧。然而你找到的只能是一份超级长的规范定义,当然这份规范定义基本上大家都是太长不看的。所以基本上大家都会去找现成的库。那么实现了amce协议的库很多ACME协议客户端,各种各样的,比如最常见的Certbot,网上的教程大多也是针对Certbot的,不过有个缺点就是,如果走的是dns认证,尤其是通配符证书申请,基本上国内比如阿里云的dns还需要自己手动去设置,不过网上已经有相关的插件可以支持阿里云dns了。不过我个人并不喜欢Certbot,尤其是需要传递一堆的参数,当配置的域名一多的时候就很难管理,那么出于以上的考虑就开始写了一个符合自己业务需求的小教本

基于node-acme-client的脚本实现

node-acme-client

本来打算自己去实现一个符合acme规范的客户端,不过时间不允许,而且不太想重复造轮子,所以翻了一下nodejs的库,发现还是有一个完全实现了acme规范的。于是打算基于上述去开发。虽然说是基于轮子去造车,但是有一些概念还是需要了解的。下文会简单说一下所遇到的概念。

账户

letsencrypt是有账户概念的,而且创建账户的接口速率限制比较严格10个ip/3小时内。所以基本上建议要搞证书都用一个公用的账户,那么他的账户申请概念是什么样的呢?

本地生成私钥(pem编码)->调用创建账户接口(/acme/new-acct)->返回创建用户信息->获取当前用户的kid/accountUrl(这步本地完成即可,因为返回的用户信息内已经包含了)

那么通过上述的流程即可拿到用户信息,上面有两个需要保存在本地的 一个是私钥,一个是用户信息的kid也就是用户的accountUrl,通常为https://acme-v02.api.letsencrypt.org/acme/acct/xxxx

console.log('创建账号');
account = await client.createAccount({
    termsOfServiceAgreed: true,
    contact: ['mailto:xxx']
});

console.log('有账户的情况下登录账户')
client = new acme.Client({ 
   directoryUrl: acme.directory.letsencrypt.production, // 正式环境
   accountKey: accountPem, // 保存的私钥
   accountUrl:accountUrl} // 保存的accountUrl
);

申请证书

申请证书其实是一个订单的形式,当你请求了一个新的订单,这个订单会返回授权,再根据授权去请求Challenge也就是挑战任务。大致形式为

申请订单(包含需要申请的域名)->返回订单授权列表

console.log('申请订单')
const order = await client.createOrder({
        wildcard: true, //如果包含了通配符申请一定要加上wildcard:true
        identifiers:[{ type: 'dns', value: '*.xxx.com' },{ type: 'dns', value: 'xxx.com' }]
    });

因为申请了通配符只需要申请两个域名,一个是*一个是根域名。申请通配符只能用dns认证的方式。 那么对应的会返回两个授权列表

授权列表

当你申请了域名后需要去申请授权,申请授权后会返回当前域名需要做的挑战任务。

订单授权列表->请求挑战列表

// 从订单获取授权
const auth = await client.getAuthorizations(order);

这个函数内部做了一些处理,所以我们只需要传递整个order

// getAuthorizations函数定义
getAuthorizations(order) {
        return Promise.map((order.authorizations || []), async (url) => {
            const resp = await this.api.getAuthorization(url);

            /* Add URL to response */
            resp.data.url = url;
            return resp.data;
        });
    }

实际上你申请了几个域名就会返回几个authorizations 比如上面我申请了两个那么就会返回两条认证信息类似于 order.authorizations=[{xxx},{xxx}]

拿到授权列表后会发现其数组元素是个object其中有个字段为challenges,challenges通常是多个挑战任务列表,也就是[{挑战1},{挑战2},{挑战3}],只需要完成其中一个挑战任务即可,但是需要完成整个authorizations,也就是我之前传递了两个域名那么需要全部完成也就是需要完成两个挑战任务。

获取挑战任务token

授权列表的挑战任务->返回挑战任务的token值

因为我这里只用dns做验证,也就是说我只需要完成dns任务就好

for (let i of auth) {
    // 获取授权challenge
    // 找到DNS授权类型的challenge
    const dnsChallenge = i.challenges.find((n) => {
        return n.type == 'dns-01'
    })
    const challenge = await client.getChallengeKeyAuthorization(dnsChallenge);
    // challenge为挑战任务的token值
    console.log('challenge', challenge);
    challengeList.push(challenge)
    
    // 在这停顿!打个断点 别做下一步操作
}

拿到token值后下一步就是去解析域名了,dns挑战需要解析一条类型为TXT的记录,前缀为acme-challenge值就为token值

范例如下

TXT Record Name: _acme-challenge.xxx.com
Value: D-52Wm4V7xoUpGax-F8FrPO45cQRcbRj-XoblaY4uYM

阿里云添加完解析后别急着走下一步,等个3分钟后再走下一步

通知服务器验证

当你完成了第一个授权列表的挑战后,通知acme服务器验证。

const dnsChallenge = authItem.challenges.find((n) => {
    return n.type == 'dns-01';
});
userAuth = await client.verifyChallenge(authItem, dnsChallenge);

完成任务挑战&&等待状态更新

当验证完毕后就可以通知acme服务器已经完成验证,然后等待acme服务器更新状态

authComplete = await client.completeChallenge(dnsChallenge);
waitStatus= await client.waitForValidStatus(dnsChallenge);

完成订单

当你所有的授权列表的挑战任务都完成后就可以完成订单了,不过在此之前我得给你整理一个大概的概念

// 授权列表
authList=[{授权1},{授权2}]
// 根据授权列表获取任务列表
const taskList = getMission(authList)
taskList=[
    { // 授权1的任务 完成其中一个即可,不过请求订单传递的验证类型是dns 所以你只能完成dns验证
        Challenge:[{type:'dns-01',},{type:'http-01'}]
    },
    { // 授权2的任务
        Challenge:[{type:'dns-01',},{type:'http-01'}]
    },
]
// 获取授权1任务token值
const task1Token = getToken(taskList[0])
// 阿里云添加TXT解析
// 等待3分钟继续
// 通知acme服务器验证完成
// 等待acme服务器验证&更改挑战任务的状态

// 获取授权2任务token值
const task2Token = getToken(taskList[1])
// 阿里云添加TXT解析
// 等待3分钟继续
// 等待acme服务器验证&更改挑战任务的状态

做完上面的事情后 就可以开始完成订单了,完成订单需要Csr,也就是你的域名私钥。其中commonName为你的组织名,建议用根域名,altNames为你申请的域名列表

let certificateKey, certificateCsr;
try {
    [certificateKey, certificateCsr] = await acme.forge.createCsr({
        commonName: 'xxx.com',//  建议用根域名
        altNames: ['xxx.com','*.xxx.com']
    });
} catch (e) {
    console.error('获取certificateKey&certificateCsr失败', e);
    return false;
}
// 完成订单
let fOrder;
try {
    fOrder = await client.finalizeOrder(order, certificateCsr);
} catch (e) {
    console.error('获取fOrder失败', e);
    // return false;
}
console.log('fOrder', fOrder);

当完成订单后就可以申请证书公钥了

获取证书公钥

// 获取公钥证书
let cert;
try {
    cert = await client.getCertificate(order);
} catch (e) {
    console.error('获取cert失败', e);
    return false;
}
console.log('cert', cert);

那么最终你的ssl证书形式为私钥:certificateKey,公钥:cert

拿到这些后就可以开始愉快的配置nginx/apache/...玩耍了

阿里云的dns解析

阿里云提供了DNS解析的接口,那么在解析记录的时候完全可以用程序来完成,这里就不再讲述了,资料都比较完善。思路大概为
获取域名列表->找到定制的域名->判断当前是否有_acme-challenge记录->添加/更新 _acme-challenge记录

    async function getDomainRecordList() {
        var params = {
            "DomainName": conf.ali.domain,
            "PageSize": 500
        };
        var requestOption = {
            method: 'GET'
        };
        return aliClient.request('DescribeDomainRecords', params, requestOption)

    }

    async function setAliDns(value) {
        let domainRecordListRes = await getDomainRecordList();
        domainRecordListRes = domainRecordListRes;
        const list = domainRecordListRes.DomainRecords.Record;
        // 找到是否有记录为_acme-challenge
        const find = list.find((n) => {
            return n.RR == '_acme-challenge';
        });

        if (find) {
            // 修改解析记录
            await changeAcmeDomainRecore(find.RecordId, value)
        } else {
            // 添加解析记录
            await addAcmeDomainRecord(value)
        }
    }

    function timeSleep(time) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(true)
            }, time)
        })
    }


    async function addAcmeDomainRecord(value) {
        var params = {
            "DomainName": conf.ali.domain,
            "RR": "_acme-challenge",
            "Type": "TXT",
            "Value": value
        };
        var requestOption = {
            method: 'POST'
        };
        return aliClient.request('AddDomainRecord', params, requestOption)
    }

    async function changeAcmeDomainRecore(recordId, value) {
        var params = {
            "DomainName": conf.ali.domain,
            "RecordId": recordId,
            "RR": "_acme-challenge",
            "Type": "TXT",
            "Value": value
        };
        var requestOption = {
            method: 'POST'
        };
        return aliClient.request('UpdateDomainRecord', params, requestOption)
    }