diff --git a/README.md b/README.md index b51ed74..96f7396 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ 不管是申请还是续期,只要是通配符证书,只能采用 dns-01 的方式校验申请者的域名,也就是说 certbot 操作者必须手动添加 DNS TXT 记录。 如果你编写一个 Cron (比如 1 1 */1 * * root certbot-auto renew),自动 renew 通配符证书,此时 Cron 无法自动添加 TXT 记录,这样 renew 操作就会失败,如何解决? - + certbot 提供了一个 hook,可以编写一个 Shell 脚本,让脚本调用 DNS 服务商的 API 接口,动态添加 TXT 记录,这样就无需人工干预了。 -在 certbot 官方提供的插件和 hook 例子中,都没有针对国内 DNS 服务器的样例,所以我编写了这样一个工具,目前支持阿里云 DNS、腾讯云 DNS、GoDaddy(certbot 官方没有对应的插件)。 +在 certbot 官方提供的插件和 hook 例子中,都没有针对国内 DNS 服务器的样例,所以我编写了这样一个工具,目前支持阿里云 DNS、腾讯云 DNS、GoDaddy(certbot 官方没有对应的插件)。 ### 自动申请通配符证书 @@ -21,7 +21,7 @@ $ git clone https://github.com/ywdblog/certbot-letencrypt-wildcardcertificates-a $ cd certbot-letencrypt-wildcardcertificates-alydns-au -$ chmod 0777 au.sh +$ chmod 0777 au.sh ``` 2:配置 @@ -32,10 +32,11 @@ $ chmod 0777 au.sh (2)DNS API 密钥: -这个 API 密钥什么意思呢?由于需要通过 API 操作阿里云 DNS 或腾讯云 DNS 的记录,所以需要去域名服务商哪儿获取 API 密钥,然后配置在 au.sh 文件中: +这个 API 密钥什么意思呢?由于需要通过 API 操作阿里云 DNS, 腾讯云 DNS 的记录,所以需要去域名服务商哪儿获取 API 密钥,然后配置在 au.sh 文件中: - ALY_KEY 和 ALY_TOKEN:阿里云 [API key 和 Secrec 官方申请文档](https://help.aliyun.com/knowledge_detail/38738.html)。 - TXY_KEY 和 TXY_TOKEN:腾讯云 [API 密钥官方申请文档](https://console.cloud.tencent.com/cam/capi)。 +- HWY_KEY 和 HWY_TOKEN: 华为云 [API 密钥官方申请文档](https://support.huaweicloud.com/devg-apisign/api-sign-provide.html) - GODADDY_KEY 和 GODADDY_TOKEN:GoDaddy [API 密钥官方申请文档](https://developer.godaddy.com/getstarted)。 (3)选择运行环境 @@ -49,16 +50,17 @@ $ chmod 0777 au.sh - Python(支持2.7和3.7,无需任何第三方库) - au.sh python aly add/clean:Python操作阿里云DNS,增加/清空DNS。 - au.sh python txy add/clean:Python操作腾讯云DNS,增加/清空DNS。 - - au.sh python godaddy add/clean:Python操作GoDaddy DNS,增加/清空DNS。 + - au.sh python hwy add/clean:Python操作华为云DNS,增加/清空DNS。 + - au.sh python godaddy add/clean:Python操作GoDaddy DNS,增加/清空DNS。 根据自己服务器环境和域名服务商选择任意一个 hook shell(包含相应参数),具体使用见下面。 3:申请证书 - + 测试是否有错误: ``` -$ ./certbot-auto certonly -d *.example.com --manual --preferred-challenges dns --dry-run --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" +$ ./certbot-auto certonly -d *.example.com --manual --preferred-challenges dns --dry-run --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" ``` **Debug:** 操作 DNS API 可能会遇到一系列问题,比如 API token 权限不足,遇到相关问题,可以查看 /var/log/certd.log。 @@ -70,12 +72,12 @@ $ ./certbot-auto certonly -d *.example.com --manual --preferred-challenges dns - 第三个参数是固定的(--manual-auth-hook中用add,--manual-clean-hook中用clean) 比如你要选择Python环境,可以将 --manual-auth-hook 输入修改为 "/脚本目录/au.sh python aly add",--manual-cleanup-hook 输入修改为 "/脚本目录/au.sh python aly clean" - + 确认无误后,实际运行(去除 --dry-run 参数): -``` +``` # 实际申请 -$ ./certbot-auto certonly -d *.example.com --manual --preferred-challenges dns --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" +$ ./certbot-auto certonly -d *.example.com --manual --preferred-challenges dns --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" ``` 参数解释(可以不用关心): @@ -91,7 +93,7 @@ $ ./certbot-auto certonly -d *.example.com --manual --preferred-challenges dns 如果你想为多个域名申请通配符证书(合并在一张证书中,也叫做 **SAN 通配符证书**),直接输入多个 -d 参数即可,比如: ``` -$ ./certbot-auto certonly -d *.example.com -d *.example.org -d www.example.cn --manual --preferred-challenges dns --dry-run --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" +$ ./certbot-auto certonly -d *.example.com -d *.example.org -d www.example.cn --manual --preferred-challenges dns --dry-run --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" ``` ### 续期证书 @@ -99,7 +101,7 @@ $ ./certbot-auto certonly -d *.example.com -d *.example.org -d www.example.cn 1:对机器上所有证书 renew ``` -$ ./certbot-auto renew --manual --preferred-challenges dns --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" +$ ./certbot-auto renew --manual --preferred-challenges dns --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" ``` 2:对某一张证书进行续期 @@ -117,23 +119,23 @@ $ ./certbot-auto certificates 记住证书名,比如 simplehttps.com,然后运行下列命令 renew: ``` -$ ./certbot-auto renew --cert-name simplehttps.com --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" +$ ./certbot-auto renew --cert-name simplehttps.com --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" ``` -### 加入 crontab +### 加入 crontab 编辑文件 /etc/crontab : ``` #证书有效期<30天才会renew,所以crontab可以配置为1天或1周 -1 1 */1 * * root certbot-auto renew --manual --preferred-challenges dns --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" +1 1 */1 * * root certbot-auto renew --manual --preferred-challenges dns --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" ``` 如果是certbot 机器和运行web服务(比如 nginx,apache)的机器是同一台,那么成功renew证书后,可以启动对应的web 服务器,运行下列crontab : ``` # 注意只有成功renew证书,才会重新启动nginx -1 1 */1 * * root certbot-auto renew --manual --preferred-challenges dns --deploy-hook "service nginx restart" --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" +1 1 */1 * * root certbot-auto renew --manual --preferred-challenges dns --deploy-hook "service nginx restart" --manual-auth-hook "/脚本目录/au.sh php aly add" --manual-cleanup-hook "/脚本目录/au.sh php aly clean" ``` @@ -142,18 +144,19 @@ $ ./certbot-auto renew --cert-name simplehttps.com --manual-auth-hook "/脚本 ### 贡献 - 阿里云 python 版 @Duke-Wu -- 腾讯云 python 版 @akgnah +- 腾讯云 python 版 @akgnah +- 华为云 python 版 @jinhucheung - GoDaddy PHP 版 wlx_1990 (2019-01-11) ### 其他 - 可以关注公众号(虞大胆的叽叽喳喳,yudadanwx),了解更多密码学&HTTPS协议知识。 - 我写了一本书[《深入浅出HTTPS:从原理到实战》](https://mp.weixin.qq.com/s/80oQhzmP9BTimoReo1oMeQ)了解更多关于HTTPS方面的知识。**如果你觉得本书还可以,希望能在豆瓣做个点评,以便让更多人了解,非常感谢。豆瓣评论地址:[https://book.douban.com/subject/30250772/](https://book.douban.com/subject/30250772/)** - + 公众号二维码: ![公众号:虞大胆的叽叽喳喳,yudadanwx](https://notes.newyingyong.cn/static/image/wxgzh/qrcode_258.jpg) 《深入浅出HTTPS:从原理到实战》二维码: -![深入浅出HTTPS:从原理到实战](https://notes.newyingyong.cn/static/image/httpsbook/httpsbook-small-jd.jpg) +![深入浅出HTTPS:从原理到实战](https://notes.newyingyong.cn/static/image/httpsbook/httpsbook-small-jd.jpg) diff --git a/au.sh b/au.sh index b465a95..001cbcc 100755 --- a/au.sh +++ b/au.sh @@ -4,9 +4,9 @@ ###### 根据自己的情况修改 Begin ############## -#PHP 命令行路径,如果有需要可以修改 +#PHP 命令行路径,如果有需要可以修改 phpcmd="/usr/bin/php" -#Python 命令行路径,如果有需要可以修改 +#Python 命令行路径,如果有需要可以修改 pythoncmd="/usr/bin/python" #填写阿里云的AccessKey ID及AccessKey Secret @@ -19,6 +19,11 @@ ALY_TOKEN="" TXY_KEY="" TXY_TOKEN="" +#填写华为云的 Access Key Id 及 Secret Access Key +#如何申请见https://support.huaweicloud.com/devg-apisign/api-sign-provide.html +HWY_KEY="" +HWY_TOKEN="" + #GoDaddy的SecretId及SecretKey #如何申请见https://developer.godaddy.com/getstarted GODADDY_KEY="" @@ -32,8 +37,8 @@ PATH=$(cd `dirname $0`; pwd) # 第一个参数:使用什么语言环境 # 第二个参数:使用那个 DNS 的 API # 第三个参数:add or clean -plang=$1 #python or php -pdns=$2 #aly or txy +plang=$1 #python or php +pdns=$2 #aly, txy, hwy, godaddy paction=$3 #add or clean #内部变量 @@ -45,43 +50,53 @@ if [[ "$paction" != "clean" ]]; then paction="add" fi -case $plang in - "php") +case $plang in + "php") cmd=$phpcmd - if [[ "$pdns" == "aly" ]]; then - dnsapi=$PATH"/php-version/alydns.php" - key=$ALY_KEY + if [[ "$pdns" == "aly" ]]; then + dnsapi=$PATH"/php-version/alydns.php" + key=$ALY_KEY token=$ALY_TOKEN - elif [[ "$pdns" == "txy" ]] ;then + elif [[ "$pdns" == "txy" ]]; then dnsapi="$PATH/php-version/txydns.php" key=$TXY_KEY token=$TXY_TOKEN + elif [[ "$pdns" == "hwy" ]]; then + # TODO + dnsapi="" + key=$HWY_KEY + token=$HWY_TOKEN + exit else dnsapi="$PATH/php-version/godaddydns.php" key=$GODADDY_KEY token=$GODADDY_TOKEN fi ;; - + "python") - + cmd=$pythoncmd - if [[ "$pdns" == "aly" ]]; then + if [[ "$pdns" == "aly" ]]; then dnsapi=$PATH"/python-version/alydns.py" key=$ALY_KEY token=$ALY_TOKEN - elif [[ "$pdns" == "txy" ]] ;then + elif [[ "$pdns" == "txy" ]]; then dnsapi=$PATH"/python-version/txydns.py" key=$TXY_KEY token=$TXY_TOKEN + elif [[ "$pdns" == "hwy" ]]; then + dnsapi="$PATH/python-version/hwydns.py" + key=$HWY_KEY + token=$HWY_TOKEN else dnsapi=$PATH"/python-version/godaddydns.py" key=$GODADDY_KEY token=$GODADDY_TOKEN exit - fi - ;; + fi + ;; esac $cmd $dnsapi $paction $CERTBOT_DOMAIN "_acme-challenge" $CERTBOT_VALIDATION $key $token >>"/var/log/certd.log" diff --git a/python-version/alydns.py b/python-version/alydns.py index bff0e24..54f4572 100644 --- a/python-version/alydns.py +++ b/python-version/alydns.py @@ -8,7 +8,7 @@ import random import string import json import sys -import os +import os pv = "python2" #python2 @@ -32,8 +32,8 @@ class AliDns: @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" diff --git a/python-version/hwydns.py b/python-version/hwydns.py new file mode 100755 index 0000000..76058ff --- /dev/null +++ b/python-version/hwydns.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import sys +import time +import urllib +import hashlib +import hmac +import binascii +import json + +if sys.version_info < (3, 0): + import urllib2 + import urllib + import urlparse +else: + import urllib.request as urllib2 + import urllib.parse as urllib + +class HwyDns: + __endpoint = 'dns.myhuaweicloud.com' + + def __init__(self, access_key_id, secret_access_key): + self.access_key_id = access_key_id + self.secret_access_key = secret_access_key + + @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) + + index = -3 if '.'.join(domain_parts[-2:]).lower() in domainarr else -2 + return ('.'.join(domain_parts[:index]), '.'.join(domain_parts[index:])) + return ('', domain) + + # @example hwydns.add_domain_record("example.com", "_acme-challenge", "123456", "TXT") + def add_domain_record(self, domain, rr, value, _type = 'TXT'): + zone_id = self.get_domain_zone_id(domain) + + if not zone_id: + return + + self.__request('POST', '/v2/zones/%s/recordsets' % (zone_id), { + 'name' : '%s.%s.' % (rr, domain), + 'type' : _type, + 'records' : [ "\"%s\"" % (value) ] + }) + + # @example hwydns.delete_domain_record("example.com", "_acme-challenge", "TXT") + def delete_domain_record(self, domain, rr, _type = 'TXT'): + zone_id = self.get_domain_zone_id(domain) + recordset_id = self.get_domain_recordset_id(domain, rr, _type) + + if not (zone_id and recordset_id): + return + + self.__request('DELETE', '/v2/zones/%s/recordsets/%s' % (zone_id, recordset_id)) + + # @example hwydns.get_domain_record("example.com", "_acme-challenge", "TXT") + def get_domain_record(self, domain, rr, _type = 'TXT'): + try: + full_domain = '.'.join([rr, domain]) + response = self.__request('GET', '/v2/recordsets?type=%s&name=%s' % (_type, full_domain)) + content = json.loads(response) + return list(filter(lambda record: record['name'][:-1] == full_domain and record['type'] == _type, content['recordsets']))[0] + except Exception as e: + print('hwydns#get_domain_record raise: ' + str(e)) + return None + + # @example hwydns.get_domain("example.com") + def get_domain(self, domain): + try: + response = self.__request('GET', '/v2/zones?type=public&name=%s' % (domain)) + content = json.loads(response) + return list(filter(lambda item: item['name'][:-1] == domain, content['zones']))[0] + except Exception as e: + print('hwydns#get_domain raise: ' + str(e)) + return None + + def get_domain_recordset_id(self, domain, rr, _type = 'TXT'): + try: + record = self.get_domain_record(domain, rr, _type) + return record['id'] if record else None + except Exception as e: + print('hwydns#get_domain_recordset_id raise: ' + str(e)) + return None + + def get_domain_zone_id(self, domain): + try: + record = self.get_domain(domain) + return record['id'] if record else None + except Exception as e: + print('hwydns#get_domain_zone_id raise: ' + str(e)) + return None + + def __request(self, method, path, payload={}): + url = 'https://%s%s?%s' % (self.__endpoint, self.__parse_path(path)[:-1], self.__parse_query_string(path)) + data = json.dumps(payload).encode('utf8') + sdk_date = self.__build_sdk_date() + + print('Request URL: ' + url) + print('Request Data: ' + str(data)) + + request = urllib2.Request(url=url, data=data) + request.get_method = lambda: method + request.add_header('Content-Type', 'application/json') + request.add_header('Host', self.__endpoint) + request.add_header('X-sdk-date', sdk_date) + request.add_header('Authorization', self.__build_authorization(request)) + print('Request headers: ' + str(request.headers)) + + try: + f = urllib2.urlopen(request, timeout=45) + response = f.read().decode('utf-8') + print(response) + return response + except urllib2.HTTPError as e: + print('hwydns#__request raise urllib2.HTTPError: ' + str(e)) + raise SystemExit(e) + + def __build_sdk_date(self): + return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + + def __build_authorization(self, request): + algorithm = 'SDK-HMAC-SHA256' + canonical_request = self.__build_canonical_request(request) + canonical_request_hexencode = self.__hexencode_sha256_hash(canonical_request.encode('utf-8')) + string2sign = "%s\n%s\n%s" % (algorithm, request.get_header('X-sdk-date'), canonical_request_hexencode) + sign = self.__build_sign(string2sign) + + return "%s Access=%s, SignedHeaders=%s, Signature=%s" % (algorithm, self.access_key_id, self.__parse_header_keys(request.headers), sign) + + def __build_canonical_request(self, request): + return "%(method)s\n%(path)s\n%(query_string)s\n%(headers)s\n%(header_keys)s\n%(data_hexencode)s" % { + 'method': request.get_method().upper(), + 'path': self.__parse_path(request.get_full_url()), + 'query_string': self.__parse_query_string(request.get_full_url()), + 'headers': self.__parse_headers(request.headers), + 'header_keys': self.__parse_header_keys(request.headers), + 'data_hexencode': self.__hexencode_sha256_hash(request.data) + } + + def __parse_path(self, url): + if sys.version_info < (3,0): + path = urlparse.urlsplit(url).path + else: + path = urllib.urlsplit(url).path + + path = path if path else '/' + pattens = urllib.unquote(path).split('/') + + tmp_paths = [] + for v in pattens: + tmp_paths.append(self.__urlencode(v)) + urlpath = '/'.join(tmp_paths) + if urlpath[-1] != '/': + urlpath = urlpath + '/' + return urlpath + + def __parse_query_string(self, url): + if sys.version_info < (3,0): + query = urlparse.parse_qs(urlparse.urlsplit(url).query) + else: + query = urllib.parse_qs(urllib.urlsplit(url).query) + + sorted_query = sorted(query.items(), key=lambda item: item[0]) + sorted_query_string = '' + for (k, v) in sorted_query: + if type(v) is list: + v.sort() + for item in v: + sorted_query_string += '&' + self.__urlencode(k) + '=' + self.__urlencode(item) + else: + sorted_query_string += '&' + self.__urlencode(k) + '=' + self.__urlencode(v) + + return sorted_query_string[1:] + + def __parse_headers(self, headers): + format_headers = dict(((k.lower(), v.strip())) for (k, v) in headers.items()) + + header_string = '' + for (k, v) in sorted(format_headers.items(), key=lambda item: item[0]): + header_string += "%s:%s\n" % (k, v) + return header_string + + def __parse_header_keys(self, headers): + return ';'.join(sorted(map(lambda key: key.lower(), headers.keys()))) + + def __build_sign(self, string2sign): + if sys.version_info < (3,0): + hm = hmac.new(self.secret_access_key, string2sign, digestmod=hashlib.sha256).digest() + else: + hm = hmac.new(self.secret_access_key.encode('utf-8'), string2sign.encode('utf-8'), digestmod=hashlib.sha256).digest() + return binascii.hexlify(hm).decode() + + def __urlencode(self, string): + return urllib.quote(str(string), safe='~') + + def __hexencode_sha256_hash(self, data): + sha256 = hashlib.sha256() + sha256.update(data) + return sha256.hexdigest() + +if __name__ == '__main__': + print('开始调用华为云 DNS API') + print('-'.join(sys.argv)) + + _, action, certbot_domain, acme_challenge, certbot_validation, api_key, api_secret = sys.argv + + subdomain, main_domain = HwyDns.getDomain(certbot_domain) + if subdomain: + subdomain = acme_challenge + '.' + subdomain + else: + subdomain = acme_challenge + + hwydns = HwyDns(api_key, api_secret) + + if 'add' == action: + hwydns.add_domain_record(main_domain, subdomain, certbot_validation) + elif 'clean' == action: + hwydns.delete_domain_record(main_domain, subdomain) + + print('结束调用华为云 DNS API') \ No newline at end of file