阿里云证书自动续期(GitHub Actions + RAM OIDC,免长期 AK)
何时用
- 想让定时任务自动续期 Let's Encrypt 证书并分发到阿里云(OSS / FC / CDN),但不想把长期 AK/SK 塞进仓库 Secrets。
- 想让新子域加一条 CNAME 就自动上 HTTPS,不用改代码。
- 更广义:任何「GitHub Actions(或别的 OIDC IdP)免密钥操作阿里云」的场景,本文第一、二节可直接复用。
架构
GitHub Actions (id-token: write)
│ 1. 请求 OIDC JWT(显式 audience)
▼
RAM OIDC 提供商 (token.actions.githubusercontent.com)
│ 2. AssumeRoleWithOIDC
▼
RAM 角色 → 临时 STS 凭证(1h)
│ 3. 写入 $GITHUB_ENV
▼
cert_manager.py(ACME DNS-01 签发 → CAS 上传 → OSS/FC 分发)
│ 4. 状态回写
▼
OSS 私有桶(account.key / privkey.pem / state.json)
仓库里只需要两个非敏感 Secret:ALI_ROLE_ARN、ALI_OIDC_ARN。
一、RAM 侧搭建(一次性,用永久 AK 调 API,AK 不落盘)
账号 ID 用 sts.GetCallerIdentity 拿,无需用户名。
1. OIDC 提供商 —— 属 IMS API,不是 ram20150501
pip install alibabacloud_ims20190815
- 方法
create_oidcprovider(小写 p),模型CreateOIDCProviderRequest - 字段全部 snake_case:
oidc_provider_name、issuer_url、client_ids、fingerprints、description issuer_url=https://token.actions.githubusercontent.comclient_ids= audience,如https://github.com/<owner>/<repo>- 指纹必须纯 hex、无冒号:
openssl s_client -connect token.actions.githubusercontent.com:443 </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl rsa -pubin -outform der 2>/dev/null \
| openssl dgst -sha1 # 取输出里的 hex,去掉冒号
2. 角色(alibabacloud_ram20150501)
ram.create_role(CreateRoleRequest(
role_name=ROLE, assume_role_policy_document=json.dumps(trust), description=...))
信任策略 —— oidc:sub 一定要 StringLike + 通配(原因见「坑 3」):
{"Statement":[{"Action":"sts:AssumeRole","Effect":"Allow",
"Principal":{"Federated":"acs:ram::<ACCT>:oidc-provider/github"},
"Condition":{"StringEquals":{"oidc:iss":"https://token.actions.githubusercontent.com",
"oidc:aud":"https://github.com/<owner>/<repo>"},
"StringLike":{"oidc:sub":"repo:<owner>*/<repo>*:ref:refs/heads/main"}}}],
"Version":"1"}
改信任范围用 update_role(UpdateRoleRequest(role_name=..., new_assume_role_policy_document=...))
—— 字段名是 new_assume_role_policy_document。
3. 权限策略
create_policy + attach_policy_to_role(snake_case:policy_name、policy_document、policy_type、role_name)。
Allow Action 至少:alidns:*、oss:*、fc:*、yundun-cert:*(不是 cas:*,见坑 5)。
改已有策略:create_policy_version(CreatePolicyVersionRequest(policy_name, policy_document, set_as_default=True));
版本上限 5,超了先 delete_policy_version 删最旧的非默认版。
直接跑 scripts/setup_oidc.py 可一步建完上述三者(AK 从环境变量读)。
二、workflow:手动 OIDC 兑换(不要依赖官方 action)
不要用 aliyun/configure-aliyun-credentials-action —— RAM 配置完全正确时它仍会报
AuthenticationFail.NoPermission / ImplicitDeny / sts:AssumeRole / PolicyType: AssumeRolePolicy,且难排查。
改为自己请求 JWT + AssumeRoleWithOIDC,并把 iss/aud/sub 打出来对照信任策略。
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- run: pip install alibabacloud_sts20150401 alibabacloud_alidns20150109 \
alibabacloud_cas20200407 alibabacloud_fc_open20210406 oss2 acme cryptography
- name: OIDC -> STS 临时凭证
env:
# ⚠️ 下面两行的美元符号与左花括号之间「故意留了一个空格」,是为规避上传通道的 WAF
# (它会把「美元符号 + 双花括号」连写当成模板注入特征拦截)。
# 复制到 workflow 时请删掉那个空格,恢复标准表达式写法。
ALI_ROLE_ARN: $ {{ secrets.ALI_ROLE_ARN }}
ALI_OIDC_ARN: $ {{ secrets.ALI_OIDC_ARN }}
AUDIENCE: https://github.com/<owner>/<repo>
run: python scripts/oidc_sts_env.py # 本 skill 自带,等价于下面的内联版
内联等价实现(要点全在注释里):
# 1) 请求 OIDC JWT:URL 已带 ?job_id=...,必须「追加」&audience=
url = os.environ['ACTIONS_ID_TOKEN_REQUEST_URL'] + '&audience=' + os.environ['AUDIENCE']
req = urllib.request.Request(url, headers={
'Authorization': 'Bearer ' + os.environ['ACTIONS_ID_TOKEN_REQUEST_TOKEN']})
oidc = json.load(urllib.request.urlopen(req, timeout=30))['value']
# 2) 解码 payload 打出 RAM 实际看到的 claim(定位 ImplicitDeny 的神器)
p = oidc.split('.')[1]; p += '=' * (-len(p) % 4)
cl = json.loads(base64.urlsafe_b64decode(p))
print('OIDC claims ->', 'iss =', cl.get('iss'), 'aud =', cl.get('aud'), 'sub =', cl.get('sub'))
# 3) AssumeRoleWithOIDC(请求字段全 snake_case,且要重试吸收偶发 500)
sts = StsClient(openapi_models.Config(endpoint='sts.aliyuncs.com'))
r = sts.assume_role_with_oidc(sts_models.AssumeRoleWithOIDCRequest(
oidcprovider_arn=os.environ['ALI_OIDC_ARN'], role_arn=os.environ['ALI_ROLE_ARN'],
oidctoken=oidc, role_session_name='cert-renew'))
c = r.body.credentials # access_key_id / access_key_secret / security_token / expiration
with open(os.environ['GITHUB_ENV'], 'a') as f:
f.write('ALIBABA_CLOUD_ACCESS_KEY_ID=%s\n' % c.access_key_id)
f.write('ALIBABA_CLOUD_ACCESS_KEY_SECRET=%s\n' % c.access_key_secret)
f.write('ALIBABA_CLOUD_SECURITY_TOKEN=%s\n' % c.security_token)
三、cert_manager.py 的三条硬要求
STOKEN = os.environ.get('ALIBABA_CLOUD_SECURITY_TOKEN', '')
# 1) oss2 必须按有无 token 选 Auth 实现(坑 6)
auth = oss2.StsAuth(AK, SK, STOKEN) if STOKEN else oss2.Auth(AK, SK)
# 2) 阿里云 SDK Config 透传 token
cfg.security_token = STOKEN # 长期 AK 场景为空,可忽略
# 3) 签发与分发解耦 + 指纹守卫(坑 7、8)
need_issue = DO('cert') and (left <= THRESHOLD or FORCE) # 证书有效只跳过签发,仍跑分发
cert_fp = hashlib.sha256(pem).hexdigest()
if cert_fp != state.get('cert_fp'): # 指纹变了才重传 CAS
old = state.get('cert_id')
if old:
cas.delete_user_certificate(DeleteUserCertificateRequest(cert_id=old)) # 注意是 cert_id
cert_id = cas.upload_user_certificate(UploadUserCertificateRequest(
name=CAS_NAME, cert=pem, key=privkey_pem)).body.cert_id
四、状态持久化(不做就会撞 LE 限额)
runner 是临时容器,account.key / privkey.pem / state.json 必须存外部。
用 OSS 私有桶:跑前 get_object_to_file、跑后 put_object_from_file,桶不存在自动建(建桶写法见坑 9)。
否则每次运行都当首次签发,撞 Let's Encrypt「每域名每周 5 张重复证书」限额。
五、keepalive:别让 GitHub 停掉 schedule
GitHub 对连续 60 天无活动的仓库会禁用 schedule,且 workflow 自己跑不算活动。
专用仓加 keepalive.yml,每月 1 日用 GITHUB_TOKEN(permissions: contents: write)改一个时间戳文件并 push:
on:
schedule: [{ cron: "0 3 1 * *" }]
workflow_dispatch:
permissions: { contents: write }
六、新子域「无感」接入(DNS 自动发现)
- 证书侧本来就无感:签的是通配
example.com + *.example.com,*.覆盖任意一级子域, 新建子域无需重新签发、不消耗配额。注意*.不覆盖多级子域(a.b.example.com)。 - 真正的手工成本在「绑定」。加
discover_targets()扫根域 CNAME(分页 100/页):<bucket>.oss-<region>.aliyuncs.com→ 正则反解 bucket + region,自动生成 OSS target<uid>.<region>.fcapp.run→ FC target(service/function 无法从 DNS 推导,取FC_TARGETS[0]兜底)- 非阿里云 CNAME(Vercel / 其他 CDN)与
_开头记录(_dnsauth/_acme-challenge)跳过
merge_targets(manual, auto):手工优先,自动发现按domain去重追加;分发循环遍历合并结果; 提供--no-discover退回纯手工模式。 ⚠️ 去重集合里必须放domain字符串,不能放 target 字典本身:
写成seen = {t['domain'] for t in manual} for t in auto: if t['domain'] not in seen: out.append(t) seen.add(t['domain']) # 写成 seen.add(t) -> TypeError: unhashable type: 'dict'seen.add(t)在生产上真炸过:整个分发 stage 抛异常,续期任务失败。- 效果:新建子域只需加一条 CNAME,下次定时任务自动写
_dnsauthTXT 验归属 + 绑cert_id+ 上 HTTPS。
七、必坑清单(按踩中概率排序)
-
迁移仓库 / 改 audience 必须三处同步,否则
AuthenticationFail.OIDCToken.AudienceNotMatch/Invalid audience.: ① workflow 的audience;② 角色信任策略的oidc:aud(update_role); ③ OIDC 提供商的ClientIds(IMSadd_client_id_to_oidcprovider/remove_client_id_from_oidcprovider)。 只改前两处是典型漏网。 -
官方 action 莫名
ImplicitDeny:aliyun/configure-aliyun-credentials-action在 RAM 全对时仍报NoPermission / ImplicitDeny / PolicyType: AssumeRolePolicy。改用第二节的手动兑换。get_role返回的Principal.Federated是数组,那是 RAM 规范化结果,不是 bug,别去改它。 -
oidc:sub别用StringEquals写死repo:<owner>/<repo>:*:GitHub 在 fork / 重跑 / 某些事件下会把 repo ID 与 owner ID 嵌进 sub,变成repo:oracis@4110185/myrepo@1368066874:ref:refs/heads/main。 用StringLike+repo:<owner>*/<repo>*:ref:refs/heads/main一套覆盖两种格式。 打印 JWT 的sub是定位此类问题的唯一快路径。 -
STS 偶发
InternalError 500:凭证指纹核对无误仍 500 时属瞬时故障,AssumeRoleWithOIDC加 3~5 次指数退避重试即可。 -
CAS 的 RAM 命名空间是
yundun-cert,不是cas:策略写cas:*覆盖不到UploadUserCertificate,报NoPermission / 403 / yundun-cert:UploadUserCertificate / AccountLevelIdentityBasedPolicy。 必须加yundun-cert:*。 -
oss2 用 STS 必须
oss2.StsAuth,不能把 token 当oss2.Auth第 3 个参数:oss2.Auth.__init__(access_key_id, access_key_secret)只收 2 个参数,传第 3 个直接TypeError: Auth.__init__() takes 3 positional arguments but 4 were given。 正确:oss2.StsAuth(ak, sk, stoken) if stoken else oss2.Auth(ak, sk)。 -
CAS 删除参数是
cert_id,不是certificate_id;且 SDK 查不到上传型证书: 写成certificate_id=会静默不删(被except吞掉),旧证书长期占用固定名称, 下次上传必报NameRepeat (code:400, 名称重复)。cas20200407只有list_user_certificate_order(仅购买订单),没有DescribeUserCertificateList, 所以state.json里的cert_id是定位旧证书的唯一途径。 稳健写法:上传前按 state 的cert_id删除(失败仅告警);若仍NameRepeat,用「固定名+时间戳」重传兜底。 -
签发与分发必须解耦,证书有效不能整体
sys.exit(0):否则上次分发失败后, 有效证书会永远卡在「已签发未分发」。用need_issue只控制签发,CAS 上传加sha256(pem)指纹守卫, 指纹一致就复用旧cert_id,避免每天重复创建 CAS 证书。 -
oss2
create_bucket第一个位置参数是permission(ACL 字符串),不是input(config 对象): 签名create_bucket(self, permission=None, input=None, headers=None)。 写create_bucket(oss2.models.BucketCreateConfig(oss2.BUCKET_ACL_PRIVATE))会把 config 塞进x-oss-aclheader,签名时TypeError: can't concat BucketCreateConfig to bytes。 正确:b.create_bucket(oss2.BUCKET_ACL_PRIVATE)(默认即私有桶)。BucketCreateConfig构造器也按 snake_case:storage_class/data_redundancy_type/acl。 -
cron 时区是 UTC:北京 03:30 =
30 19 * * *。schedule 有 ±15 分钟漂移,对续期无影响。wait_txt()等 DNS 轮询在 Linux runner 用dig(需dnsutils),Windows 无 dig 回退nslookup。 -
定时跑全绿 ≠ 管线健康:自动发现这类"只有出现新目标才会执行"的分支,平时根本走不到, 于是 bug 能在每天 success 的 run 里潜伏几个月。真实案例:
seen.add(t)(见第六节) 在新增第 3 个子域的当天才第一次崩(此前定时跑连续 5 天 success)。 任何一次改动域名/CNAME/目标清单后,都要手动workflow_dispatch跑一次, 别只看最近几次定时 run 的绿勾。日志里出现证书仍有效(剩 N 天),无需重新签发是正常路径,不是失败。 -
判断"这条流水线到底存不存在"时,必须连私有仓库一起扫:只列 public 仓库会漏掉
oracis/cert-renew这类私有基础设施仓库,从而误判"自动续期没搭"。 用gh repo list <owner> --private --limit 200或 API/user/repos?visibility=private。 另:本机gh常处于未登录状态(gh auth status→ not logged in), 可直接从~/.git-credentials取 token:TOKEN=$(sed -n 's#^https://\([^:]*\):\([^@]*\)@github.com#\2#p' ~/.git-credentials), 然后curl -H "Authorization: Bearer $TOKEN"调 API(记得先unset本机代理变量,见下)。
八、验收清单
跑一次 workflow,日志应依次出现:
OIDC claims -> iss=... aud=... sub=repo:<owner>/<repo>:ref:refs/heads/main
STS 临时凭证已获取
新证书已签发,有效期至 ... # 未到期时是「证书仍有效(剩 N 天),无需重新签发」,正常
已上传 CAS,cert_id = ... # 指纹未变时是「证书未变化(fp 一致),复用 cert_id=...」
DNS 自动发现 OSS 目标 N 个: ... # 新子域必须出现在这里
处理 OSS <domain> -> 已绑定自定义域名 # 自动发现的会带「(自动发现)」后缀
FC 自定义域名 <domain> 证书已更新
全部完成 / RENEW_OK
再去浏览器核对站点的证书颁发者(Let's Encrypt R11/R12)与到期时间即可。 验证 HTTPS 时不要用 HEAD:OSS 静态托管只重写 GET,HEAD 会返回 403, 浏览器和用户实际走 GET,属于误报。
附:脚本
scripts/setup_oidc.py—— 一次性建 OIDC 提供商 + 角色 + 权限策略(读环境变量里的永久 AK)。scripts/oidc_sts_env.py—— workflow 用:OIDC JWT →AssumeRoleWithOIDC→ 写$GITHUB_ENV。
