Let’s Encrypt证书的申请、部署以及自动续期

/ 0评 / 0

前言

在信息安全越来越受到重视的当今,https成为了网站的标准配置,但是购买一张https证书少则几百/年,多则几万/年,让一些小型企业、机构以及计算机爱好者望而却步。

为了推广https,由Mozilla、Cisco、Akamai、IdenTrust、EFF等组织人员发起并成立了Let's Encrypt,为人们签发免费的https证书。

本文记录如何申请Let's Encrypt颁发的https证书,以及如何在过期前自动续期。内容总结自网络,非原创。

具体步骤

安装snap

sudo apt-get install snap

snap是一个软件包管理工具

安装certbot

sudo snap install --classic certbot

certbot是一个Let's Encrypt客户端,可以申请证书、部署证书、更新证书...

建立软链接

sudo ln -s /snap/bin/certbot /usr/bin/certbot

生成证书

certbot certonly  --manual -d yuannancheng.com -d *.yuannancheng.com --server https://acme-v02.api.letsencrypt.org/directory

按照提示把DNS TXT记录解析到域名中。

确保解析已生效后,回到命令行,按下回车,certbot会检验DNS记录以确保你的确拥有此域名的所有权。

确认无误后,将会颁发证书。

成功输出

 IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/yuannancheng.com/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/yuannancheng.com/privkey.pem
   Your certificate will expire on 2021-08-28. To obtain a new or
   tweaked version of this certificate in the future, simply run
   certbot again. To non-interactively renew *all* of your
   certificates, run "certbot renew"
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

配置apache

<IfModule mod_ssl.c>
  <VirtualHost *:443>
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/yuannancheng.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/yuannancheng.com/privkey.pem
  </VirtualHost>
</IfModule>

设置定时任务

Let's Encrypt颁发的证书只有90天的有效期,因此我们需要通过设置定时任务在证书过期前自动续期

crontab 0 4 20 */2 * /root/cert-renew.sh >/root/cert-renew.log 2>&1

crontab是一个日程管理工具,可以定时运行指定文件

这段命令的意思是每两个月的20号的凌晨4点将会执行/root/cert-renew.sh文件,并将错误信息保存到/root/cert-renew.log

同时/root保持以下目录结构

├── alydns
│   ├── alydns.py
│   ├── au.sh
│   └── domain.ini
└── cert-renew.sh

alydns/alydns.py文件内容

# coding:utf-8

import base64
import urllib
import hmac
import datetime
import random
import string
import json
import sys
import os

pv = "python2"
#python2
if sys.version_info[0] < 3:
    from urllib import quote
    from urllib import urlencode
    import hashlib
else:
    from urllib.parse import quote
    from urllib.parse import urlencode
    from urllib import request
    pv = "python3"

class AliDns:
    def __init__(self, access_key_id, access_key_secret, domain_name):
        self.access_key_id = access_key_id
        self.access_key_secret = access_key_secret
        self.domain_name = domain_name

    @staticmethod
    def getDomain(domain):
        domain_parts = domain.split('.')

        if len(domain_parts) > 2:
            dirpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
            domainfile = dirpath + "/domain.ini"
            domainarr = []
            with open(domainfile) as f:
                for line in f:
                    val = line.strip()
                    domainarr.append(val)

            #rootdomain = '.'.join(domain_parts[-(2 if domain_parts[-1] in {"co.jp", "com.tw", "net", "com", "com.cn", "org", "cn", "gov", "net.cn", "io", "top", "me", "int", "edu", "link"} else 3):])
            rootdomain = '.'.join(domain_parts[-(2 if domain_parts[-1] in
                                                 domainarr else 3):])
            selfdomain = domain.split(rootdomain)[0]
            return (selfdomain[0:len(selfdomain)-1], rootdomain)
        return ("", domain)

    @staticmethod
    def generate_random_str(length=14):
        """
        生成一个指定长度(默认14位)的随机数值,其中
        string.digits = "0123456789'
        """
        str_list = [random.choice(string.digits) for i in range(length)]
        random_str = ''.join(str_list)
        return random_str

    @staticmethod
    def percent_encode(str):
        res = quote(str.encode('utf-8'), '')
        res = res.replace('+', '%20')
        res = res.replace('*', '%2A')
        res = res.replace('%7E', '~')
        return res

    @staticmethod
    def utc_time():
        """
        请求的时间戳。日期格式按照ISO8601标准表示,
        并需要使用UTC时间。格式为YYYY-MM-DDThh:mm:ssZ
        例如,2015-01-09T12:00:00Z(为UTC时间2015年1月9日12点0分0秒)
        :return:
        """
        #utc_tz = pytz.timezone('UTC')
        #time = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
        #time = datetime.datetime.now(tz=utc_tz).strftime('%Y-%m-%dT%H:%M:%SZ')
        time = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
        return time

    @staticmethod
    def sign_string(url_param):
        percent_encode = AliDns.percent_encode
        sorted_url_param = sorted(url_param.items(), key=lambda x: x[0])
        can_string = ''
        for k, v in sorted_url_param:
            can_string += '&' + percent_encode(k) + '=' + percent_encode(v)
        string_to_sign = 'GET' + '&' + '%2F' + \
            '&' + percent_encode(can_string[1:])
        return string_to_sign

    @staticmethod
    def access_url(url):
        if pv == "python2":
            f = urllib.urlopen(url)
            result = f.read().decode('utf-8')
            #print(result)
            return json.loads(result)
        else:
            req = request.Request(url)
            with request.urlopen(req) as f:
                result = f.read().decode('utf-8')
                #print(result)
                return json.loads(result)

    def visit_url(self, action_param):
        common_param = {
            'Format': 'json',
            'Version': '2015-01-09',
            'AccessKeyId': self.access_key_id,
            'SignatureMethod': 'HMAC-SHA1',
            'Timestamp': AliDns.utc_time(),
            'SignatureVersion': '1.0',
            'SignatureNonce': AliDns.generate_random_str(),
            'DomainName': self.domain_name,
        }
        url_param = dict(common_param, **action_param)
        string_to_sign = AliDns.sign_string(url_param)

        hash_bytes = self.access_key_secret + "&"
        if pv == "python2":
            h = hmac.new(hash_bytes, string_to_sign, digestmod=hashlib.sha1)
        else:
            # Return a new hmac object
            # key is a bytes or bytearray object giving the secret key
            # Parameter msg can be of any type supported by hashlib
            # Parameter digestmod can be the name of a hash algorithm.(字符串)
            h = hmac.new(hash_bytes.encode('utf-8'),
                         string_to_sign.encode('utf-8'), digestmod='SHA1')

        if pv == "python2":
            signature = base64.encodestring(h.digest()).strip()
        else:
            # digest() 返回摘要,= HMAC(key, msg, digest).digest()
            # encodestring Deprecated since version 3.1
            # encodebytes() Encode the bytes-like object s,which can contain arbitrary binary data, and return bytes containing the base64-encoded data
            signature = base64.encodebytes(h.digest()).strip()

        url_param.setdefault('Signature', signature)
        url = 'https://alidns.aliyuncs.com/?' + urlencode(url_param)
        #print(url)
        return AliDns.access_url(url)

    # 显示所有
    def describe_domain_records(self):
        """
        最多只能查询此域名的 500条解析记录
        PageNumber  当前页数,起始值为1,默认为1
        PageSize  分页查询时设置的每页行数,最大值500,默认为20
        :return:
        """
        action_param = dict(
            Action='DescribeDomainRecords',
            PageNumber='1',
            PageSize='500',
        )
        result = self.visit_url(action_param)
        return result

    # 增加解析
    def add_domain_record(self, type, rr, value):
        action_param = dict(
            Action='AddDomainRecord',
            RR=rr,
            Type=type,
            Value=value,
        )
        result = self.visit_url(action_param)
        return result

    # 修改解析
    def update_domain_record(self, id, type, rr, value):
        action_param = dict(
            Action="UpdateDomainRecord",
            RecordId=id,
            RR=rr,
            Type=type,
            Value=value,
        )
        result = self.visit_url(action_param)
        return result

    # 删除解析
    def delete_domain_record(self, id):
        action_param = dict(
            Action="DeleteDomainRecord",
            RecordId=id,
        )
        result = self.visit_url(action_param)
        return result

if __name__ == '__main__':
    #filename,ACCESS_KEY_ID, ACCESS_KEY_SECRET = sys.argv
    #domain = AliDns(ACCESS_KEY_ID, ACCESS_KEY_SECRET, 'simplehttps.com')
    #domain.describe_domain_records()
    #增加记录
    #print(domain.add_domain_record("TXT", "test", "test"))

    # 修改解析
    #domain.update_domain_record('4011918010876928', 'TXT', 'test2', 'text2')
    # 删除解析记录
    # data = domain.describe_domain_records()
    # record_list = data["DomainRecords"]["Record"]
    # for item in record_list:
    #   if 'test' in item['RR']:
    #       domain.delete_domain_record(item['RecordId'])

    # 第一个参数是 action,代表 (add/clean)
    # 第二个参数是域名
    # 第三个参数是主机名(第三个参数+第二个参数组合起来就是要添加的 TXT 记录)
    # 第四个参数是 TXT 记录值
    # 第五个参数是 APPKEY
    # 第六个参数是 APPTOKEN
    #sys.exit(0)

    print("域名 API 调用开始")
    print("-".join(sys.argv))
    file_name, cmd, certbot_domain, acme_challenge, certbot_validation, ACCESS_KEY_ID, ACCESS_KEY_SECRET = sys.argv

    certbot_domain = AliDns.getDomain(certbot_domain)
    #print (certbot_domain)
    if certbot_domain[0] == "":
            selfdomain = acme_challenge
    else:
            selfdomain = acme_challenge + "." + certbot_domain[0]

    domain = AliDns(ACCESS_KEY_ID, ACCESS_KEY_SECRET, certbot_domain[1])

    if cmd == "add":
        result = (domain.add_domain_record(
            "TXT", selfdomain, certbot_validation))
        if "Code" in result:
            print("aly dns 域名增加失败-" +
                  str(result["Code"]) + ":" + str(result["Message"]))
            sys.exit(0)
    elif cmd == "clean":
        data = domain.describe_domain_records()
        if "Code" in data:
            print("aly dns 域名删除失败-" +
                  str(data["Code"]) + ":" + str(data["Message"]))
            sys.exit(0)
        record_list = data["DomainRecords"]["Record"]
        if record_list:
            for item in record_list:
                if (item['RR'] == selfdomain):
                    domain.delete_domain_record(item['RecordId'])

print("域名 API 调用结束")

alydns/domain.ini文件内容

net
com
com.cn
cn
org
co.jp
com.tw
gov
net.cn
io
top
me
int
edu
link
uk
hk
shop

alydns/au.sh文件内容

#!/bin/bash

#ywdblog@gmail.com 欢迎关注我的书《深入浅出HTTPS:从原理到实战》

###### 根据自己的情况修改 Begin ##############
#填写阿里云的AccessKey ID及AccessKey Secret
#如何申请见https://help.aliyun.com/knowledge_detail/38738.html
cmd="/usr/bin/python3"
key="keykeykeykeykeykey"
token="tokentokentokentokentoken"
################ END ##############

PATH=$(cd `dirname $0`; pwd)
dnsapi=$PATH"/alydns.py"

# 命令行参数
paction=$1 # add or clean
if [[ "$paction" != "clean" ]]; then
    paction="add"
fi

$cmd $dnsapi $paction $CERTBOT_DOMAIN "_acme-challenge" $CERTBOT_VALIDATION $key $token >>"/var/log/certd.log"

if [[ "$paction" == "add" ]]; then
  # DNS TXT 记录刷新时间
  /bin/sleep 20
fi

cert-renew.sh文件内容

#!/usr/bin

# 停止apache服务器
systemctl stop apache2

# 重新申请证书

/usr/bin/certbot renew --force-renew --manual --preferred-challenges dns --manual-auth-hook "/root/alydns/au.sh add" --manual-cleanup-hook "/root/alydns/au.sh clean"

# 启动apache服务器
systemctl start apache2

测试是否可以重新申请

certbot renew --dry-run --manual --preferred-challenges dns --manual-auth-hook "/root/alydns/au.sh add" --manual-cleanup-hook "/root/alydns/au.sh clean"

部署成功后访问网站,浏览器地址栏会出现一把小锁(例如本页面)

这样,我们就实现了https站点的部署以及证书过期前自动续期。

发表回复