前言
前两天突然发现服务器的SSL证书过期了,acme的自动更新也没成功运行,费了不少劲也踩了不少坑,以下简记let’s encrypt + win-acme的认证与更新。
windows server 2012 + nginx==1.28.0 + win-acme==2.2.9.1701
准备工作
win-acme
win-acme应该算windows上比较好用的,官网及说明文档见https://www.win-acme.com,下载页见https://github.com/win-acme/win-acme/releases/,版本根据自己需求选择,trimmed是精简版(无法安装插件),一般pluggable就行,其他形如 plugin.validation.dns.*.v.*.*.*.zip的是对应dns的插件(需配合pluggable版本使用),有需要的可以下载对应压缩包,并把压缩包内容解压到win-acme的根目录即可。
以下默认解压后程序根目录为C:/win-acme。
两种认证模式示例
ssl证书认证的核心即将域名与服务器进行绑定以防止被劫持。以下简介两种不同的认证模式。
文件认证
文件认证的基本模式为,win-acme在本地创建一个文件,然后让认证服务器从指定域名访问该文件,比对文件内容是否一致。 基于此逻辑,需要完成的有以下几步
配置服务器
以nginx为例,要让服务器从相应域名能够访问到文件,首先要配置好nginx.conf
location ^~ /.well-known/acme-challenge/ { # 请将该block置于最前,以避免被其他规则覆盖 default_type "text/plain"; try_files $uri =404; # 查找请求的文件,找不到返回404 expires -1; # 禁止缓存验证文件(避免验证失败) add_header Cache-Control "no-store, no-cache, must-revalidate";}server { listen 80; # 服务器认证默认走80端口 server_name abc.com; # 域名
root C:/certificates/abc.com/; # 临时访问文件的本地位置 include snippets/acme_challenge.conf; # 引入认证模块
location / { # 自动跳转https return 302 https://abc.com$request_uri; } ... # 其他配置,如日志、流量等}当然,把认证模块抽象出来再通过include进行复用并非必要,如果只有一个域名,直接在server block里写location规则就行
外部访问 http://abc.com/.well-known/acme-challenge/xxx 时会指向 C:\certificates\abc.com\.well-known\acme-challenge\xxx
确认相应权限
这个坑比较容易踩,主要是让win-acme和nginx拥有相应必要权限。
| 目录 | 权限 |
|---|---|
| 证书存放目录 | 写入权限 + nginx 读取权限 |
| 验证目录 | 修改权限 + nginx/everyone读取权限 |
| win-acme程序目录 | 执行权限 + 运行账户写入权限 |
- 手动运行时当前登录用户有相应权限即可。
- 如果要作为计划任务运行,可以考虑用SYSTEM用户或专门的服务账户,并确保该账户拥有相应权限。
运行WACS(Windows ACME Simple)
-
TUI
<span id="wacs-tui">-
选择认证模式
[M] Create Certificate (full options)手动选择配置
-
如何输入域名
[2] Manual Input手动输入模式
-
输入域名
abc.com此处替换为你的实际域名
-
选择证书条目数
[4] Single certificate单证书(可选按域名/按主机)
-
如何确认域名所有权
[1] [http] Save verification files on (network) path将验证文件保存至(网络)路径
-
输入验证文件存放位置
C:\certificates\abc.com此处替换为你的实际临时存放文件夹,即 配置服务器中的root路径
-
是否复制web.config到认证目录下
n这个一般是IIS配置才需要的模块,非IIS的直接选否即可。因为WACS认证时let’s encrypt服务器会访问
http://abc.com/.well-known/acme-challenge/{token},而IIS可能会阻止未知扩展名的文件访问,或者对静态文件设置MIME类型导致未定义类型请求被过滤,web.config 就是为了确保 .well-known/acme-challenge 下的文件可以被外部访问,且返回正确的MIME type,以避免被IIS拦截请求。 -
确认加密类型
[2] RSA key使用默认RSA即可
-
如何存储证书
[2] PEM encoded files (Apache, nginx, etc.)前面用的nginx,所以这里也使用nginx支持的PEM类型
-
存储证书位置
D:\certificated\abc.com此处替换为你的本地存储位置
-
是否对PEM文件加密
[1] None -
其他证书存储位置
[5] No (additional) storage steps无需其他存储
-
是否更新服务器配置
[3] No (additional) installation steps无需其他安装步骤,如果有需要,比如重启nginx服务器可以自己编写脚本,然后选择
[2] Start external script or program -
其他细节
打开并确认协议条款,并输入邮箱 -
认证成功
认证成功后可以确认nginx.conf的配置
server {listen 443 ssl;server_name abc.com;ssl_certificate D:/certificates/abc.com/abc.com-chain.pem; # 证书路径ssl_certificate_key D:/certificates/abc.com/abc.com-key.pem; # 私钥路径root ... # 其他...}然后重启nginx服务
Terminal window nginx -tnginx -s reload另外wacs一般会把自动更新写进任务计划程序,可以通过
taskschd.msc找到。也可在主界面选择[R] Run Renewals-[S] Run *all* renewals (force)来强制更新 -
认证失败
根据提示信息确认失败原因,修正后重试。
注意, 未经备案的域名可能导致let’s encrypt在访问时被部分dns服务商拦截,此类报错相对隐蔽,可能需要用
--verbose的详细日志才能发现真正原因
-
-
CLI
命令行参数用法详参https://www.win-acme.com/reference/cli
以下给出上述步骤的命令行参数写法
Terminal window wacs.exe --source manual --host "abc.com" ^--validation filesystem --webroot "C:\certificates\abc.com" ^--store pemfiles --pemfilespath "D:\certificated\abc.com" ^--verbose >> res.loglinux请使用反斜杠
\换行(不会有人用linux还来看wacs的流程吧,不会吧,不会吧)最后一行
--verbose启用详细日志输出,>>将结果静默追加输出至当前文件夹的res.log文件,方便检查。如无需追加直接使用>覆盖输出。
DNS认证
DNS认证的基本模式为,在DNS记录中添加一条TXT记录,let’s encrypt检查该记录是否存在且正确。 部分常见的DNS服务商(如Cloudflare, Aliyun等)可以通过下载对应的插件来进行认证(见win-acme) 以下简介通用做法,需要dns服务商提供api接口
编写脚本
wacs需要两个接口,一为创建一条dns记录,二为删除该条dns记录,具体可用参数占位符详见https://www.win-acme.com/reference/plugins/validation/dns/script。
| 参数名 | 示例 | 释义 |
|---|---|---|
| {Identifier} | sub.abc.com | 待验证的主机名 |
| {RecordName} | _acme-challenge.sub.abc.com | 完整TXT记录名 |
| {ZoneName} | abc.com | 可注册域名 |
| {NodeName} | _acme-challenge.sub | 记录节点名 |
| {Token} | DGyRejmCefe7v4NfDGDKfA | ACME 要求添加到 TXT 记录的内容 |
| {vault://json/mysecret} | - | Vault中存储的内容(比如 API Key 或密码) |
wacs在调用脚本时,根据状态码确认脚本是否成功运行,以下仅示例。
C:\Python311\python.exe "C:\win-acme\CustomizeScripts\dns.py" %*如使用虚拟环境,以anaconda为例
CALL "D:\Anaconda3\condabin\conda.bat" activate myenvpython "C:\win-acme\CustomizeScripts\dns.py" %*CALL conda deactivateecho Donepython脚本,具体api响应结构和请求参数,请参考dns服务商提供的文档
# requirements: requests, dnspython# python >= 3.6 (typehint, f-string)# python >= 3.11 (StrEnum)import requestsimport sysfrom enum import IntEnum, StrEnumimport timeimport loggingimport dns.resolver
logging.basicConfig(level=logging.INFO)logger = logging.getLogger(__name__)fmt = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s",datefmt="%Y-%m-%d %H:%M:%S")stream_handler = logging.StreamHandler()stream_handler.setFormatter(fmt)logger.addHandler(stream_handler)
API_KEY = "YOUR_API_KEY"BASE = "https://www.sample.com/api"TIMEOUT_MIN = 10
class VERSION(IntEnum): DEFAULT = 1
class TYPE(StrEnum): XML = "xml" JSON = "json"
class ACTION(StrEnum): ADD_DNS_RECORD = "dnsAddRecord" LIST_DNS_RECORDS = "dnsListRecords" DELETE_DNS_RECORD = "dnsDeleteRecord"
class RRTYPE(StrEnum): A = "A" AAAA = "AAAA" CNAME = "CNAME" MX = "MX" TXT = "TXT" SRV = "SRV" CAA = "CAA"
def wait_for_dns_propagation(txt_record_name: str, expected_value: str,timeout: int = 60*10, interval: int = 30): start = time.time() while True: try: answers = dns.resolver.resolve(txt_record_name, 'TXT') for rdata in answers: for txt_string in rdata.strings: try: decoded_value = txt_string.decode() if decoded_value == expected_value: logger.info(f"TXT记录生效: {txt_record_name} = {expected_value}") return True except Exception as e: logger.error(f"无法解码字符串:{e}") continue except Exception as e: logger.warning(f"查询DNS失败: {e}, 等待{interval}s后重试") time.sleep(interval) elapsed = time.time() - start if elapsed > timeout: logger.error(f"等待 TXT 记录超时: {txt_record_name}") return False
def add(domain: str, txt_value: str, rrhost: str): url = f"{BASE}/{ACTION.ADD_DNS_RECORD}?version={VERSION.DEFAULT}&type={TYPE.JSON}&key={API_KEY}&domain={domain}&rrtype={RRTYPE.TXT}&rrhost={rrhost}&rrvalue={txt_value}&rrttl=3600" r = requests.get(url) if r.status_code != 200: logger.error(f"添加DNS记录失败: {r.text}") sys.exit(1) logger.info(r.text) logger.info(f"等待DNS记录生效...{TIMEOUT_MIN}分钟") res = wait_for_dns_propagation(rrhost, txt_value, timeout=60*TIMEOUT_MIN, interval=30) if res: sys.exit(0) else: logger.error("DNS记录超时未生效, 请检查是否成功添加,如有必要可上调最大等待时长") sys.exit(1)
def delete(domain: str, rec_id: str): url = f"{BASE}/{ACTION.DELETE_DNS_RECORD}?version={VERSION.DEFAULT}&type={TYPE.JSON}&key={API_KEY}&domain={domain}&rrid={rec_id}" r = requests.get(url) logger.info(r.text) sys.exit(0 if r.status_code == 200 else 1)
def get_txt_record_id(domain: str, txt_value: str): url = f"{BASE}/{ACTION.LIST_DNS_RECORDS}?version={VERSION.DEFAULT}&type={TYPE.JSON}&key={API_KEY}&domain={domain}" r = requests.get(url) logger.info(r.text) data = r.json() reply = data.get("reply", None) if reply: resource_records = reply.get("resource_record", None) if resource_records: for rec in resource_records: if rec.get("type") == RRTYPE.TXT and rec.get("value") == txt_value: return rec.get("record_id") logger.error("未找到一致的TXT记录") else: logger.error("未找到记录") else: logger.error("请求失败") return None
def main(): action = sys.argv[1] domain = sys.argv[2] txt_value = sys.argv[3] rrhost = sys.argv[4] if len(sys.argv) > 4 else "_acme-challenge" if action == "add": add(domain, txt_value, rrhost) elif action == "delete": rec_id = get_txt_record_id(domain, txt_value) if rec_id: delete(domain, rec_id) else: logger.warning("没有需要删除的记录") sys.exit(0) else: raise ValueError("未知操作")
if __name__ == "__main__": main()运行WACS(Windows ACME Simple)
-
TUI
-
前置准备 前四步同前,详见文件认证部分
-
如何确认域名所有权
[8] [dns] Create verification records with your own script用脚本创建认证记录
-
脚本路径
C:/win-acme/CustomScript/dns.bat此处更改为你的脚本文件路径
-
删除认证记录
[1] Using the same script按需求选择
-
脚本参数(创建记录)
add {ZoneName} {Token} {RecordName}此处按照你的脚本设置传递参数
-
脚本参数(删除记录)
delete {ZoneName} {Token}此处按照你的脚本设置传递参数
-
并发设置
[1] Run everything one by one一般选串行即可,避免dns服务商出现http429等问题
-
后续设置
后续同前,详见文件认证部分
-
-
CLI
命令行参数用法详参https://www.win-acme.com/reference/cli
以下给出上一个步骤的命令行参数写法
Terminal window wacs.exe --source manual --host "abc.com" ^--validationmode dns-01 --validation script --dnsscript "C:\win-acme\CustomizeScripts\dns.bat" ^--dnscreatescriptarguments "add {ZoneName} {Token} {RecordName}" ^--dnsdeletescriptarguments "delete {ZoneName} {Token}" ^--store pemfiles --pemfilespath "D:\certificated\abc.com" ^--verbose >> res.log -
Vault使用
出于安全性考虑,以及避免脚本硬编码API_KEY,可以使用Vault,如https://github.com/hashicorp/vault,同样注意权限问题。
- 存储key
vault kv put secret/winacme_dns api_key="YOUR_REAL_DNS_API_KEY"注意:vault中token有过期时间,合理设计有效期或自动更新。
-
修改脚本接收的参数(sys.argv)
-
修改wacs使用
-
TUI中修改参数,注意参数顺序与脚本对应
add {ZoneName} {Token} {vault://json/secret/winacme_dns:api_key} {RecordName}delete {ZoneName} {Token} {vault://json/secret/winacme_dns:api_key} -
或CLI使用中修改相应传参
Terminal window ...--dnscreatescriptarguments "add {ZoneName} {Token} {vault://json/secret/winacme_dns:api_key} {RecordName}" ^--dnsdeletescriptarguments "delete {ZoneName} {Token} {vault://json/secret/winacme_dns:api_key}" ^...
-
其他
限额提醒
Let’s encrypt的申请有rate limit,如果仅在脚本或设置调试阶段,可以考虑使用 --baseuri https://acme-staging-v02.api.letsencrypt.org/参数,待调试成功后再进入生产环境。
| 类型 | 限额 |
|---|---|
| 同主域名证书申请限额 | 50/周/主域名 |
| 同域名集证书申请份数限额 | 5/周/域名集 |
| 同账户申请证书份数限额 | 300/3小时/账户 |
| 同账户同域名集验证失败次数限额 | 5/小时/域名集/账户 |
主域名指 sub1.example.com、sub2.example.com → example.com
域名集指申请的证书所生效的域名集合,如 {example.com, sub.example.com}