你是不是也遇到过这些问题:太穷买不起年付的通配符证书,手上有好几台白嫖的服务器,有的还没有 80 和 443 端口,证书申请起来麻烦,手动申请和部署的话每几个月还要维护一次(免费证书大多三个月有效期),想通过 acme.sh 脚本一键部署通配符证书又不想把 DNS 服务商的 API Key 放在每台服务器上或者在每台机器上都配置脚本(何况 API 调用好像还有次数限制)。

本文将介绍通过搭建证书申请服务端与客户端的模式,实现免费通配符证书证书的自动申请分发与续期。

前言

老规矩,不想看我废话的可以跳过此节 |ू・ω・` )

本项目高度依赖于 acme.sh 脚本。

acme.sh 是一个通过 ACME 协议从 Let’s Encrypt 和 ZeroSSL 等 CA 机构申请免费的证书的 Linux 脚本

其实之前也搜索了一番,确实没有发现什么比较适合我的解决方案,甚至有想过能不能把 Node.js 之类版本的 ACME 脚本部署到 Vercel 这种 Serverless 平台上的,发现倒是有两个现成的(ACME Serverless Fitness ShopACME Cert Updater)。

但是用的是 AWS 的 Serverless 和 S3 储存或者 Google Cloud Run,想了想还是放弃了,一个是不熟悉不想折腾,另一个是不清楚免费额度(看了下好像是要付费的),最主要的还是因为更新,我这么懒才不要因为更新随时又跑去点点点,自然也放弃了自己写一个脚本放 Vercel 上面运行或者通过 Docker 部署的想法(当然要把本文提到的工具和 acme.sh 一起打包到一个 Docker 里面用也不是不可以,但是在有些服务器上安装 Docker 本身也是一件麻烦事),毕竟你看 acme.sh 都是三天两头更新一次的,虽然我也不知道更新了啥(〃’▽’〃)。

然后想到了那种,在一台服务器上部署一个服务端进行通配符证书的申请续期与分发,然后需要用到证书的服务器上就只用定时运行一个客户端从服务端获取最新的证书并放在指定目录就行了,然后经过一番搜索,发现这种服务端 + 客户端的模式已经有轮子了(AcmeCat),刚想拿来用,看了一下更新时间已经是前年了,而且因为作者自己造了全部轮子,所以不像 acme.sh 那样支持好多功能和 DNS 服务商,可惜不更新了quq,所以到这里只好自己动手了。

说到底原理也挺简单的,就是一个 Web 服务端来提供由 acme.sh 脚本申请到的通配符证书的下载,NGINX + PHP 估计就有一堆轮子吧,但是 NGINX + PHP 一个是安装起来麻烦,想随便找个机器放这个服务还得先配一套环境,如果本来机器上还有其它的网站和 Web 服务倒挺合适,但是如果只是为了提供一个证书下载服务感觉就有点折腾和占用资源了,然后想到如果用 Python 写一个的话好像挺合适,搜一下就有类似的用 Python 提供文件服务的代码,并且通过 Basic Auth 进行用户认证,想着自己拿来改一下认证方式(Basic Auth 我还是觉得太不靠谱了哈哈)不就能用了,所以差点就用 Python 写了。可是,想了一下,自己不会 Python 啊,要不换 Node.js 吧,但是 Node.js 环境装起来也麻烦,跨平台打包的话估计是把一大坨 node_modules 打包进去,何况 Node.js 我也算是不怎么会的(怎么我啥也不会啊(摔))。整理了一下自己的需求:

  1. 无需安装运行环境或运行环境安装起来很方便
  2. 能够跨平台运行(虽然主要是在 Linux(Arm/x86) 上使用)
  3. 轻量化,占用资源和空间小,无需额外安装 NGINX 或 Caddy 这类 Web 服务器(虽然 Caddy 装起来也挺方便)
  4. 能够安全加密传输(不能在传输中使用固定的 token 或密码)

然后第一时间想到了要不就用 Bash 写一个吧,反正 acme.sh 也是 Bash,于是收集资料,找到了这篇关于 bash-server教程,稍微看了一下,嗯,好难啊 (;´д`)ゞ,如果要实现我想要的功能的话,完全做不来呜呜呜,要不学学用 Python 写得了,然后想了一下,既然都不会,还得要跨平台的,那我不干脆用 Go 写好了,而且 Python 的话也要装各种包环境(如果用到的话,至于 Python 能不能跨平台编译成可执行程序我也不清楚),嗯,然后去稍微搜了点资料看了一下好像也还行(至少感觉入门程度的话 Go 比 C 和 Rust 之类的简单一点),应该能实现,于是就有了这篇文章。

下面开始正式的教程吧~

安装配置 acme.sh

这部分主要是按照 acme.sh 的官方文档来的,具体操作需要改变的地方我会说明。

安装系统环境

参考文档里面的大概需要这些:

# Debian/Ubuntu
apt-get install openssl cron socat curl -y
apt-get upgrade ca-certificates
systemctl enable cron
systemctl start cron

# CentOS(顺便悼念一下凉凉的 CentOS 8)
yum -q -y install openssl crontabs socat curl
yum update ca-certificates
systemctl enable crond
systemctl start crond

创建工作目录

这里使用的是 /home/acme,如果这里你使用的是其他的目录的话,后面执行的命令里面也要记得一起更改:

mkdir -p /home/acme

安装 acme.sh 脚本

其中 /home/acme/acme.log 是脚本的日志文件:

curl https://get.acme.sh | sh
source ~/.bashrc
source ~/.bash_profile
acme.sh  --upgrade  --auto-upgrade --log  "/home/acme/acme.log"

定义临时变量

DOMAIN 的值修改成你要申请证书的域名,这里要申请的是通配符证书所以不需要填写子域名(除非你要申请子域名的通配符证书,但是我不知道 acme.sh 支不支持),关于后面几个变量,这里使用的 DNS 服务商是 CloudFlare,如果你使用的是其它服务商的话,可以根据文档修改,acme.sh 支持的服务商挺多的(100 多种,不愧是优秀的开源项目),在这里可以查看:DNS API plugins.

# example.com 修改成你的域名
export DOMAIN="example.com"

# 下面的内容根据所使用的 DNS 服务商更改
export CF_Key="b8e8fff91ff445a1a238fc080797910b"
export CF_Email="[email protected]"

设置 CA

具体支持看这里:Supported CA,默认是 ZeroSSL,这里换成 Let’s Encrypt 了(ZeroSSL 需要注册而且不支持签发 ECC 证书(大佬说已经支持了好诶),Let’s Encrypt 因为更换了根证书所以存在一些兼容性问题,各有各的好处吧)。

# 如果要使用 Let's Encrypt 执行以下命令
acme.sh --set-default-ca --server letsencrypt

# 如果要使用默认的 ZeroSSL 的话需要提前使用邮箱注册账户(使用 Let's Encrypt 的话无需执行)
acme.sh  --register-account  -m [email protected] --server zerossl

看见有人说 ZeroSSL 只能三个免费证书,需要说明一下,ZeroSSL 只是网页端申请免费证书限制是三个,而通过 ACME 通道申请的证书数量是没有限制的哈,参见:https://zerossl.com/documentation/acme/

申请与移动证书

签发证书

在之前的工作目录下面创建好对应域名的子目录,这个以后会用到,如果你的域名是 Emoji 或者中文之类(以及特殊字符)的话建议修改 ${DOMAIN} 为你要存放证书的目录(一般来说直接执行就行)。然后 dns_cf 改为你的 DNS 服务商,其它服务商参考上文提到的文档,稍等片刻就能申请成功。

mkdir -p /home/acme/${DOMAIN}
acme.sh --issue --dns dns_cf -d ${DOMAIN} -d *.${DOMAIN}

移动证书

官方文档说的是“安装证书”,但是我们是要部署一个证书分发服务,所以这里我把它叫做是移动证书,即把证书安装到之前所说的工作目录下,同时 reloadcmd 的作用是把当前时间戳给写入了 time.log 这个文件,这个很重要,之后在客户端上面同步证书的时候会用到:

acme.sh --install-cert -d ${DOMAIN} \
--cert-file      /home/acme/${DOMAIN}/cert.pem  \
--key-file       /home/acme/${DOMAIN}/key.pem  \
--fullchain-file /home/acme/${DOMAIN}/fullchain.pem \
--reloadcmd     "echo \$(date -d \"\$current\" +%s) > /home/acme/${DOMAIN}/time.log"

签发 ECC 证书(可选)

和上面两步基本一样,只是在签发证书的时候加了 --keylength ec-256 参数,具体看文档。注意不同的 CA 可能不支持签发 ECC 证书(目前 Let’s Encrypt 是支持的)。另外证书存放的目录加了 _ecc 后缀:

# 签发 ECC 证书
mkdir -p /home/acme/${DOMAIN}_ecc
acme.sh --issue --dns dns_cf -d ${DOMAIN} -d *.${DOMAIN} --keylength ec-256

# 移动 ECC 证书
acme.sh --install-cert --ecc -d ${DOMAIN} \
--cert-file      /home/acme/${DOMAIN}_ecc/cert.pem  \
--key-file       /home/acme/${DOMAIN}_ecc/key.pem  \
--fullchain-file /home/acme/${DOMAIN}_ecc/fullchain.pem \
--reloadcmd     "echo \$(date -d \"\$current\" +%s) > /home/acme/${DOMAIN}_ecc/time.log"

设置续期通知(可选)

acme.sh 脚本可以在证书续期成功或者失败的时候发送通知,这里就简单提一下(拿 Telegram 举例),具体看文档:Set notifications.

# Token returned by @BotFather during bot creation above.
export TELEGRAM_BOT_APITOKEN="6254903718:O75Sp8xv_-nXL5_YoPmFQqJ"
# Chat ID fetched from @getidsbot
export TELEGRAM_BOT_CHATID="6254903718"
acme.sh --set-notify --notify-hook telegram
# Test
acme.sh --cron

部署证书分发服务端

到这里证书已经申请到我们的服务器上的工作目录里面了(例如前文提到的 /home/acme 目录),接下来就要用到我们的工具 acmeDeliver 惹。

下载 acmeDeliver

https://github.com/julydate/acmeDeliver/releases 页面查看最新的 acmeDeliver 服务端,这里以 64 位 x86 的 Linux 为例,下载到了 /home/acme 目录:

curl -sLo /home/acme/acmeDeliver https://github.com/julydate/acmeDeliver/releases/download/v1.1/acmeDeliver_1.1_Linux_x86_64
chmod +x /home/acme/acmeDeliver

运行 acmeDeliver

首先尝试运行一下,其中 -p 指定服务端口,-d 指定了前文提到的工作目录,-k 指定的密码你得记住,因为待会儿在客户端会用到,如果没有报错就说明一切正常啦(废话)。

/home/acme/acmeDeliver -p 9929 -d "/home/acme/" -k 9bff385c71d051c3e81af2bb6950b3e4

然后就是放在后台运行:

nohup /home/acme/acmeDeliver -p 9929 -d "/home/acme/" -k 9bff385c71d051c3e81af2bb6950b3e4 > /home/acme/acmeDeliver.log 2>&1 &

如果你的服务器有防火墙,记得在服务商面板上和 iptables 里面放行对应的服务端口,具体去搜 CentOS/Debian/Ubuntu 如何放行端口,我就不废话啦。

更多关于程序的使用说明请去项目主页查看:https://github.com/julydate/acmeDeliver/tree/master#server

进程守护(可选)

服务端有时候会莫名其妙挂掉,所以还是建议弄一下进程守护。进程守护的方式有很多,这里只说两种。

第一种是用 crontab 脚本,保存以下脚本为 keepAcmeDeliver.sh,并放到 /home/acme/ 文件夹内,记得更改对应参数。

#!/bin/bash
check_pid(){
    PID=`ps -ef |grep -v grep | grep acmeDeliver |awk '{print $2}'`
}
crontab_monitor_acmedeliver(){
    check_pid
    if [[ -z ${PID} ]]; then
        echo -e "${Error} [$(date "+%Y-%m-%d %H:%M:%S %u %Z")] 检测到 acmeDeliver 服务端 未运行 , 开始启动..." | tee -a /tmp/acmeDeliverKeeper.log
        nohup /root/acmeDeliver -p 9929 -d "/home/acme/" -k 9bff385c71d051c3e81af2bb6950b3e4 > /tmp/acmeDeliver.log 2>&1 &
        sleep 1s
        check_pid
        if [[ -z ${PID} ]]; then
            echo -e "${Error} [$(date "+%Y-%m-%d %H:%M:%S %u %Z")] acmeDeliver 服务端 启动失败..." | tee -a /tmp/acmeDeliverKeeper.log && exit 1
        else
            echo -e "${Info} [$(date "+%Y-%m-%d %H:%M:%S %u %Z")] acmeDeliver 服务端 启动成功..." | tee -a /tmp/acmeDeliverKeeper.log && exit 1
        fi
    else
        echo -e "${Info} [$(date "+%Y-%m-%d %H:%M:%S %u %Z")] acmeDeliver 服务端 进程运行正常..." && exit 0
    fi
}
crontab_monitor_acmedeliver

然后执行 crontab -e 并在最后加上一行:

* * * * * /bin/bash /home/acme/keepAcmeDeliver.sh monitor

第二种是使用 systemd,执行以下命令,记得先更改对应参数。

cat > /etc/systemd/system/acmeDeliver.service << EOF
[Unit]
Description=acmeDeliver
After=network-online.target
Wants=network-online.target systemd-networkd-wait-online.service
[Service]
Type=simple
User=root
Restart=on-failure
RestartSec=5s
DynamicUser=true
ExecStart=/home/acme/acmeDeliver -p 9929 -d "/home/acme/" -k 9bff385c71d051c3e81af2bb6950b3e4 > /home/acme/acmeDeliver.log 2>&1 &
[Install]
WantedBy=multi-user.target
EOF

然后用 root 权限执行以下命令:

# 设置开机启动
systemctl enable --now acmeDeliver

# 启动/重新启动/停止
systemctl start/restart/stop acmeDeliver

到这里,服务端的设置已经完毕。

使用证书分发客户端

接下来的操作就是在每个需要用到证书的服务器上安装客户端脚本了,这里要感谢 @Raoby(https://github.com/Raobee) 帮我写的客户端脚本,不然我自己又得瞎折腾好些天~

安装系统环境

和服务端差不多:

# Debian/Ubuntu
apt-get install openssl cron curl -y
apt-get update ca-certificates
systemctl enable cron
systemctl start cron

# CentOS
yum -q -y install openssl crontabs curl
yum update ca-certificates
systemctl enable crond
systemctl start crond

下载客户端

从项目的 client 分支下载客户端脚本到 root 目录,当然你也可以下载到其它目录:

curl -sLo /root/acmeDeliverClient.sh https://raw.githubusercontent.com/julydate/acmeDeliver/client/client.sh
chmod +x /root/acmeDeliverClient.sh

使用客户端部署证书

更改客户端的工作目录,其实也就是你的证书需要存放的目录,默认是放在 tmp 目录的,将 \/home\/acme/ 改为你要存放的目录即可:

 sed -i 's|\/tmp\/acme|\/home\/acme/|g' /root/acmeDeliverClient.sh

测试运行客户端,其中 -p 指定的密码就是前面你部署服务端的时候设置的密码,233.233.233.233:9929 改为你服务器的 IP 和前面设置的服务端口,当然也可以绑定域名,然后example.com 改为你的域名,如果你之前是按我的教程申请的 ECC 证书,那么这里应该是有个后缀,比如 example.com_ecc.

/root/acmeDeliverClient.sh  -d "example.com" -p "9bff385c71d051c3e81af2bb6950b3e4" -s "http://233.233.233.233:9929" -c "0"

如果没有报错那就大功告成啦~(废话+1),这个时候在服务器的 /home/acme 目录里面应该就能看见你的通配符证书了。

关于证书的使用参考 acme.sh 的文档,Apache 会用到 key.pem, cert.pem, fullchain.pem 这三个文件,NGINX 会用到 key.pem, fullchain.pem 这两个文件

设置客户端定时同步

然后执行 crontab -e 在最后一行加上以下内容,其中 0 0 * * * 表示每天 0 点请求服务端看是否有文件更新,如果你之前服务端有在目录里生成一个正常的 time.log 文件,那么这里就不会有什么问题,客户端就只会在服务端证书文件更新的时候才从服务端同步每个文件:

0 0 * * * /root/acmeDeliverClient.sh  -d "example.com" -p "9bff385c71d051c3e81af2bb6950b3e4" -s "http://233.233.233.233:9929" -c "0" > /dev/null 2>&1 &

同时客户端脚本也支持证书部署之类的高级功能,更多关于客户端的使用说明请去项目主页查看:https://github.com/julydate/acmeDeliver/tree/master#client

总结

本文提到的这套程序基本实现了通配符证书的分发,可以在一台服务器上部署,多台服务器上自动同步更新通配符证书,而对于前言提到的需求也基本得以实现:

  1. 无需安装运行环境
  2. 能够跨平台运行(只要能运行 acme.sh 脚本的地方)
  3. 轻量化,占用资源和空间小
  4. 能够安全加密传输,防请求重放
  5. 安全读取目录,限制只能读取工作目录内的文件

关于加密传输,其实就是支持 TLS 而已,具体看项目主页的 Usage,本来之前有想过自己做文件加密传输,但是一想到本来服务器上就有证书,直接通过 https 下载证书文件不就行了。

本文比较冗长,其实也就是前三部分关于服务端的部署比较麻烦点,但是一次部署好了之后长时间都能用,也不用麻烦再去其它服务器上部署,之后有新的服务器要使用证书的时候只需要执行一次最后一步客户端的安装即可。

同时也欢迎大家提 Pull request 贡献代码,毕竟我哒编码能力有限(很菜),还是有些功能没实现的,比如能在 Windows 上面运行的客户端,能针对不同域名设置不同访问密码的功能。

题外话,更安全一点的,能够实现客户端初始化部署的时候使用一个统一密码(不存储在服务器上)和服务端交换一个与客户端 IP 绑定的密钥文件,这样就算客户端被偷窥,也只能拿到该客户端上当时的证书文件,且服务端可以让该客户端上的密钥过期,也不会出现密码泄露了需要去每台服务器上更改新的客户端密码的情况(因为密码不存储在客户端服务器上且只会在你初始化部署的时候输入一次),但是我觉得这样难免有点小题大做了 OvO

最后在这里要感谢为项目做出贡献的 @Raoby@Moe ,并且感谢 acme.sh 这样的开源项目以及 Let’s Encrypt 这样的组织提供的免费证书,让我们能方便免费的用上 SSL 证书。