CentOS7 Certbot 自动更新 Let's Encrypt SSL 证书(Nginx,https)

Scroll Down

目前 https 是大势所趋,上周末成功把博客迁移到Halo后,还未来得及配置 https,之前都是从阿里云申请的赛门铁克免费版 SSL 证书,很方便,申请是快捷,缺点是不能自动续费。了解到CertbotLet's Encrypt官方推荐的证书生成客户端工具。

1.安装 Certbot

直接上命令,还可以安装 certbot-nginx 插件,笔者 Nginx 证书时自己配置,暂时未安装(如果需要可参考:[^1])。

# 先安装 EPEL 仓库(因为 certbot 在这个源里,目前还没在默认的源里)
yum install epel-release
yum update
yum clean all
yum makecache

# 安装 certbot
yum install certbot -y
## 如果提示 urllib3 安装失败,可以先卸载掉 pip 安装的 urllib3
pip uninstall urllib3
## 然后通过 yum 安装 python-urllib3
yum install python-urllib3

## 更新 pip 版本方法
pip install --upgrade pip

# 如果提示 pyOpenSSL 安装失败,可先卸载掉 certbot 和 pyOpenSSL
yum remove certbot && pip uninstall pyOpenSSL
yum install -y python-devel
yum install -y openssl-devel

# 然后重新安装 certbot
yum install certbot 

# 如果还是安装失败(提示:ImportError: ‘pyOpenSSL’ module missing required functionality. Try upgrading to v0.14 or newer.)
## 目前一个靠谱的解决办法
pip install --upgrade --force-reinstall 'requests==2.6.0'

# 测试是否安装成功
certbot --help
# 查看 certbot 版本,ACME v2 在 certbot 0.20.0+ 才支持
certbot --version

貌似还可以通过 pip 安装 certbot-auto,用法和 certbot 一致,可参考:[^2]

2.申请证书前必要的配置

获取域名证书过程中, Let's Encrypt 会对域名发起访问,以确认申请者对域名的所有权。故需要配置 nginx,以便能够对 Let's Encrypt 的访问返回正确的响应。

假设我们要申请:a.xxx.com,b.xxx.com这两个域名的证书。在 nginx 中配置:

server {
	listen       80;
	server_name  "a.xxx.com" "b.xxx.com";

	location ^~  /.well-known/acme-challenge/ {
		default_type "text/plain";
		root  /home/letsencrypt/;
	}
}

然后,手动创建临时目录

mkdir /home/letsencrypt

重启 Nginx

# 重启前先检测下配置文件是否正确
nginx -t
nginx -s reload

同时还要把上述==域名解析==到服务器。

3.申请证书

Nginx 配置好之后,就可以正式开始申请域名证书了。首次申请证书成功后有90天有效期。

3.1 challenge 介绍

Let's Encrypt需要验证网站的所有权才能颁发证书, 官方称之为challenge(挑战).

有三种方式可以实现验证: (官方文档在此)

  • 在网站上的指定位置发布指定文件(HTTP-01)
  • 在网站上提供指定的临时证书(TLS-SNI-01)
  • 在域名系统中发布指定的DNS记录(DNS-01)

3.2 普通子域名申请

3.2.1 HTTP-01 方式 challenge

执行命令:

certbot certonly --email xxx@126.com --webroot -w /home/letsencrypt -d a.xxx.com -d b.xxx.com
  • certonly 是 certbot 众多插件之一,代表安装证书,还可以选择其他插件。
  • --email 可指定应急邮箱,证书到期前会有邮件提示。
  • -d 指定域名,可以指定多个

上述方法还需要将子域名指定到当前服务器,并在服务器上配置web访问,以支持 HTTP-01 challenge.

我使用的是 Nginx 的形式,添加如下配置:

## SSL cert auto generate, verify need
server {
	listen 80;
	server_name "a.xxx.com";
	
	location ^~  /.well-known/acme-challenge/ {
		default_type "text/plain";
		root  /home/letsencrypt/;
	}
}

3.2.2 DNS challenge 方式(推荐

执行命令:

certbot certonly --email xxx@126.com --manual --preferred-challenges dns -d a.xxx.com
  • --manual 表示手动交互模式,Certbot 有很多插件,不同的插件都可以申请证书,用户可以根据需要自行选择
  • --preferred-challenges 使用 DNS 方式校验域名所有权,域名验证 dns 解析需要增加 TXT 类型的解析配置,子域名为_acme-challenge,记录值为交互模式(命令行)所给出的值。

申请时需要手动在域名解析中添加一条 TXT 记录,执行命令时会有提示。
例如:

_acme-challenge.a.xxx.com -- TXT -- xxxxxxxxxxxxxxxxxxxxxxx-4ukpnxfmy-io

3.3 泛域名(通配符)申请

还支持申请通配符域名证书

certbot certonly --email xxx@126.com -d *.xxx.com --manual --preferred-challenges dns --server https://acme-v02.api.letsencrypt.org/directory  
  • --manual 表示手动交互模式,Certbot 有很多插件,不同的插件都可以申请证书,用户可以根据需要自行选择
  • --preferred-challenges 使用 DNS 方式校验域名所有权,域名验证 dns 解析需要增加 TXT 类型的解析配置,子域名为_acme-challenge,记录值为交互模式(命令行)所给出的值。
  • --server Let's Encrypt ACME v2 版本使用的服务器不同于 v1 版本,需要显式指定。

3.4 申请过程后续

执行过程中会有几次命令确认,根据提示 确认/不同意 操作即可,申请成功后会有对应提示。

创建成功后 /etc/letsencrypt/live/xxx.com/ 下会生成 4 个文件,请勿更改 ssl 文件位置,这样可以减少自动续期时的操作

  • cert.pem - Apache服务器端证书
  • chain.pem - Apache根证书和中继证书
  • fullchain.pem - Nginx所需要ssl_certificate文件
  • privkey.pem - 安全证书KEY文件

Nginx环境,就只需要用到fullchain.pemprivkey.pem两个证书文件

4.证书的更新

由于默认申请的证书只有90天,所以需要配置自动更新。

4.1 普通子域名证书更新

普通子域名自动更新测试,使用--dry-run选项表示测试,非真正执行更新

certbot renew --dry-run

测试成功会看到类似如下提示:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
** DRY RUN: simulating 'certbot renew' close to cert expiry
**          (The test certificates below have not been saved.)

Congratulations, all renewals succeeded. The following certs have been renewed:
  /etc/letsencrypt/live/a.xxx.com/fullchain.pem (success)
** DRY RUN: simulating 'certbot renew' close to cert expiry
**          (The test certificates above have not been saved.)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

到期后手动更新证书

certbot renew -v

还可以配置自动更新

certbot renew --quiet --no-self-upgrade

如果是 DNS challenges 方式,更新时不能使用采用上述方法了,需要继续往下看。

需要使用命令(具体原因可以参考 4.2 节):

# 测试更新(测试成功会有提示信息,并不会真的生效,只是测试)
certbot renew --dry-run --cert-name a.xxx.com --manual-auth-hook /usr/bin/certbot-alidns

# 手动更新--可配置到 crontab 中,定时执行
certbot renew --cert-name a.xxx.com --manual-auth-hook /usr/bin/certbot-alidns --deploy-hook "python3 /opt/bin/qiniu_cdn_ssl_cert_auto_renew.py halo.cdn.sherlocky.com"

# 手动更新(并通过 deploy-hook 指定自动上传新证书到七牛)
certbot renew --cert-name a.xxx.com --manual-auth-hook /usr/bin/certbot-alidns --deploy-hook "python3 qiniu_cdn_ssl_cert_auto_renew.py xxx.yyy.com"

关于自动上传证书到七牛云可参考博文:使用七牛云 API 上传 letsencrypt SSL 证书并绑定到 CDN

4.2 泛域名证书更新

如果使用普通子域名的方式,会报一个一个错误:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Cert not due for renewal, but simulating renewal for dry run
Could not choose appropriate plugin: The manual plugin is not working; there may be problems with your existing configuration.
The error was: PluginError('An authentication script must be provided with --manual-auth-hook when using the manual plugin non-interactively.',)
Attempting to renew cert (xxx.com) from /etc/letsencrypt/renewal/xxx.com.conf produced an unexpected error: The manual plugin is not working; there may be problems with your existing configuration.
The error was: PluginError('An authentication script must be provided with --manual-auth-hook when using the manual plugin non-interactively.',). Skipping.
All renewal attempts failed. The following certs could not be renewed:
  /etc/letsencrypt/live/xxx.com/fullchain.pem (failure)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

4.2.1 问题分析

这是因为Let’s Encrypt颁发的通配符证书每次续期需要验证一下DNS的TXT记录,这个TXT记录的值是会发生变化的。所以续期时候还需要更改下DNS的TXT记录。
而且,还需要指定--manual-auth-hook参数,意思就是当我们自动使用插件的时候必须提供一个身份验证脚本,这样就可以使用 cerbot 提供的--manual-auth-hook来进行证书的更新,因为要录入DNS记录,所以会失败。

大致解决思路:

可以使用 certbot 插件,比如 certbot-dns-cloudflare 插件(还有其他的),就是自动更新 cloudflare 等 DNS 的记录。或者手动编写脚本,cerbot 官方提供了 cloudflare 的样例,笔者的域名是阿里云的,所以就寻找到了网友写好的阿里云可用的脚本。

4.2.2 准备 Aliyun DNS 更新脚本

可以使用GitHub网友提供的python脚本(还支持腾讯云和Godaddy),或者使用CSDN网友提供的阿里云Go版小程序certbot-alidns,这里我使用的是已经编译好的阿里云Go版小程序certbot-alidns(源码贴在文末了),在此表示感谢。

4.2.3 配置更新脚本所需信息

配置两个环境变量,可以写在/etc/profile,或者.bashrc里面,然后执行source生效。

# 阿里云的access key和secret(登录阿里云控制台获取)
export CERTBOT_ALI_KEY=""
export CERTBOT_ALI_SECRET=""

下载certbot-alidns,并添加可执行权限。

wget https://ghost.oss.sherlocky.com/certbot/certbot-alidns -O /usr/bin/certbot-alidns
chmod +x /usr/bin/certbot-alidns

4.2.4 更新泛域名证书

# 测试更新(测试成功会有提示信息,并不会真的生效,只是测试)
certbot renew --dry-run --cert-name xxx.com --manual-auth-hook /usr/bin/certbot-alidns

# 手动更新
certbot renew --cert-name xxx.com --manual-auth-hook /usr/bin/certbot-alidns

还可以在更新后,自动执行命令,比如重启Nginx:

###### 手动更新后自动重启nginx(前提:Nginx已service化)
certbot renew --cert-name xxx.com --manual-auth-hook /usr/bin/certbot-alidns --deploy-hook "systemctl restart nginx"

4.2.5 重新加载下ssl证书

配合Nginx使用具体可参见下一章节。

/usr/local/nginx/sbin/nginx -t
/usr/local/nginx/sbin/nginx -s reload

4.2.6 配置自动更新

添加 crontab 计划,使用命令crontab -e,添加以下内容。

## 泛域名证书的更新
##### crontab 任务自动更新(每月 15 号,因为证书有效期<30天才会renew)
##### 只有成功renew证书,才会重新启动nginx
0 0 15 * * certbot renew --cert-name xxx.com --manual-auth-hook /usr/bin/certbot-alidns --deploy-hook "systemctl restart nginx" >> /var/log/certbot.log
## 子域名证书的更新
0 0 15 * * certbot renew --cert-name a.xxx.com --manual-auth-hook /usr/bin/certbot-alidns --deploy-hook "python3 qiniu_cdn_ssl_cert_auto_renew.py xxx.yyy.com" >> /var/log/certbot.log

5.证书的使用

笔者的证书主要配合 Nginx 使用,当然也可以作其他用途。

直接上 Nginx 配置:

server {
	listen       80;
	server_name  "a.xxx.com";
	# 实现自动跳转
	return 301 https://$server_name$request_uri;
}		

server {
	listen       443;
	server_name  "a.xxx.com";
	
	ssl on;
	root html;
	index index.html index.htm;		
	ssl_certificate  /etc/letsencrypt/live/a.xxx.com/fullchain.pem;
	ssl_certificate_key /etc/letsencrypt/live/a.xxx.com/privkey.pem;
	ssl_session_timeout 5m;
	ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
	ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
	ssl_prefer_server_ciphers on;
	
	location / {
		root   html;
		index  index.html index.htm;
		proxy_pass        http://halo_blog;
		client_max_body_size  3000m;
		include jira_proxy.conf;
		access_log off;
		proxy_set_header X-Forwarded-Proto $scheme;
		
		# 点击劫持:无X-Frame-Options头信息
		add_header X-Frame-Options SAMEORIGIN;
	}
	# 禁止访问以下类型文件(防一部分恶意攻击)
	location ~* \.(ini|cfg|dwt|lbi|php|action)$ {    
		deny all;    
	}  		
}

Go版阿里云DNS更新小程序源码贴一下:

package main
import (
    "fmt"
    "github.com/denverdino/aliyungo/dns"
    "os"
    "time"
)
var certbot_ali_key string
var certbot_ali_secret string
func init() {
    // 定义阿里云的访问key和secret
    certbot_ali_key = os.Getenv("CERTBOT_ALI_KEY")
    certbot_ali_secret = os.Getenv("CERTBOT_ALI_SECRET")
    // 判断阿里云的key和secret是否存在
    if certbot_ali_key == "" || certbot_ali_secret == "" {
        fmt.Println("请设置环境变量CERTBOT_ALI_KEY和CERTBOT_ALI_SECRET")
        os.Exit(1)
    }
}
func main() {
    client := dns.NewClient(certbot_ali_key, certbot_ali_secret)
    var args = new(dns.DescribeDomainRecordsArgs)
    args.DomainName = os.Getenv("CERTBOT_DOMAIN")
    args.RRKeyWord = "_acme-challenge"
    args.TypeKeyWord = "TXT"
    res, err := client.DescribeDomainRecords(args)
    if err == nil {
        records := res.DomainRecords.Record
        // 记录大于1执行更新,小于1执行创建
        if len(records) > 0 {
            for i := 0; i < len(records); i++ {
                var update_args = new(dns.UpdateDomainRecordArgs)
                update_args.RecordId = records[i].RecordId
                update_args.RR = "_acme-challenge"
                update_args.Value = os.Getenv("CERTBOT_VALIDATION")
                update_args.Type = "TXT"
                res, err := client.UpdateDomainRecord(update_args)
                if err == nil {
                    fmt.Println("更新成功:", res.RecordId)
                    time.Sleep(time.Duration(20) * time.Second)
                } else {
                    fmt.Println("更新失败:", err.Error())
                    os.Exit(2)
                }
            }
        } else {
            // 执行创建操作
            var add_args = new(dns.AddDomainRecordArgs)
            add_args.DomainName = os.Getenv("CERTBOT_VALIDATION")
            add_args.RR="_acme-challenge"
            add_args.Value=os.Getenv("CERTBOT_VALIDATION")
            add_args.Type="TXT"
            res,err:=client.AddDomainRecord(add_args)
            if err == nil {
                fmt.Println("创建成功:", res.RecordId)
                time.Sleep(time.Duration(20) * time.Second)
            } else {
                fmt.Println("创建失败:", err.Error())
                os.Exit(2)
            }
        }
    }
}

[^1]: How to install Let's Encrypt on Nginx - UpCloud
[^2]: 使用 CertBot 自动更新 Let's Encrypt SSL 证书 - 简书
[^3]: CentOS 7 下 安装 Let's Encrypt 的通配符证书
[^4]: CentOS 7 上使用Certbot申请通配符证书