├── LICENSE ├── README.md ├── aliyun.py ├── config.ini ├── letsencrypt-create.sh └── manual-hook.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Broly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 工具介绍 2 | Let’s Encrypt通配符证书申请,只能用DNS plugins的验证方式。其原理就是依照提示增加某个TXT记录的域名进行验证。整个流程都是需要配合certbot的提示并手动执行。 3 | 如果想自动完成这个过程,根据官方文档提供的资料,需要写有两个hook脚本来替代人工操作: 4 | - **--manual-auth-hook** 5 | - **--manual-cleanup-hook** 6 | 7 | 强烈建议看完本人的这篇博文之后再来使用:《[LET’S ENCRYPT通配符证书的申请与自动更新(附阿里云域名的HOOK脚本)](https://blog.dreamlikes.cn/2018/08/25/lets-encrypt-wildcard-certificate-apply-and-auto-renew/)》 8 | 9 | 这个工具是专门针对阿里云(万网)域名使用的,其他域名供应商的请勿使用。 10 | 11 | ## 使用步骤 12 | ### 一、下载代码 13 | ``` 14 | git clone https://github.com/broly8/letsencrypt-aliyun-dns-manual-hook.git 15 | ``` 16 | 17 | ### 二、配置appid和appsecret 18 | 首先去自己的阿里云域名管理后台,申请有增加和删除域名权限的appid和appsecret。具体申请步骤请参考 https://help.aliyun.com/knowledge_detail/38738.html 19 | 然后把申请好的appid和appsecret填入到**config.ini**文件中。 20 | ``` 21 | [aliyun] 22 | appid=your-appid 23 | appsecret=your-appsecret 24 | ``` 25 | ### 三、配置日志 26 | 如果要启用日志记录功能.方便追查操作,可以修改log节点下的enable为True. logfile参数为日志位置和日志文件名.默认生成在/var/log下 27 | ``` 28 | [log] 29 | enable=False 30 | logfile=/var/log/dmlkdevtool.log 31 | ``` 32 | ### 四、申请通配符证书 33 | 官方的证书申请工具certbot,有两个参数 **--manual-auth-hook** 和 **--manual-cleanup-hook** 34 | 即分别指定脚本,去增加TXT记录的域名和删除。 35 | 36 | 所以配合到本工具使用就是: 37 | ``` 38 | certbot certonly \ 39 | ... 40 | --manual-auth-hook 'python /path/to/manual-hook.py --auth' \ 41 | --manual-cleanup-hook 'python /path/to/manual-hook.py --cleanup' 42 | ``` 43 | 如果是多域名,可以修改/etc/letsencrypt/renewal/[domain].conf,在renewalparams节点下添加两行配置即可: 44 | ``` 45 | [renewalparams] 46 | authenticator = manual 47 | account = xxxxxxxxxxxxxxxxxxx 48 | pref_challs = dns-01, 49 | manual_public_ip_logging_ok = True 50 | server = https://acme-v02.api.letsencrypt.org/directory 51 | manual_auth_hook = python /path/to/manual-hook.py --auth 52 | manual_cleanup_hook = python /path/to/manual-hook.py --cleanup 53 | ``` 54 | 55 | 如果你对certbot工具不熟悉,或者仅仅想申请自己的通配符证书,可以使用本人提供的另一个脚本工具 **letsencrypt-create.sh** ,使用方法很简单 56 | ``` 57 | sh letsencrypt-create.sh -m your-email@example.com -d yourdomain.com 58 | ``` 59 | 60 | 如果想强制生成或者更新通配符证书,则使用 **-f** 参数 61 | ``` 62 | sh letsencrypt-create.sh -m your-email@example.com -d yourdomain.com -f 63 | ``` 64 | 65 | 如使用过程有任何问题,欢迎issue。 66 | -------------------------------------------------------------------------------- /aliyun.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import time 5 | import urllib 6 | import base64 7 | import hashlib 8 | import hmac 9 | import logging 10 | import logging.handlers 11 | 12 | if sys.version_info < (3,0): 13 | import urllib2 14 | import urllib 15 | from base64 import encodestring as base64encode 16 | else: 17 | import urllib.request as urllib2 18 | import urllib.parse as urllib 19 | from base64 import encodebytes as base64encode 20 | 21 | 22 | class AliyunDns: 23 | __endpoint = 'http://alidns.aliyuncs.com' 24 | __letsencryptSubDomain = '_acme-challenge' 25 | __appid = '' 26 | __appsecret = '' 27 | __logger = logging.getLogger("logger") 28 | 29 | def __init__(self, appid, appsecret): 30 | self.__appid = appid 31 | self.__appsecret = appsecret 32 | 33 | def __getSignatureNonce(self): 34 | return int(round(time.time() * 1000)) 35 | 36 | def __percentEncode(self, str): 37 | if sys.version_info <(3,0): 38 | res = urllib.quote(str.decode().encode('utf8'), '') 39 | else: 40 | res = urllib.quote(str.encode('utf8')) 41 | res = res.replace('+', '%20') 42 | res = res.replace('\'', '%27') 43 | res = res.replace('\"', '%22') 44 | res = res.replace('*', '%2A') 45 | res = res.replace('%7E', '~') 46 | 47 | return res 48 | 49 | def __signature(self, params): 50 | sortedParams = sorted(params.items(), key=lambda params: params[0]) 51 | 52 | query = '' 53 | for (k, v) in sortedParams: 54 | query += '&' + \ 55 | self.__percentEncode(k) + '=' + self.__percentEncode(str(v)) 56 | 57 | stringToSign = 'GET&%2F&' + self.__percentEncode(query[1:]) 58 | try: 59 | if (sys.version_info <(3,0)): 60 | h = hmac.new(self.__appsecret + "&", stringToSign, hashlib.sha1) 61 | else: 62 | h = hmac.new((self.__appsecret + "&").encode(encoding="utf-8"), stringToSign.encode(encoding="utf-8"), hashlib.sha1) 63 | except Exception as e: 64 | self.__logger.error(e) 65 | signature = base64encode(h.digest()).strip() 66 | 67 | return signature 68 | 69 | def __request(self, params): 70 | commonParams = { 71 | 'Format': 'JSON', 72 | 'Version': '2015-01-09', 73 | 'SignatureMethod': 'HMAC-SHA1', 74 | 'SignatureNonce': self.__getSignatureNonce(), 75 | 'SignatureVersion': '1.0', 76 | 'AccessKeyId': self.__appid, 77 | 'Timestamp': time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) 78 | } 79 | # print(commonParams) 80 | 81 | # merge all params 82 | finalParams = commonParams.copy() 83 | finalParams.update(params) 84 | 85 | # signature 86 | finalParams['Signature'] = self.__signature(finalParams) 87 | self.__logger.info('Signature'+ str(finalParams['Signature'])) 88 | # get final url 89 | url = '%s/?%s' % (self.__endpoint, urllib.urlencode(finalParams)) 90 | # print(url) 91 | 92 | request = urllib2.Request(url) 93 | try: 94 | f = urllib2.urlopen(request) 95 | response = f.read() 96 | self.__logger.info(response.decode('utf-8')) 97 | except urllib2.HTTPError as e: 98 | self.__logger.info(e.read().strip().decode('utf-8')) 99 | raise SystemExit(e) 100 | 101 | def addDomainRecord(self, domain, rr, value): 102 | params = { 103 | 'Action': 'AddDomainRecord', 104 | 'DomainName': domain, 105 | 'RR': rr, 106 | 'Type': 'TXT', 107 | 'Value': value 108 | } 109 | self.__request(params) 110 | 111 | def deleteSubDomainRecord(self, domain, rr): 112 | params = { 113 | 'Action': 'DeleteSubDomainRecords', 114 | 'DomainName': domain, 115 | 'RR': rr, 116 | 'Type': 'TXT' 117 | } 118 | self.__request(params) 119 | 120 | def addLetsencryptDomainRecord(self, domain, value): 121 | parts = domain.split('.') 122 | ln = len(parts) 123 | if ln > 2 and not domain.endswith(".cn"): 124 | self.addDomainRecord(".".join(parts[ln - 2:ln]), self.__letsencryptSubDomain + '.' + ".".join(parts[0:ln - 2]), value) 125 | else: 126 | self.addDomainRecord(domain, self.__letsencryptSubDomain, value) 127 | 128 | def deleteLetsencryptDomainRecord(self, domain): 129 | parts = domain.split('.') 130 | ln = len(parts) 131 | if ln > 2 and not domain.endswith(".cn"): 132 | self.deleteSubDomainRecord(".".join(parts[ln - 2:ln]), self.__letsencryptSubDomain + '.' + ".".join(parts[0:ln - 2])) 133 | else: 134 | self.deleteSubDomainRecord(domain, self.__letsencryptSubDomain) 135 | 136 | def toString(self): 137 | print('AliyunDns[appid='+self.__appid + 138 | ', appsecret='+self.__appsecret+']') 139 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [aliyun] 2 | appid=your-appid 3 | appsecret=your-appsecret 4 | [log] 5 | enable=False 6 | logfile=/var/log/dmlkdevtool.log 7 | -------------------------------------------------------------------------------- /letsencrypt-create.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DIR="$( cd "$(dirname "$0")" ; pwd -P )" 4 | EMAIL= 5 | DOMAIN= 6 | FORCE= 7 | 8 | while getopts "m:d:f" opt; do 9 | case $opt in 10 | m) 11 | EMAIL=${OPTARG} 12 | ;; 13 | d) 14 | DOMAIN=${OPTARG} 15 | ;; 16 | f) 17 | FORCE=1 18 | ;; 19 | \?) 20 | echo "Invalid option: -$OPTARG" 21 | exit 1 22 | ;; 23 | esac 24 | done 25 | 26 | if [ -z "${DOMAIN}" ]; then 27 | echo "Option:-d is necessary." 28 | exit 1 29 | fi 30 | 31 | cmd="certbot certonly" 32 | if [ -n "${EMAIL}" ]; then 33 | cmd="${cmd} --email ${EMAIL} " 34 | fi 35 | 36 | if [ -n "${FORCE}" ]; then 37 | cmd="${cmd} --force-renewal " 38 | fi 39 | 40 | cmd="${cmd} --agree-tos --preferred-challenges dns --server https://acme-v02.api.letsencrypt.org/directory --manual --manual-auth-hook 'python $DIR/manual-hook.py --auth' --manual-cleanup-hook 'python $DIR/manual-hook.py --cleanup' -d ${DOMAIN} -d *.${DOMAIN}" 41 | 42 | eval ${cmd} 43 | -------------------------------------------------------------------------------- /manual-hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | import getopt 6 | import time 7 | if sys.version_info < (3,0): 8 | import ConfigParser 9 | else: 10 | import configparser as ConfigParser 11 | import aliyun 12 | import logging 13 | import logging.handlers 14 | 15 | # Set the global configuration 16 | CONFIG_FILENAME = 'config.ini' 17 | configFilepath = os.path.split(os.path.realpath(__file__))[0] + os.path.sep + CONFIG_FILENAME 18 | config = ConfigParser.ConfigParser() 19 | config.read(configFilepath) 20 | 21 | logger = logging.getLogger("logger") 22 | formatter = logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") 23 | consoleHandler = logging.StreamHandler(stream=sys.stdout) 24 | logger.setLevel(logging.DEBUG) 25 | consoleHandler.setLevel(logging.DEBUG) 26 | consoleHandler.setFormatter(formatter) 27 | logger.addHandler(consoleHandler) 28 | 29 | fileLogFlag = True if config.get('log','enable').lower() == 'true' else False 30 | if fileLogFlag: 31 | logfile = config.get('log','logfile') 32 | fileHandler = logging.FileHandler(filename=logfile) 33 | fileHandler.setLevel(logging.DEBUG) 34 | fileHandler.setFormatter(formatter) 35 | logger.addHandler(fileHandler) 36 | 37 | 38 | def getAliyunDnsInstance(): 39 | appid = config.get('aliyun', 'appid') 40 | appsecret = config.get('aliyun', 'appsecret') 41 | return aliyun.AliyunDns(appid, appsecret) 42 | 43 | 44 | def auth(): 45 | try: 46 | if 'CERTBOT_DOMAIN' not in os.environ: 47 | raise Exception('Environment variable CERTBOT_DOMAIN is empty.') 48 | if 'CERTBOT_VALIDATION' not in os.environ: 49 | raise Exception('Environment variable CERTBOT_VALIDATION is empty.') 50 | 51 | domain = os.environ['CERTBOT_DOMAIN'] 52 | value = os.environ['CERTBOT_VALIDATION'] 53 | 54 | logger.info('Start setting DNS') 55 | logger.info('Domain:' + domain) 56 | logger.info('Value:' + value) 57 | 58 | aliyunDns = getAliyunDnsInstance() 59 | # aliyunDns.toString() 60 | 61 | # add letsencrypt domain record 62 | aliyunDns.addLetsencryptDomainRecord(domain, value) 63 | 64 | # wait for completion 65 | logger.info('sleep 10 secs') 66 | time.sleep(10) 67 | 68 | logger.info('Success.') 69 | logger.info('DNS setting end!') 70 | 71 | except Exception as e: 72 | logger.error(str(e)) 73 | sys.exit() 74 | 75 | 76 | def cleanup(): 77 | try: 78 | if 'CERTBOT_DOMAIN' not in os.environ: 79 | raise Exception('Environment variable CERTBOT_DOMAIN is empty.') 80 | 81 | domain = os.environ['CERTBOT_DOMAIN'] 82 | logger.info('Start to clean up') 83 | logger.info('Domain:' + domain) 84 | aliyunDns = getAliyunDnsInstance() 85 | # aliyunDns.toString() 86 | 87 | # delete letsencrypt domain record 88 | aliyunDns.deleteLetsencryptDomainRecord(domain) 89 | 90 | # wait for completion 91 | time.sleep(10) 92 | 93 | logger.info('Success.') 94 | logger.info('Clean up end!') 95 | 96 | except Exception as e: 97 | logger.error(str(e)) 98 | sys.exit() 99 | 100 | 101 | def usage(): 102 | def printOpt(opt, desc): 103 | firstPartMaxLen = 30 104 | 105 | firstPart = ' ' + ', '.join(opt) 106 | secondPart = desc.replace('\n', '\n' + ' ' * firstPartMaxLen) 107 | 108 | delim = '' 109 | firstPartLen = len(firstPart) 110 | if firstPartLen >= firstPartMaxLen: 111 | spaceLen = firstPartMaxLen 112 | delim = '\n' 113 | else: 114 | spaceLen = firstPartMaxLen - firstPartLen 115 | 116 | delim = delim + ' ' * spaceLen 117 | print(firstPart + delim + secondPart) 118 | 119 | print('Usage: python %s [option] [arg] ...' % os.path.basename(__file__)) 120 | print('Options:') 121 | printOpt(['-h', '--help'], 122 | 'Display help information.') 123 | printOpt(['-v', '--version'], 124 | 'Display version information.') 125 | printOpt(['--auth'], 126 | 'auth hook.') 127 | printOpt(['--cleanup'], 128 | 'auth hook.') 129 | 130 | 131 | def version(): 132 | print('dmlkdevtool.py ' + __version__) 133 | print(__copyright__) 134 | print('License ' + __license__ + '.') 135 | print('Written by ' + __author__ + '.') 136 | 137 | 138 | def main(argc, argv): 139 | try: 140 | if(argc == 1): 141 | usage() 142 | raise Exception('') 143 | 144 | opts, args = getopt.getopt( 145 | argv[1:], 146 | 'hv', 147 | [ 148 | 'help', 149 | 'version', 150 | 'auth', 151 | 'cleanup', 152 | ] 153 | ) 154 | 155 | for opt, arg in opts: 156 | if opt in ('-h', '--help'): 157 | usage() 158 | elif opt in ('-v', '--version'): 159 | version() 160 | elif opt in ('--auth'): 161 | auth() 162 | elif opt in ('--cleanup'): 163 | cleanup() 164 | else: 165 | logger.error('Invalid option: ' + opt) 166 | 167 | except getopt.GetoptError as e: 168 | logger.error(str(e)) 169 | except AttributeError as e: 170 | logger.error(e.args) 171 | except Exception as e: 172 | logger.error(str(e)) 173 | 174 | sys.exit() 175 | 176 | 177 | if __name__ == '__main__': 178 | main(len(sys.argv), sys.argv) 179 | --------------------------------------------------------------------------------