├── README.md ├── acme.py ├── acmeAuto.sh └── acmeRenew.sh /README.md: -------------------------------------------------------------------------------- 1 | # ACME 2 | 3 | ## 安装依赖 4 | ``` 5 | apt install -y python3-pip python3-cryptography python3-aiohttp 6 | pip3 install aiohttp_socks 7 | 8 | ``` 9 | 10 | ## acme.py 11 | ``` 12 | # 签发新证书 13 | python3 acme.py -d "xxx.com,*.xxx.com" 14 | python3 acme.py -d "sub.xxx.com,*.sub.xxx.com" -v dns -s google -sub "xxx.com" -ecc 15 | 16 | # 注册授权 17 | python3 acme.py -register -s google -mail "xyz@abc.com" -kid "" -key "" 18 | 19 | # 华为DNS自动验证, 否则手动 DNS 验证. 也可自行修改源码. 20 | # 修改 acme.py 文件第 587 行最后的部分 **{"key": None, "secret": None} 21 | # 将两个 None 换成你自己的华为DNS密钥即可自动验证DNS. 22 | ``` 23 | 24 | ## acmeAuto.sh 25 | ``` 26 | 示例 27 | bash acmeAuto.sh 28 | 29 | # 调用 acme.py 签发脚本内指定域名的证书. 30 | # 如果有 GTS 授权, 优先签发 GTS 证书. 否则签发匿名的 letsencrypt 证书. 31 | # 域名采用数组模式. 一行一组域名, 域名间用逗号分隔. 32 | # 如果要签发的是子域名, 请在最后添加主域名并用分号分隔. 33 | 34 | bash acmeAuto.sh 1 35 | # 最后参数为 1 表示利用系统 cron 实现自动运行(默认为每个星期一凌晨三点三分) 36 | ``` 37 | 38 | ## acmeRenew.sh 39 | ``` 40 | # bash acmeRenew.sh <域名> <服务:证书路径> <证书下载服务器> <是否启用cron定时更新> 41 | 42 | 示例: 43 | bash acmeRenew.sh moeclub.org nginx:/etc/nginx http://xxx.abc.com 1 44 | 45 | # 从地址 http://xxx.abc.com/moeclub.org/server.crt.pem 下载证书 46 | # 从地址 http://xxx.abc.com/moeclub.org/server.key.pem 下载密钥 47 | # 将下载的证书和密钥储存至 /etc/nginx 目录内, 并重启 nginx 服务. 48 | # 当服务器储存证书和本地一致时只下载为临时文件, 不替换证书不重启服务. 49 | # 最后参数为 1 表示启动cron定时更新, 默认为每个星期一凌晨四点四分执行更新检查. 50 | ``` 51 | 52 | ## GTS 授权 (Google Trust Services LLC) 53 | ``` 54 | # 登录谷歌账户, 打开网址点击 Enable 按钮 (无需GCP账号,只需要能够访问谷歌的账户创建项目) 55 | https://console.cloud.google.com/apis/library/publicca.googleapis.com 56 | # 点开右上角的命令图标打开 Cloud Shell, 输入创建密钥命令 57 | gcloud publicca external-account-keys create 58 | # 使用 acme.py 进行授权后即可签发 GTS 证书 59 | python3 acme.py -register -s google -mail "xyz@abc.com" -kid "" -key "" 60 | 61 | 62 | # 选择GCP项目<可选> 63 | gcloud config set project 64 | # 打开API权限<可选> 65 | gcloud services enable publicca.googleapis.com 66 | 67 | ``` 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /acme.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- encoding: utf-8 -*- 3 | # Author: MoeClub.org 4 | 5 | # apt-get install -y python3-pip python3-cryptography 6 | # pip3 install aiohttp aiohttp_socks 7 | from aiohttp import client 8 | from aiohttp_socks import ProxyConnector, ProxyType 9 | from urllib import parse 10 | import collections 11 | import binascii 12 | import base64 13 | import asyncio 14 | import hashlib 15 | import hmac 16 | import json 17 | import time 18 | import os 19 | 20 | 21 | from cryptography import x509 22 | from cryptography.hazmat.backends import default_backend 23 | from cryptography.hazmat.primitives import serialization, hashes, hmac 24 | from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding 25 | from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature 26 | 27 | 28 | class DNS: 29 | @staticmethod 30 | def HUAWEI(name, sub: list, order: list, ttl=15, **kwargs): 31 | auth = None 32 | if "key" in kwargs and kwargs["key"] is not None and "secret" in kwargs and kwargs["secret"] is not None: 33 | auth = "key={}&secret={}".format(kwargs["key"], kwargs["secret"]) 34 | if "token" in kwargs and kwargs["token"] is not None: 35 | auth = "token={}".format(kwargs["token"]) 36 | if auth is None or auth == "": 37 | return [] 38 | result = {} 39 | for item in order: 40 | if "domain" not in item or "txt" not in item: 41 | continue 42 | if not str(item["domain"]).startswith(name): 43 | continue 44 | domain = str(item["domain"]).replace(name, "", 1).strip(".") 45 | n = name 46 | for d in sub: 47 | if str(domain).endswith(d): 48 | domain = d 49 | n = str(item["domain"]).replace(d, "", -1).strip(".") 50 | break 51 | n_domain = str("{}_{}").format(n, domain) 52 | if n_domain not in result: 53 | result[n_domain] = {"domain": domain, "name": n, "txt": []} 54 | result[n_domain]["txt"].append(str('"{}"').format(item["txt"])) 55 | urls = [] 56 | _urls = [] 57 | for n_domain in result: 58 | data = base64.urlsafe_b64encode(json.dumps(result[n_domain]["txt"], separators=(",", ":"), ensure_ascii=False).encode()).decode() 59 | url = "https://api.moeclub.org/HWDNS?{}&action=add&target=record&domain={}&name={}&data={}&type={}&ttl={}".format(auth, result[n_domain]["domain"], result[n_domain]["name"], data, "TXT_BASE64", ttl) 60 | urls.append(url) 61 | _url = "https://api.moeclub.org/HWDNS?{}&action=del&target=record&domain={}&name={}&type={}".format(auth, result[n_domain]["domain"], result[n_domain]["name"], "TXT") 62 | _urls.append(_url) 63 | return urls, _urls 64 | 65 | 66 | class ACME: 67 | ServerList = { 68 | "google": "https://dv.acme-v02.api.pki.goog/directory", 69 | "letsencrypt": "https://acme-v02.api.letsencrypt.org/directory", 70 | } 71 | 72 | Root = os.path.dirname(os.path.abspath(__file__)) 73 | DNSHostValue = "_acme-challenge" 74 | Server = None 75 | Nonce = None 76 | AccountURL = None 77 | OrderURL = None 78 | OrderDetails = None 79 | PrivKey = None 80 | PrivateKeyPath = None 81 | CrtKey = None 82 | Crt = None 83 | JWKHash = None 84 | Challenges = list() 85 | DOMAIN = list() 86 | SUBDOMAIN = list() 87 | KWARGS = dict() 88 | 89 | def __init__(self, domain: (str, list), sub="", verify="dns", server="letsencrypt", rootData="acme", privKeyPath=None, privateKeyName="acme.key", ecc=True, proxy=None, **kwargs): 90 | self.KWARGS = kwargs 91 | self.Proxy = proxy 92 | self.server = server 93 | self.VerifyType = verify 94 | self.privKeyPath = privKeyPath 95 | self.PrivateKey = privateKeyName 96 | self.RootData = rootData 97 | self.ECC = True if ecc is True else False 98 | if domain is not None: 99 | if isinstance(domain, str): 100 | domain = str(domain).split(",") 101 | self.DOMAIN = [str(item).strip().strip(".").lower() for item in domain if "." in item] 102 | if isinstance(sub, str): 103 | sub = str(sub).split(",") 104 | self.SUBDOMAIN = [str(item).strip().strip(".").lower() for item in sub if "." in item] 105 | 106 | async def HTTP(self, method, url, headers=None, cookies=None, data=None, redirect=True, Proxy=None, timeout=30, loop=None): 107 | method = str(method).strip().upper() 108 | if method not in ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"]: 109 | raise Exception(str("HTTP Method Not Allowed [{}].").format(method)) 110 | if headers: 111 | Headers = {str(key).strip(): str(value).strip() for (key, value) in headers.items()} 112 | else: 113 | Headers = {"User-Agent": "Mozilla/5.0", "Accept-Encoding": ""} 114 | respData = {"code": None, "data": None, "headers": None, "cookies": None, "url": None, "req": None, "err": None} 115 | resp = None 116 | Connector = client.TCPConnector(ssl=False, force_close=True, enable_cleanup_closed=True, use_dns_cache=False) 117 | if Proxy is not None or str(Proxy).strip() != "": 118 | proxyParsed = parse.urlparse(str(Proxy).strip()) 119 | if proxyParsed.scheme in ["socks5"]: 120 | proxyType = ProxyType.SOCKS5 121 | elif proxyParsed.scheme in ["socks4"]: 122 | proxyType = ProxyType.SOCKS4 123 | elif proxyParsed.scheme in ["http", "https"]: 124 | proxyType = ProxyType.HTTP 125 | else: 126 | proxyType = None 127 | if proxyType is not None: 128 | try: 129 | username, password = (parse.unquote(proxyParsed.username), parse.unquote(proxyParsed.password)) 130 | except: 131 | username, password = ('', '') 132 | Connector = ProxyConnector(proxy_type=proxyType, host=proxyParsed.hostname, port=proxyParsed.port, username=username, password=password, rdns=None, ssl=False, force_close=True, enable_cleanup_closed=True, use_dns_cache=False) 133 | try: 134 | async with client.request(method=method, url=url, headers=Headers, cookies=cookies, data=data, timeout=client.ClientTimeout(total=timeout), allow_redirects=redirect, raise_for_status=False, connector=Connector, loop=loop) as resp: 135 | respData["data"] = await resp.read() 136 | respData["code"] = resp.status 137 | respData["headers"] = resp.headers 138 | respData["cookies"] = resp.cookies 139 | # respData["url"] = resp.url 140 | # respData["req"] = resp.request_info 141 | except Exception as e: 142 | if respData["code"] is None: 143 | respData["code"] = 555 144 | if respData["data"] is None: 145 | respData["data"] = b"" 146 | respData["err"] = str(e) 147 | if Connector is not None: 148 | if Connector.closed is False: 149 | await Connector.close() 150 | if resp is not None: 151 | resp.close() 152 | del resp, Connector, headers, method, url, Proxy 153 | return respData 154 | 155 | async def SEND(self, url, protected, payload): 156 | body = dict() 157 | body["protected"] = protected 158 | body["payload"] = payload 159 | body["signature"] = self.Sign(key=self.PrivKey, data=str("{}.{}").format(body["protected"], body["payload"]).encode()) 160 | resp = await self.HTTP("POST", url=url, headers=self.Headers(), data=json.dumps(body, separators=(',', ':'), ensure_ascii=False), redirect=False, Proxy=self.Proxy) 161 | if resp["code"] in [200, 201]: 162 | if "Replay-Nonce" in resp["headers"]: 163 | self.Nonce = resp["headers"]["Replay-Nonce"] 164 | return resp 165 | 166 | def ReadFile(self, f): 167 | if f is None: 168 | return None 169 | if os.path.sep not in f: 170 | f = os.path.join(self.Root, f) 171 | if not os.path.exists(f): 172 | return None 173 | fd = open(f, mode="r", encoding="utf-8") 174 | data = fd.read() 175 | fd.close() 176 | return data 177 | 178 | def WriteFile(self, f, d: (str, bytes), o=True): 179 | if os.path.sep not in f: 180 | f = os.path.join(self.Root, f) 181 | if o is False and os.path.exists(f): 182 | return True 183 | fdir = os.path.dirname(f) 184 | if not os.path.exists(fdir): 185 | os.makedirs(fdir) 186 | if isinstance(d, bytes): 187 | fd = open(f, mode="wb") 188 | else: 189 | fd = open(f, mode="w", encoding="utf-8") 190 | fd.write(d) 191 | fd.close() 192 | return True 193 | 194 | def Headers(self): 195 | return { 196 | "User-Agent": "ACME/1.0", 197 | "Accept-Encoding": "", 198 | "Accept-Language": "en", 199 | "Content-Type": "application/jose+json", 200 | } 201 | 202 | def SignHMAC(self, key: bytes, data: bytes, alg=hashes.SHA256()): 203 | h = hmac.HMAC(key, alg) 204 | h.update(data) 205 | return base64.urlsafe_b64encode(h.finalize()).decode().rstrip("=") 206 | 207 | def EncodeInt(self, i, bitSize=None): 208 | extend = 0 209 | if bitSize is not None: 210 | extend = ((bitSize + 7) // 8) * 2 211 | hi = hex(i).rstrip("L").lstrip("0x") 212 | hl = len(hi) 213 | if extend > hl: 214 | extend -= hl 215 | else: 216 | extend = hl % 2 217 | return binascii.unhexlify(extend * '0' + hi) 218 | 219 | def Sign(self, key, data, alg=hashes.SHA256()): 220 | if isinstance(key, (str, bytes)): 221 | privKey = serialization.load_pem_private_key(key if isinstance(key, bytes) else str(key).encode("utf-8"), password=None, backend=default_backend()) 222 | else: 223 | privKey = key 224 | if hasattr(privKey, "curve"): 225 | signature = privKey.sign(data=data, signature_algorithm=ec.ECDSA(alg)) 226 | r, s = decode_dss_signature(signature) 227 | signature = self.EncodeInt(r, privKey.curve.key_size) + self.EncodeInt(s, privKey.curve.key_size) 228 | else: 229 | signature = privKey.sign(data=data, padding=padding.PKCS1v15(), algorithm=alg) 230 | return base64.urlsafe_b64encode(signature).decode().rstrip("=") 231 | 232 | def JWK(self, privKey=None, ECC=True): 233 | if privKey is None: 234 | if ECC is True: 235 | privKey = ec.generate_private_key(curve=ec.SECP256R1(), backend=default_backend()) 236 | else: 237 | privKey = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) 238 | else: 239 | privKey = serialization.load_pem_private_key(data=privKey if isinstance(privKey, bytes) else str(privKey).encode(), password=None, backend=default_backend()) 240 | privateKey = privKey.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption()).decode("utf-8").strip() 241 | pubKey = privKey.public_key() 242 | pubNum = pubKey.public_numbers() 243 | jwk = collections.OrderedDict() 244 | if hasattr(privKey, "curve"): 245 | jwk["crv"] = str("P-{}").format(512 if pubKey.curve.key_size == 521 else pubKey.curve.key_size) 246 | jwk["kty"] = 'EC' 247 | jwk["x"] = self.B64Encode(self.EncodeInt(pubNum.x, pubKey.curve.key_size)) 248 | jwk["y"] = self.B64Encode(self.EncodeInt(pubNum.y, pubKey.curve.key_size)) 249 | else: 250 | jwk["e"] = self.B64Encode(self.EncodeInt(pubNum.e, None)) 251 | jwk["kty"] = 'RSA' 252 | jwk["n"] = self.B64Encode(self.EncodeInt(pubNum.n, None)) 253 | jwkSHA256 = self.B64Encode(hashlib.sha256(json.dumps(jwk, separators=(",", ":"), ensure_ascii=False).encode()).digest()) 254 | return privateKey, privKey, jwk, jwkSHA256 255 | 256 | def B64Encode(self, s): 257 | return base64.urlsafe_b64encode(s if isinstance(s, bytes) else str(s).encode()).decode().rstrip("=") 258 | 259 | def B64Decode(self, s): 260 | s += "="*((4 - len(s) % 4) % 4) 261 | return base64.urlsafe_b64decode(s) 262 | 263 | def CSR(self, domain: (str, list), privateKey=None): 264 | if privateKey is None or privateKey == "" or privateKey == b"": 265 | if self.ECC is True: 266 | privKey = ec.generate_private_key(curve=ec.SECP256R1(), backend=default_backend()) 267 | else: 268 | privKey = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) 269 | else: 270 | if isinstance(privateKey, (str, bytes)): 271 | privKey = serialization.load_pem_private_key(privateKey if isinstance(privateKey, bytes) else str(privateKey).encode("utf-8"), password=None, backend=default_backend()) 272 | else: 273 | privKey = privateKey 274 | if isinstance(domain, str): 275 | domain = str(domain).split(",") 276 | domain = [str(item).strip() for item in domain if str(item).strip() != ""] 277 | if len(domain) == 0: 278 | return None, None 279 | builder = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([])).add_extension(x509.SubjectAlternativeName([x509.DNSName(item) for item in domain]), critical=False) 280 | csr = builder.sign(privKey, hashes.SHA256()).public_bytes(encoding=serialization.Encoding.PEM).decode("utf-8").strip() 281 | pKey = privKey.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption()).decode("utf-8").strip() 282 | return csr, pKey 283 | 284 | async def Init(self): 285 | if not str(self.server).startswith("https://"): 286 | if self.server not in self.ServerList: 287 | return False 288 | server = self.ServerList[self.server] 289 | resp = await self.HTTP("GET", url=server, headers=self.Headers(), redirect=False, Proxy=self.Proxy) 290 | if resp["code"] == 200: 291 | self.Server = json.loads(resp["data"].decode()) 292 | if "Replay-Nonce" in resp["headers"]: 293 | self.Nonce = resp["headers"]["Replay-Nonce"] 294 | if self.Nonce is None: 295 | self.Nonce = await self.GetNonce() 296 | if self.privKeyPath is not None: 297 | if os.path.sep not in self.PrivateKeyPath: 298 | self.PrivateKey = self.PrivateKeyPath 299 | self.PrivateKeyPath = os.path.join(self.Root, self.RootData, parse.urlparse(server).hostname, self.PrivateKey) 300 | return True 301 | return False 302 | 303 | async def GetNonce(self): 304 | if self.Server is None or "newNonce" not in self.Server: 305 | return None 306 | resp = await self.HTTP("HEAD", url=self.Server["newNonce"], headers=self.Headers(), redirect=False, Proxy=self.Proxy) 307 | if resp["code"] == 200 and "Replay-Nonce" in resp["headers"]: 308 | return resp["headers"]["Replay-Nonce"] 309 | return None 310 | 311 | async def GetAccount(self): 312 | if self.AccountURL is None: 313 | status = await self.Account() 314 | if status is not True: 315 | return False 316 | return True 317 | 318 | async def Account(self, mail=None, kid=None, hmacKey=None): 319 | if self.Server is None or self.Nonce is None: 320 | status = await self.Init() 321 | if status is not True: 322 | return False 323 | privateKey, self.PrivKey, jwk, self.JWKHash = self.JWK(privKey=self.ReadFile(f=self.PrivateKeyPath), ECC=self.ECC) 324 | payload = { 325 | "termsOfServiceAgreed": True, 326 | } 327 | if mail is not None and mail != "": 328 | payload["contact"] = ["mailto:{}".format(str(mail).strip().lower())] 329 | if "meta" in self.Server and "externalAccountRequired" in self.Server["meta"] and self.Server["meta"]["externalAccountRequired"] is True: 330 | if kid is not None and hmacKey is not None: 331 | payload["externalAccountBinding"] = dict() 332 | payload["externalAccountBinding"]["protected"] = self.B64Encode(json.dumps({"alg": "HS256", "kid": kid, "url": self.Server["newAccount"]}, separators=(', ', ': '), ensure_ascii=False)) 333 | payload["externalAccountBinding"]["payload"] = self.B64Encode(json.dumps(jwk, separators=(', ', ': '), ensure_ascii=False)) 334 | payload["externalAccountBinding"]["signature"] = self.SignHMAC(key=self.B64Decode(hmacKey), data=str("{}.{}").format(payload["externalAccountBinding"]["protected"], payload["externalAccountBinding"]["payload"]).encode()) 335 | 336 | resp = await self.SEND(url=self.Server["newAccount"], protected=self.B64Encode(json.dumps({"alg": "ES{}".format(self.PrivKey.curve.key_size) if hasattr(self.PrivKey, "curve") else "RS256", "jwk": jwk, "nonce": self.Nonce, "url": self.Server["newAccount"]}, separators=(', ', ': '), ensure_ascii=False)), payload=self.B64Encode(json.dumps(payload, separators=(', ', ': '), ensure_ascii=False))) 337 | if resp["code"] in [200, 201]: 338 | self.WriteFile(f=self.PrivateKeyPath, d=privateKey, o=False) 339 | if "Location" in resp["headers"]: 340 | self.AccountURL = resp["headers"]["Location"] 341 | return True 342 | try: 343 | respJson = json.loads(resp["data"].decode()) 344 | key = "detail" if "detail" in respJson else None 345 | assert key is not None 346 | print("Error: {}".format(respJson[key]), flush=True) 347 | except: 348 | print(resp) 349 | return False 350 | 351 | async def Order(self, domain: (list, str) = None, verify=None): 352 | if not await self.GetAccount(): 353 | return None 354 | 355 | if verify is None: 356 | verify = self.VerifyType 357 | verify = str(verify).strip().lower() 358 | if verify not in ["dns", "http", "tls-alpn"]: 359 | verify = "dns" 360 | self.VerifyType = verify 361 | 362 | if domain is not None: 363 | if isinstance(domain, str): 364 | domain = str(domain).split(",") 365 | domain = [str(item).strip() for item in domain if "." in item] 366 | self.DOMAIN = domain 367 | else: 368 | domain = self.DOMAIN 369 | payload = {"identifiers": []} 370 | for item in domain: 371 | payload["identifiers"].append({ 372 | "type": verify, 373 | "value": item, 374 | }) 375 | if len(payload["identifiers"]) <= 0: 376 | return None 377 | 378 | protected = {"alg": "ES{}".format(self.PrivKey.curve.key_size) if hasattr(self.PrivKey, "curve") else "RS256", "kid": self.AccountURL, "nonce": self.Nonce, "url": self.Server['newOrder']} 379 | resp = await self.SEND(url=self.Server["newOrder"], protected=self.B64Encode(json.dumps(protected, separators=(', ', ': '), ensure_ascii=False)), payload=self.B64Encode(json.dumps(payload, separators=(', ', ': '), ensure_ascii=False))) 380 | if resp["code"] in [200, 201]: 381 | if "Location" in resp["headers"]: 382 | self.OrderURL = resp["headers"]["Location"] 383 | self.OrderDetails = json.loads(resp["data"].decode()) 384 | if "status" in self.OrderDetails and self.OrderDetails["status"] in ["ready", "valid"]: 385 | return True 386 | if "authorizations" in self.OrderDetails: 387 | result = [] 388 | for authUrl in self.OrderDetails["authorizations"]: 389 | auth = await self.AuthChall(authUrl=authUrl) 390 | if isinstance(auth, (tuple, list)): 391 | result += auth 392 | result = [item for item in result if "type" in item and str(item["type"]).startswith("{}-".format(verify))] 393 | if len(result) == 0: 394 | return None 395 | result = [item for item in result if "status" in item and item["status"] not in ["valid"]] 396 | self.Challenges = result 397 | if len(result) == 0: 398 | return True 399 | for item in result: 400 | if "error" in item and "detail" in item["error"]: 401 | print('[{}] [{}] {} "{}" [{}]'.format(time.strftime("%Y/%m/%d %H:%M:%S", time.localtime()), item["type"], item["domain"], item["txt"], item["error"]["detail"]), flush=True) 402 | else: 403 | print('[{}] [{}] {} "{}"'.format(time.strftime("%Y/%m/%d %H:%M:%S", time.localtime()), item["type"], item["domain"] , item["txt"]), flush=True) 404 | return result 405 | try: 406 | respJson = json.loads(resp["data"].decode()) 407 | key = "detail" if "detail" in respJson else None 408 | assert key is not None 409 | print("Error: {}".format(respJson[key]), flush=True) 410 | except: 411 | print(resp) 412 | return None 413 | 414 | async def AuthChall(self, authUrl): 415 | if authUrl is None or str(authUrl).strip() == "": 416 | return None 417 | if not await self.GetAccount(): 418 | return None 419 | if self.OrderDetails is None or self.OrderURL is None: 420 | return None 421 | resp = await self.SEND(url=authUrl, protected=self.B64Encode(json.dumps({"alg": "ES{}".format(self.PrivKey.curve.key_size) if hasattr(self.PrivKey, "curve") else "RS256", "kid": self.AccountURL, "nonce": self.Nonce, "url": authUrl}, separators=(', ', ': '), ensure_ascii=False)), payload="") 422 | if resp["code"] in [200, 201]: 423 | if "Replay-Nonce" in resp["headers"]: 424 | self.Nonce = resp["headers"]["Replay-Nonce"] 425 | respJson = json.loads(resp["data"].decode()) 426 | if "identifier" in respJson and "value" in respJson["identifier"] and "status" in respJson: 427 | if "challenges" in respJson: 428 | result = [] 429 | for item in respJson["challenges"]: 430 | if item["status"] in ["valid"]: 431 | continue 432 | if "type" not in item or "token" not in item: 433 | continue 434 | value = str("{}.{}").format(item["token"], self.JWKHash) 435 | if item["type"] in ['http-01']: 436 | item["txt"] = value 437 | item["domain"] = respJson["identifier"]["value"] 438 | if item["type"] in ['dns-01', 'tls-alpn-01']: 439 | item["txt"] = self.B64Encode(hashlib.sha256(value.encode()).digest()) 440 | item["domain"] = str("{}.{}").format(self.DNSHostValue, respJson["identifier"]["value"]) 441 | if "txt" not in item: 442 | continue 443 | result.append(item) 444 | return result 445 | return True 446 | return None 447 | 448 | async def Chall(self, challUrl): 449 | if not await self.GetAccount(): 450 | return None 451 | protected = {"alg": "ES{}".format(self.PrivKey.curve.key_size) if hasattr(self.PrivKey, "curve") else "RS256", "kid": self.AccountURL, "nonce": self.Nonce, "url": challUrl} 452 | resp = await self.SEND(url=challUrl, protected=self.B64Encode(json.dumps(protected, separators=(', ', ': '), ensure_ascii=False)), payload="e30") 453 | if resp["code"] in [200, 201]: 454 | return True 455 | return False 456 | 457 | async def CheckChall(self, verify=None): 458 | if verify is None: 459 | verify = self.VerifyType 460 | if verify is None: 461 | verify = "dns" 462 | if not await self.GetAccount(): 463 | return None 464 | if self.OrderDetails is None: 465 | return None 466 | if "status" in self.OrderDetails and self.OrderDetails["status"] in ["ready", "valid"]: 467 | return True 468 | if len(self.Challenges) == 0: 469 | return None 470 | valid = [] 471 | for item in self.Challenges: 472 | if "url" not in item or "status" not in item: 473 | continue 474 | status = await self.Chall(challUrl=item["url"]) 475 | if status is True: 476 | valid.append(item["url"]) 477 | if len(valid) != len(self.Challenges): 478 | return None 479 | await asyncio.sleep(5) 480 | if "authorizations" in self.OrderDetails: 481 | result = [] 482 | for authUrl in self.OrderDetails["authorizations"]: 483 | auth = await self.AuthChall(authUrl=authUrl) 484 | if isinstance(auth, (tuple, list)): 485 | result += auth 486 | result = [item for item in result if "type" in item and str(item["type"]).startswith("{}-".format(verify)) and "status" in item and item["status"] not in ["valid"]] 487 | if len(result) == 0: 488 | return True 489 | for item in result: 490 | if "error" in item and "detail" in item["error"]: 491 | print('[{}] [{}] {} "{}" [{}]'.format(time.strftime("%Y/%m/%d %H:%M:%S", time.localtime()), item["type"], item["domain"], item["txt"], item["error"]["detail"]), flush=True) 492 | else: 493 | print('[{}] [{}] {} "{}"'.format(time.strftime("%Y/%m/%d %H:%M:%S", time.localtime()), item["type"], item["domain"] , item["txt"]), flush=True) 494 | return None 495 | 496 | async def Finalize(self, csr=None): 497 | if not await self.GetAccount(): 498 | return None 499 | if self.OrderDetails is None: 500 | return None 501 | if csr is None: 502 | csr, self.CrtKey = self.CSR(domain=self.DOMAIN, privateKey=None) 503 | if "BEGIN CERTIFICATE REQUEST" in csr: 504 | csr = str(csr).replace("BEGIN CERTIFICATE REQUEST", "").replace("END CERTIFICATE REQUEST", "").replace("-", "").replace("\n", "").replace("/", "_").replace("+", "-").rstrip("=") 505 | protected = {"alg": "ES{}".format(self.PrivKey.curve.key_size) if hasattr(self.PrivKey, "curve") else "RS256", "kid": self.AccountURL, "nonce": self.Nonce, "url": self.OrderDetails["finalize"]} 506 | payload = {"csr": csr} 507 | resp = await self.SEND(url=self.OrderDetails["finalize"], protected=self.B64Encode(json.dumps(protected, separators=(', ', ': '), ensure_ascii=False)), payload=self.B64Encode(json.dumps(payload, separators=(', ', ': '), ensure_ascii=False))) 508 | if resp["code"] in [200, 201]: 509 | return True 510 | return False 511 | 512 | async def FinalizeCrt(self, crtUrl): 513 | protected = {"alg": "ES{}".format(self.PrivKey.curve.key_size) if hasattr(self.PrivKey, "curve") else "RS256", "kid": self.AccountURL, "nonce": self.Nonce, "url": crtUrl} 514 | resp = await self.SEND(url=crtUrl, protected=self.B64Encode(json.dumps(protected, separators=(', ', ': '), ensure_ascii=False)), payload="") 515 | if resp["code"] in [200, 201]: 516 | return resp["data"].decode() 517 | return None 518 | 519 | async def CheckOrder(self, csr=None): 520 | if not await self.GetAccount(): 521 | return None 522 | if self.OrderDetails is None: 523 | return None 524 | for _ in range(15): 525 | protected = {"alg": "ES{}".format(self.PrivKey.curve.key_size) if hasattr(self.PrivKey, "curve") else "RS256", "kid": self.AccountURL, "nonce": self.Nonce, "url": self.OrderURL} 526 | resp = await self.SEND(url=self.OrderURL, protected=self.B64Encode(json.dumps(protected, separators=(', ', ': '), ensure_ascii=False)), payload="") 527 | if resp["code"] in [200, 201]: 528 | self.OrderDetails = json.loads(resp["data"].decode()) 529 | if "status" in self.OrderDetails: 530 | if self.OrderDetails["status"] in ["valid"]: 531 | if "certificate" in self.OrderDetails: 532 | self.Crt = await self.FinalizeCrt(crtUrl=self.OrderDetails["certificate"]) 533 | if self.Crt is not None: 534 | break 535 | if self.OrderDetails["status"] in ["ready"]: 536 | status = await self.Finalize(csr=csr) 537 | if status is False: 538 | break 539 | await asyncio.sleep(delay=6) 540 | continue 541 | if self.Crt is not None and self.CrtKey is not None: 542 | # CrtMark = "{}_{}_{}".format(time.strftime("%Y%m%d%H%M%S", time.localtime()), "ecc" if self.ECC is True else "rsa", self.DOMAIN[0].replace("*", "").replace(".", "_").strip("_")) 543 | CrtMark = self.DOMAIN[0].replace("*", "").strip(".") 544 | CrtPath = os.path.join(self.Root, self.RootData, "crt", CrtMark) 545 | if not os.path.exists(CrtPath): 546 | os.makedirs(CrtPath) 547 | self.WriteFile(f=os.path.join(CrtPath, "server.crt.pem"), d=self.Crt, o=True) 548 | self.WriteFile(f=os.path.join(CrtPath, "server.key.pem"), d=self.CrtKey, o=True) 549 | return self.Crt, self.CrtKey 550 | 551 | async def NewCrt(self): 552 | order = await self.Order() 553 | if order is None: 554 | return None, None 555 | if isinstance(order, list) and len(order) > 0: 556 | urls, _urls = [], [] 557 | for _ in range(5): 558 | if ("key" in self.KWARGS and self.KWARGS["key"] is not None and "secret" in self.KWARGS and self.KWARGS["secret"] is not None) or ("token" in self.KWARGS and self.KWARGS["token"] is not None): 559 | urls, _urls = DNS.HUAWEI(name=self.DNSHostValue, sub=self.SUBDOMAIN, order=order, ttl=15, **self.KWARGS) 560 | for url in urls: 561 | resp = await self.HTTP(method="GET", url=url, timeout=60, Proxy=self.Proxy) 562 | print(json.dumps(json.loads(resp["data"].decode()), indent=4, ensure_ascii=False), flush=True) 563 | else: 564 | input("Add TXT records manually, and press to continue ...") 565 | await asyncio.sleep(delay=15) 566 | status = await self.CheckChall() 567 | if status is True: 568 | for url in _urls: 569 | resp = await self.HTTP(method="GET", url=url, timeout=60, Proxy=self.Proxy) 570 | print(json.dumps(json.loads(resp["data"].decode()), indent=4, ensure_ascii=False), flush=True) 571 | break 572 | return await self.CheckOrder() 573 | 574 | 575 | 576 | if __name__ == "__main__": 577 | # NewCrt: python3 acme.py -d "xxx.com,*.xxx.com" 578 | # NewCrt: python3 acme.py -d "sub.xxx.com,*.sub.xxx.com" -v dns -s google -sub "xxx.com" -ecc 579 | 580 | # Register: python3 acme.py -register -s google -mail "xyz@abc.com" -kid "" -key "" 581 | # Enable GTS: https://console.cloud.google.com/apis/library/publicca.googleapis.com 582 | # GTS HMAC KEY: gcloud publicca external-account-keys create 583 | ## gcloud config set project ; gcloud services enable publicca.googleapis.com 584 | 585 | import argparse 586 | parser = argparse.ArgumentParser() 587 | parser.add_argument('-d', dest='domain', type=str, help='domains with comma separated.') 588 | parser.add_argument('-v', dest="verify", type=str, default="dns", help='http, dns.') 589 | parser.add_argument('-s', dest="server", type=str, default="letsencrypt", help='ca directory.') 590 | parser.add_argument('-ecc', dest="ecc", action="store_true", help='use ecc, default rsa.') 591 | parser.add_argument('-register', dest="register", action="store_true", help='register') 592 | parser.add_argument('-mail', dest="mail", type=str, help='mail, register') 593 | parser.add_argument('-kid', dest="kid", type=str, help='eab kid, register.') 594 | parser.add_argument('-key', dest="key", type=str, help='eab hmac key, register') 595 | parser.add_argument('-data', dest="data", type=str, default="acme", help='data directory') 596 | parser.add_argument('-sub', dest="sub", type=str, default="", help='declare sub domain with comma separated.') 597 | parser.add_argument('-proxy', dest="proxy", type=str, default=None, help='use proxy.') 598 | args = parser.parse_args() 599 | 600 | loop = asyncio.get_event_loop() 601 | acme = ACME(domain=args.domain, sub=args.sub, verify=args.verify, server=args.server, rootData=args.data, ecc=args.ecc, proxy=args.proxy, **{"key": None, "secret": None}) 602 | if args.register is True: 603 | status = loop.run_until_complete(acme.Account(mail=args.mail, kid=args.kid, hmacKey=args.key)) 604 | print("Register Status: {}".format(status)) 605 | if status is True: 606 | print("Private Key: {}".format(acme.PrivateKeyPath)) 607 | if len(acme.DOMAIN) == 0: 608 | os._exit(0) 609 | crt, key = loop.run_until_complete(acme.NewCrt()) 610 | if crt is not None and key is not None: 611 | print(crt, flush=True) 612 | print(key, flush=True) 613 | os._exit(0) 614 | os._exit(1) 615 | 616 | 617 | -------------------------------------------------------------------------------- /acmeAuto.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DOMAIN=() 4 | DOMAIN+=("moeclub.org,*.moeclub.org") 5 | # DOMAIN+=("sub.moeclub.org,*.sub.moeclub.org;moeclub.org") 6 | 7 | 8 | execPath=`readlink -f "$0"` 9 | execName=`basename "$execPath"` 10 | [ "$1" == "1" ] && chmod 777 "$execPath" && sed -i "/${execName//\//\\/}/d;\$a\3 3 * * 1 root bash ${execPath} >/dev/null 2>&1 &\n\n\n" /etc/crontab >/dev/null 2>&1 11 | 12 | cd $(dirname "$execPath") 13 | [ -f "./acme.py" ] || exit 1 14 | [ -f "./acme/dv.acme-v02.api.pki.goog/acme.key" ] && s="google" || s="letsencrypt" 15 | 16 | for domain in "${DOMAIN[@]}"; do 17 | _domain="${domain};" 18 | domain=`echo "${_domain}" |cut -d';' -f1` 19 | sub=`echo "${_domain}" |cut -d';' -f2` 20 | if [ "${s}" == "letsencrypt" ]; then 21 | python3 ./acme.py -s "${s}" -ecc -d "${domain}" -sub "${sub}" 22 | else 23 | python3 ./acme.py -s "${s}" -d "${domain}" -sub "${sub}" 24 | fi 25 | done 26 | -------------------------------------------------------------------------------- /acmeRenew.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | crtDomain="${1:-}" 4 | crtSeviceRoot="${2:-nginx:/etc/nginx}" 5 | crtServer="${3:-http://crt.moeclub.org}" 6 | crtCron="${4:-0}" 7 | crtServiceName=`echo "${crtSeviceRoot}" |cut -d':' -f1` 8 | crtRoot=`echo "${crtSeviceRoot}" |cut -d':' -f2` 9 | 10 | crtTarget="${crtRoot%/}" 11 | # crtTarget="${crtRoot%/}/${crtDomain%/}" 12 | 13 | [ -n "${crtDomain}" ] && [ -n "${crtRoot}" ] && [ -n "${crtServer}" ] || exit 1 14 | [ "${crtCron}" != "0" ] && execName="acmeRenew.sh" && execPath="/usr/local/bin/${execName}" && { [ "$0" != "${execPath}" ] && cp -rf "$0" "$execPath" || true; } && chmod 777 "$execPath" && sed -i "/ ${crtSeviceRoot//\//\\/} /d;\$a\4 4 * * 1 root bash ${execPath} ${crtDomain} ${crtSeviceRoot} ${crtServer} >/dev/null 2>&1 &\n\n\n" /etc/crontab >/dev/null 2>&1 15 | 16 | 17 | crt="$(mktemp)" 18 | key="$(mktemp)" 19 | rm -rf ${crt} ${key} 20 | trap "rm -rf ${crt} ${key}" EXIT 21 | 22 | checkMd5() { 23 | crtMode="${1:-}" 24 | crtPath="${2:-}" 25 | [ -n "$crtMode" ] && [ -n "$crtPath" ] && [ -f "$crtPath" ] || return 26 | [ "$crtMode" == "crt" ] && crtMd5=`openssl x509 -in "${crtPath}" -pubkey -noout -outform pem 2>/dev/null |openssl md5 2>/dev/null |cut -d'=' -f2 |grep -o '[0-9a-z]*'` 27 | [ "$crtMode" == "key" ] && crtMd5=`openssl pkey -in "${crtPath}" -pubout -outform pem 2>/dev/null |openssl md5 2>/dev/null |cut -d'=' -f2 |grep -o '[0-9a-z]*'` 28 | echo -ne "$crtMd5" 29 | } 30 | 31 | # check local crt 32 | localMd5=`checkMd5 crt "${crtTarget%/}/server.crt.pem"` 33 | # download crt 34 | wget -qO "${crt}" "${crtServer%/}/${crtDomain%/}/server.crt.pem"; 35 | [ "$?" -eq "0" ] || exit 1 36 | crtMd5=`checkMd5 crt "${crt}"` 37 | [ -n "${crtMd5}" ] || exit 1 38 | [ -n "${localMd5}" ] && [ "${localMd5}" == "${crtMd5}" ] && exit 0 39 | # download key 40 | wget -qO "${key}" "${crtServer%/}/${crtDomain%/}/server.key.pem"; 41 | [ "$?" -eq "0" ] || exit 1 42 | keyMd5=`checkMd5 key "${key}"` 43 | [ -n "${keyMd5}" ] || exit 1 44 | [ -n "${crtMd5}" ] && [ -n "${keyMd5}" ] && [ "${crtMd5}" == "${keyMd5}" ] || exit 1 45 | [ -f "${crt}" ] && [ -f "${key}" ] || exit 1 46 | # target 47 | [ -d "${crtTarget%/}" ] || mkdir -p "${crtTarget%/}" 48 | cp -rf "${crt}" "${crtTarget%/}/server.crt.pem" 49 | cp -rf "${key}" "${crtTarget%/}/server.key.pem" 50 | 51 | # restart service 52 | systemctl restart "${crtServiceName}" 2>/dev/null 53 | --------------------------------------------------------------------------------