├── .gitignore ├── .idea ├── vcs.xml ├── .gitignore ├── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml ├── misc.xml ├── modules.xml └── migrate_upyun_certificate.iml ├── README.md └── migrate_upyun_certificate.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/migrate_upyun_certificate.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # migrate_upyun_certificate 2 | 3 | ## 脚本介绍 4 | 5 | 又拍最新推出的 Let's encrypt 通配符证书自动续签需要 999 元, 对于个人建站使用CDN加速希望使用通配符证书,无疑是比较贵的。 6 | 7 | ![](https://file.awen.me/blog/20201027131046.png) 8 | 9 | 但是我又希望只签发一个通配符证书,这样可以配置多个二级域名进行 CDN 加速,好在又拍云除了提供收费自动续签通配符证书的方式,还提供了自动上传证书的方式。 10 | 不过这种方式有一个缺陷,那就是由于我使用的 Let's encrypt 证书需要 2 个月续签一次, 不过好在 Let's encrypt 支持自动续签. 11 | 12 | 通过 [acme.sh](https://github.com/acmesh-official/acme.sh/wiki/%E8%AF%B4%E6%98%8E) 这个 Shell 脚本配置 DNS API 的方式进行自动续签证书。 13 | 14 | 这样证书会在 60 天以后会自动更新, 无需任何操作. 整个过程都是自动的, 用户不需要关心。 15 | 16 | 那么有了这个功能之后,要解决的就是我将自动续签完的证书内容上传到又拍云并替换掉旧的证书就可以了。 17 | 18 | 本脚本可以实现将你将本地或服务器上签发的 Let's encrypt 通配符证书, 通过配置 Linux 计划任务的方式定期上传到又拍云并自动进行迁移后删除旧的证书。 19 | 20 | ## 使用说明 21 | 22 | 1.首先,假设你已经通过acme.sh 使用 DNS API 的方式申请好了通配符证书。 23 | 24 | 2.执行 25 | ``` 26 | git clone git@github.com:monkey-wenjun/migrate_upyun_certificate.git 27 | ``` 28 | 29 | 30 | 3.打开 migrate_upyun_certificate.py ,找到如下配置 31 | 32 | ``` 33 | ############# 配置信息段 ############# 34 | username = "upyun" # 又拍云用户名,必填 35 | password = "upyun" # 又拍云密码,必填 36 | check_domain = "awen.me" # 要配置的证书,避免误删其他过期证书,必填 37 | certificate_path = "/usr/local/nginx/conf/ssl/awen.me/fullchain.cer" # 证书公钥,建议配置待根证书的证书内容,否则在部分浏览器可能会出现问题。必填 38 | private_key_path = "/usr/local/nginx/conf/ssl/awen.me/awen.me.key" # 证书私钥,必填 39 | domain_conf_path = "/usr/local/nginx/conf/ssl/awen.me/awen.me.conf" # 证书更新配置文件,用户读取下一次更新时间 40 | ############# 配置信息结束 ############# 41 | ``` 42 | 43 | 假设你的证书在/usr/local/nginx/conf/ssl/ 44 | 45 | 配置又拍云的登录用户名和密码 46 | 配置 certificate_path 和 private_key_path 为你的证书公钥和私钥路径; 47 | 配置 check_domain 为要替换的通配符域名,此选项可以在替换完成证书后,帮助你删除又拍云中已经过期的旧证书; 48 | 配置 domain_conf_path, 该选项会读取 acme.sh 生成的证书配置文件,获取 Le_CertCreateTime 字段,该字段为最近一次更新证书的时间,脚本会在该时间点左右进行自动更新证书; 49 | 该文件内容如下 50 | 51 | ``` 52 | [root@VM-0-12-centos ~]# cat /usr/local/nginx/conf/ssl/awen.me/awen.me.conf 53 | Le_Domain='awen.me' 54 | Le_Alt='*.awen.me' 55 | Le_Webroot='dns_cf' 56 | Le_PreHook='' 57 | Le_PostHook='' 58 | Le_RenewHook='' 59 | Le_API='https://acme-v02.api.letsencrypt.org/directory' 60 | Le_Keylength='' 61 | Le_OrderFinalize='https://acme-v02.api.letsencrypt.org/acme/finalize/XX/XXX' 62 | Le_LinkOrder='https://acme-v02.api.letsencrypt.org/acme/order/XX/XXX' 63 | Le_LinkCert='https://acme-v02.api.letsencrypt.org/acme/cert/XXXX' 64 | Le_CertCreateTime='1603763751' 65 | Le_CertCreateTimeStr='2020年 10月 27日 星期二 01:55:51 UTC' 66 | Le_NextRenewTimeStr='2020年 12月 26日 星期六 01:55:51 UTC' 67 | Le_NextRenewTime='1608861351' 68 | 69 | ``` 70 | 71 | 4.在本地或 Linux 配置计划任务,每天执行一次这个脚本 72 | 73 | ``` 74 | 30 03 * * * /usr/bin/python3 /opt/migrate_upyun_certificate/migrate_upyun_certificate.py 75 | ``` 76 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 59 | -------------------------------------------------------------------------------- /migrate_upyun_certificate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*-coding:utf-8-*- 3 | import logging 4 | import sys 5 | 6 | from requests import Session 7 | from time import time, sleep 8 | 9 | """ 10 | @ author:wenjun 11 | @ E-mail: hi@awen.me 12 | @ datetime:2020/10/27 6:34 上午 13 | @ software: PyCharm 14 | @ filename: migrate_upyun_certificate.py 15 | """ 16 | 17 | 18 | class MigrateUpyunCertificate: 19 | 20 | def __init__(self): 21 | self.session = Session() 22 | self.logger = logging.getLogger("upyun") 23 | formatter = logging.Formatter('%(asctime)s %(levelname)-8s: %(message)s') 24 | # 文件日志 25 | file_handler = logging.FileHandler("upyun.log") 26 | file_handler.setFormatter(formatter) # 可以通过setFormatter指定输出格式 27 | # 控制台日志 28 | console_handler = logging.StreamHandler(sys.stdout) 29 | console_handler.formatter = formatter # 也可以直接给formatter赋值 30 | # 为logger添加的日志处理器 31 | self.logger.addHandler(file_handler) 32 | self.logger.addHandler(console_handler) 33 | # 指定日志的最低输出级别,默认为WARN级别 34 | self.logger.setLevel(logging.INFO) 35 | 36 | def login(self, username, password): 37 | """ 38 | 登录又拍云 39 | :param username: 40 | :param password: 41 | :return: 42 | """ 43 | url = "https://console.upyun.com/accounts/signin/" 44 | 45 | payload = {"username": username, "password": password} 46 | headers = { 47 | 'Accept': "application/json, text/plain, */*", 48 | 'Accept-Encoding': "gzip, deflate, br", 49 | 'Accept-Language': "zh-CN,zh;q=0.9,en;q=0.8", 50 | 'Cache-Control': "no-cache", 51 | 'Connection': "keep-alive", 52 | 'Content-Type': "application/json", 53 | 'DNT': "1", 54 | 'Host': "console.upyun.com", 55 | 'Origin': "https://console.upyun.com", 56 | 'Pragma': "no-cache", 57 | 'Referer': "https://console.upyun.com/login/", 58 | 'User-Agent': "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) " 59 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36", 60 | 'cache-control': "no-cache", 61 | 'Postman-Token': "2d9bd080-b549-4c41-89ce-0b011f344a3f" 62 | } 63 | 64 | response = self.session.post(url, json=payload, headers=headers) 65 | 66 | if response.status_code == 200: 67 | return response.cookies 68 | 69 | def get_ssl_list(self): 70 | """ 71 | 获取SSL 列表 72 | :return: 73 | """ 74 | url = "https://console.upyun.com/api/https/certificate/list/?limit=10" 75 | headers = { 76 | 'Accept': 'application/json, text/plain, */*', 77 | 'Accept-Encoding': 'gzip, deflate, br', 78 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 79 | 'Cache-Control': 'no-cache', 80 | 'Connection': 'keep-alive', 81 | 'DNT': '1', 82 | 'Host': 'console.upyun.com', 83 | 'Pragma': 'no-cache', 84 | 'Referer': 'https://console.upyun.com/toolbox/ssl/', 85 | 'Sec-Fetch-Dest': 'empty', 86 | 'Sec-Fetch-Mode': 'cors', 87 | 'Sec-Fetch-Site': 'ame-origin', 88 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' 89 | '(KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36' 90 | } 91 | 92 | response = self.session.get(url, headers=headers) 93 | if response.status_code == 200: 94 | resp_json = response.json() 95 | result = resp_json["data"]["result"] 96 | return result 97 | 98 | @staticmethod 99 | def format_result_info(result_json, check_domain): 100 | """ 101 | 格式化输出cer_id 以及到期时间等信息 102 | :param check_domain: 103 | :param result_json: 104 | :return: 105 | """ 106 | cer_list = [] 107 | for key, value in result_json.items(): 108 | cer_id = key 109 | if len(key) != 32: 110 | continue 111 | common_name = value["commonName"] 112 | validity_end = value["validity"]["end"] 113 | if common_name != check_domain: 114 | continue 115 | data = { 116 | "cer_id": cer_id, 117 | "domain": common_name, 118 | "validity_end": validity_end 119 | } 120 | cer_list.append(data) 121 | return cer_list 122 | 123 | def upload_cerfile(self, certificate_path, private_key_path): 124 | """ 125 | 上传证书 126 | :param private_key_path: 127 | :param certificate_path: 128 | :return: 129 | """ 130 | with open(certificate_path) as f: 131 | certificate_str = f.read() 132 | with open(private_key_path) as k: 133 | private_key_str = k.read() 134 | url = "https://console.upyun.com/api/https/certificate/" 135 | 136 | payload = {"certificate": certificate_str.strip("\n"), 137 | "private_key": private_key_str.strip("\n") 138 | } 139 | headers = { 140 | 141 | 'Accept': 'application/json, text/plain, */*', 142 | 'Accept-Encoding': 'zip, deflate, br', 143 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 144 | 'Cache-Control': 'no-cache', 145 | 'Connection': 'keep-alive', 146 | 'content-type': 'application/json', 147 | 'DNT': '1', 148 | 'Host': 'console.upyun.com', 149 | 'Origin': 'https://console.upyun.com', 150 | 'Pragma': 'no-cache', 151 | 'Referer': 'https://console.upyun.com/toolbox/ssl/', 152 | 'Sec-Fetch-Dest': 'empty', 153 | 'Sec-Fetch-Mode': 'cors', 154 | 'Sec-Fetch-Site': 'same-origin', 155 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' 156 | '(KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36' 157 | } 158 | response = self.session.post(url, headers=headers, json=payload) 159 | if response.status_code == 200: 160 | resp_json = response.json() 161 | common_name = resp_json["data"]["result"]["commonName"] 162 | certificate_id = resp_json["data"]["result"]["certificate_id"] 163 | validity_start = resp_json["data"]["result"]["validity"]["start"] 164 | validity_end = resp_json["data"]["result"]["validity"]["end"] 165 | data = { 166 | "common_name": common_name, 167 | "certificate_id": certificate_id, 168 | "validity_start": validity_start, 169 | "validity_end": validity_end 170 | 171 | } 172 | return data 173 | 174 | def migrate_certificate(self, new_cer_id, old_cer_id): 175 | """ 176 | 证书迁移 177 | :param new_cer_id: 178 | :param old_cer_id: 179 | :return: 180 | """ 181 | url = "https://console.upyun.com/api/https/migrate/certificate" 182 | 183 | payload = {"old_crt_id": old_cer_id, "new_crt_id": new_cer_id} 184 | headers = { 185 | 'Accept': 'application/json, text/plain, */*', 186 | 'Accept-Encoding': 'gzip, deflate, br', 187 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 188 | 'Cache-Control': 'no-cache', 189 | 'Connection': 'keep-alive', 190 | 'content-type': 'application/json', 191 | 'DNT': '1', 192 | 'Host': 'console.upyun.com', 193 | 'Origin': 'https://console.upyun.com', 194 | 'Pragma': 'no-cache', 195 | 'Referer': 'https://console.upyun.com/toolbox/ssl/', 196 | 'Sec-Fetch-Dest': 'empty', 197 | 'Sec-Fetch-Mode': 'cors', 198 | 'Sec-Fetch-Site': 'same-origin', 199 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' 200 | '(KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36' 201 | } 202 | 203 | response = self.session.post(url, headers=headers, json=payload) 204 | if response.status_code != 200: 205 | return False 206 | resp_json = response.json() 207 | if resp_json["data"]["result"]: 208 | result_data = resp_json["data"]["result"] 209 | self.logger.info(f"证书迁移结果 {result_data}") 210 | return True 211 | 212 | def delete_certificate(self, certificate_id, by_time): 213 | 214 | """ 215 | 删除旧的证书 216 | :param by_time: 217 | :param certificate_id: 218 | :return: 219 | """ 220 | if by_time >= 1: 221 | self.logger.info("当前证书大于 2天,无需删除") 222 | return 223 | self.logger.info("小于 1 天,开始删除旧证书") 224 | url = "https://console.upyun.com/api/https/certificate/?certificate_id={}".format(certificate_id) 225 | headers = { 226 | 'Accept': 'application/json, text/plain, */*', 227 | 'Accept-Encoding': 'gzip, deflate, br', 228 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 229 | 'Cache-Control': 'no-cache', 230 | 'Connection': 'keep-alive', 231 | 'DNT': '1', 232 | 'Host': 'console.upyun.com', 233 | 'Origin': 'https://console.upyun.com', 234 | 'Pragma': 'no-cache', 235 | 'Referer': 'https://console.upyun.com/toolbox/ssl/', 236 | 'Sec-Fetch-Dest': 'empty', 237 | 'Sec-Fetch-Mode': 'cors', 238 | 'Sec-Fetch-Site': 'same-origin', 239 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) ' 240 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36' 241 | } 242 | 243 | response = self.session.delete(url, headers=headers) 244 | if response.status_code != 200: 245 | self.logger.info("证书删除失败,连接异常") 246 | resp_json = response.json() 247 | try: 248 | if resp_json["data"]["status"]: 249 | self.logger.info("证书删除成功") 250 | except KeyError: 251 | self.logger.info("证书删除失败") 252 | 253 | @staticmethod 254 | def read_acme_conf(conf_path, unix_time): 255 | with open(conf_path) as f: 256 | for i in (f.readlines()): 257 | key = i.strip().split("=") 258 | if key[0] == "Le_NextRenewTime": 259 | le_next_renew_time = str(key[1]).replace("'", "") 260 | return int(int(le_next_renew_time) - unix_time) / 60 / 60 / 24 261 | 262 | def main(self): 263 | 264 | """############# 配置信息段 #############""" 265 | username = "fangwenjun" # 又拍云用户名,必填 266 | password = "XXXX" # 又拍云密码,必填 267 | check_domain = "awen.me" # 要配置的证书,避免误删其他过期证书,必填 268 | certificate_path = "/Users/wenjun/.acme.sh/awen.me/fullchain.cer" # 证书公钥,建议配置待根证书的证书内容,否则在部分浏览器可能会出现问题。必填 269 | private_key_path = "/Users/wenjun/.acme.sh/awen.me/awen.me.key" # 证书私钥,必填 270 | domain_conf_path = "/Users/wenjun/.acme.sh/awen.me/awen.me.conf" # 证书更新配置文件,用户读取下一次更新时间 271 | """############# 配置信息段 #############""" 272 | unix_time = int(time()) 273 | update_time = int(self.read_acme_conf(domain_conf_path, unix_time)) 274 | self.logger.info("距离下一次更新还有 {} 天".format(update_time)) 275 | if update_time >= 1: 276 | self.logger.info("还早,不要这么急嘛!") 277 | return 278 | self.logger.info("开始登录又拍云") 279 | self.login(username=username, password=password) 280 | self.logger.info("开始获取证书列表") 281 | result_json = self.get_ssl_list() 282 | cer_list = self.format_result_info(result_json, check_domain) 283 | old_cer_id = cer_list[-1]["cer_id"] 284 | validity_end = cer_list[-1]["validity_end"] 285 | now_unix = unix_time * 1000 286 | by_time = int((int(validity_end) - now_unix) / 1000 / 60 / 60 / 24) 287 | self.logger.info(f"当前时间 {now_unix}, 证书截止时间 {validity_end} 剩余时间 {by_time} 天") 288 | if by_time >= 1: 289 | self.logger.info("当前证书大于 2天,无需操作") 290 | return 291 | self.logger.info("开始读取本地证书公钥和私钥信息") 292 | self.logger.info("开始上传证书") 293 | upload_cerfile_info = self.upload_cerfile(certificate_path=certificate_path, 294 | private_key_path=private_key_path) 295 | new_cer_id = upload_cerfile_info["certificate_id"] 296 | self.logger.info("开始迁移证书") 297 | self.migrate_certificate(new_cer_id=new_cer_id, old_cer_id=old_cer_id) 298 | sleep(5) 299 | self.logger.info(f"开始删除已过期的 {check_domain} 证书,sold_cer_id {old_cer_id},by_time {by_time}") 300 | self.delete_certificate(old_cer_id, by_time) 301 | 302 | 303 | if __name__ == '__main__': 304 | migrate_upyun_certificate = MigrateUpyunCertificate() 305 | migrate_upyun_certificate.main() 306 | --------------------------------------------------------------------------------