前言
在信息安全越来越受到重视的当今,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站点的部署以及证书过期前自动续期。