证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。
新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts 部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts 编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
→ 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts 新增 POST /ssl/issue、GET /ssl/renew-check、
POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron 每日 04:10 自动续期检查;cpu_ms 提到 60s
与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。
实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory
测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
/**
|
||
* DNS 提供商适配层 —— DNS-01 验证时增删 TXT 记录。
|
||
*
|
||
* 目前支持两家(覆盖三组域名的实际情况):
|
||
* · tencentcloud → DNSPod(usj.cc / t-t.live 的 DNS 都在腾讯云)
|
||
* · cloudflare → Cloudflare DNS(200181.xyz)
|
||
*
|
||
* ★ 两家的签名方式差异很大,这里各自封装成一个 `DnsProvider` 接口:
|
||
* addTxt(name, value) / delTxt(name, value) / listTxt(name)
|
||
*
|
||
* ★ 腾讯云走的是它统一网关的 TC3-HMAC-SHA256 签名(v3),不是 DNSPod 老版
|
||
* 的 secretId + 明文签名。参数全在 JSON body 里,签名串要覆盖
|
||
* `POST\n/\n\ncontent-type:...\nhost:...\n\ncontent-type;host\n<sha256(body)>`。
|
||
*/
|
||
|
||
import type { AccessRecord } from './certstore';
|
||
|
||
export interface DnsProvider {
|
||
/** 域名类型标签,报错时带上 */
|
||
readonly kind: string;
|
||
/** 写一条 TXT */
|
||
addTxt(fqdn: string, value: string): Promise<void>;
|
||
/** 删一条 TXT(值要匹配,避免删掉同名的其它记录) */
|
||
delTxt(fqdn: string, value: string): Promise<void>;
|
||
/** 列出某个名字下现有的 TXT 值(用于查重/清理残留) */
|
||
listTxt(fqdn: string): Promise<string[]>;
|
||
}
|
||
|
||
// ==================================================================== 工具
|
||
|
||
/**
|
||
* 把 FQDN 拆成「记录名 + 根域名」。
|
||
*
|
||
* ★ 为什么不能简单地按「最后两段」切:usj.cc 是两段,但 t-t.live 也是两段,
|
||
* 而如果以后接入 xxx.com.cn 这种,最后两段就是错的。这里用**已知根域名列表**
|
||
* 反查:从最长后缀开始匹配,命中即止。
|
||
*/
|
||
export function splitHost(fqdn: string, roots: string[]): { sub: string; root: string } {
|
||
const h = fqdn.replace(/\.$/, '').toLowerCase();
|
||
const sorted = [...roots].map((r) => r.toLowerCase()).sort((a, b) => b.length - a.length);
|
||
for (const r of sorted) {
|
||
if (h === r) return { sub: '@', root: r };
|
||
if (h.endsWith('.' + r)) return { sub: h.slice(0, -(r.length + 1)), root: r };
|
||
}
|
||
// 没匹配上就用最后两段兜底(多数情况也对)
|
||
const parts = h.split('.');
|
||
if (parts.length <= 2) return { sub: '@', root: h };
|
||
return { sub: parts.slice(0, -2).join('.'), root: parts.slice(-2).join('.') };
|
||
}
|
||
|
||
// ==================================================================== 腾讯云 DNSPod
|
||
|
||
const TC_HOST = 'dnspod.tencentcloudapi.com';
|
||
const TC_SERVICE = 'dnspod';
|
||
const TC_VERSION = '2021-03-23';
|
||
|
||
/** 腾讯云 TC3-HMAC-SHA256 签名(https://cloud.tencent.com/document/api/1427/56174) */
|
||
async function tc3Sign(
|
||
secretId: string,
|
||
secretKey: string,
|
||
payload: string,
|
||
action: string,
|
||
): Promise<Record<string, string>> {
|
||
const timestamp = Math.floor(Date.now() / 1000);
|
||
const date = new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||
|
||
// ① 规范请求串
|
||
const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${TC_HOST}\n`;
|
||
const signedHeaders = 'content-type;host';
|
||
const hashedPayload = await sha256Hex(payload);
|
||
const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, hashedPayload].join('\n');
|
||
|
||
// ② 待签字符串
|
||
const credentialScope = `${date}/${TC_SERVICE}/tc3_request`;
|
||
const hashedCanonical = await sha256Hex(canonicalRequest);
|
||
const stringToSign = ['TC3-HMAC-SHA256', String(timestamp), credentialScope, hashedCanonical].join('\n');
|
||
|
||
// ③ 逐层派生签名密钥
|
||
const kDate = await hmacSha256(enc(`TC3${secretKey}`), date);
|
||
const kService = await hmacSha256(kDate, TC_SERVICE);
|
||
const kSigning = await hmacSha256(kService, 'tc3_request');
|
||
const signature = bufToHex(await hmacSha256(kSigning, stringToSign));
|
||
|
||
const authorization =
|
||
`TC3-HMAC-SHA256 Credential=${secretId}/${credentialScope}, ` +
|
||
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||
|
||
return {
|
||
Authorization: authorization,
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
Host: TC_HOST,
|
||
'X-TC-Action': action,
|
||
'X-TC-Version': TC_VERSION,
|
||
'X-TC-Timestamp': String(timestamp),
|
||
};
|
||
}
|
||
|
||
export class TencentDns implements DnsProvider {
|
||
readonly kind = 'tencentcloud';
|
||
private roots: string[] = [];
|
||
|
||
constructor(
|
||
private readonly secretId: string,
|
||
private readonly secretKey: string,
|
||
) {}
|
||
|
||
private async call<T>(action: string, payload: Record<string, unknown>): Promise<T> {
|
||
const body = JSON.stringify(payload);
|
||
const headers = await tc3Sign(this.secretId, this.secretKey, body, action);
|
||
const r = await fetch(`https://${TC_HOST}/`, { method: 'POST', headers, body });
|
||
const text = await r.text();
|
||
let d: { Response?: { Error?: { Code?: string; Message?: string }; [k: string]: unknown } };
|
||
try {
|
||
d = JSON.parse(text);
|
||
} catch {
|
||
throw new Error(`DNSPod 返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`);
|
||
}
|
||
const err = d.Response?.Error;
|
||
if (err) throw new Error(`DNSPod ${action} 失败:${err.Code || ''} ${err.Message || ''}`);
|
||
return d.Response as T;
|
||
}
|
||
|
||
/** 根域名列表(缓存在实例上)—— 需要它才能把 fqdn 拆成 sub + root */
|
||
private async loadRoots(): Promise<string[]> {
|
||
if (this.roots.length) return this.roots;
|
||
const res = await this.call<{ DomainList?: { Name: string }[] }>('DescribeDomainList', {});
|
||
this.roots = (res.DomainList || []).map((d) => d.Name);
|
||
return this.roots;
|
||
}
|
||
|
||
async addTxt(fqdn: string, value: string): Promise<void> {
|
||
const roots = await this.loadRoots();
|
||
const { sub, root } = splitHost(fqdn, roots);
|
||
await this.call('CreateRecord', {
|
||
Domain: root,
|
||
SubDomain: sub === '@' ? '@' : sub,
|
||
RecordType: 'TXT',
|
||
RecordLine: '默认',
|
||
Value: value,
|
||
TTL: 600,
|
||
});
|
||
}
|
||
|
||
async delTxt(fqdn: string, value: string): Promise<void> {
|
||
const roots = await this.loadRoots();
|
||
const { sub, root } = splitHost(fqdn, roots);
|
||
// 先查同名同值的记录 id,再逐条删 —— 直接删整个名字会误伤别的平台写的
|
||
const res = await this.call<{ RecordList?: { RecordId: number; Value: string }[] }>('DescribeRecordList', {
|
||
Domain: root,
|
||
Subdomain: sub === '@' ? '@' : sub,
|
||
RecordType: 'TXT',
|
||
});
|
||
for (const rec of res.RecordList || []) {
|
||
// DNSPod 会把值里的 `"` 转义保留,比较前统一去引号
|
||
if (rec.Value.replace(/^"|"$/g, '') === value) {
|
||
await this.call('DeleteRecord', { Domain: root, RecordId: rec.RecordId });
|
||
}
|
||
}
|
||
}
|
||
|
||
async listTxt(fqdn: string): Promise<string[]> {
|
||
const roots = await this.loadRoots();
|
||
const { sub, root } = splitHost(fqdn, roots);
|
||
const res = await this.call<{ RecordList?: { Value: string }[] }>('DescribeRecordList', {
|
||
Domain: root,
|
||
Subdomain: sub === '@' ? '@' : sub,
|
||
RecordType: 'TXT',
|
||
});
|
||
return (res.RecordList || []).map((r) => r.Value.replace(/^"|"$/g, ''));
|
||
}
|
||
}
|
||
|
||
// ==================================================================== Cloudflare
|
||
|
||
export class CloudflareDns implements DnsProvider {
|
||
readonly kind = 'cloudflare';
|
||
|
||
constructor(
|
||
private readonly apiToken: string,
|
||
/**
|
||
* 可选的固定 Zone ID。
|
||
*
|
||
* ★ 为什么需要这个(2026-10-06 实测踩到):Cloudflare 的 API Token 可以
|
||
* 被限制成**「只能操作某几个 zone」**,这种 token 调 `GET /zones`
|
||
* (不带 name)会返回 **200 + 空数组**,而不是报错 —— 看权限「没问题」,
|
||
* 但 `zoneIdFor()` 永远查不到 zone,DNS-01 就会一直失败。
|
||
* 配上固定 Zone ID 就能绕开列举这一步(`/zones/<id>/dns_records` 只要
|
||
* 该 zone 在授权范围内就能用)。
|
||
*/
|
||
private readonly fixedZoneId?: string,
|
||
private readonly fixedZoneName?: string,
|
||
) {}
|
||
|
||
private async call<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||
const r = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
||
...init,
|
||
headers: {
|
||
Authorization: `Bearer ${this.apiToken}`,
|
||
'Content-Type': 'application/json',
|
||
...(init.headers || {}),
|
||
},
|
||
});
|
||
const text = await r.text();
|
||
let d: { success?: boolean; errors?: { code?: number; message?: string }[]; result?: unknown };
|
||
try {
|
||
d = JSON.parse(text);
|
||
} catch {
|
||
throw new Error(`Cloudflare 返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`);
|
||
}
|
||
if (!d.success) {
|
||
const errs = d.errors || [];
|
||
const msg = errs.map((e) => `${e.code ?? ''} ${e.message ?? ''}`.trim()).join('; ');
|
||
// ★ 403 在 DNS 这条路上几乎只有两个原因,直接说清楚省得来回猜
|
||
if (r.status === 403) {
|
||
throw new Error(
|
||
`Cloudflare 拒绝访问(403):${msg || '权限不足'} —— ` +
|
||
`该 Token 缺少「Zone → DNS → Edit」权限,或未把域名加入 Token 的 Zone Resources`,
|
||
);
|
||
}
|
||
throw new Error(`Cloudflare 调用失败(HTTP ${r.status}):${msg}`);
|
||
}
|
||
return d.result as T;
|
||
}
|
||
|
||
/** 从 fqdn 里找出属于本 token 的 zone id */
|
||
private async zoneIdFor(fqdn: string): Promise<{ zoneId: string; root: string }> {
|
||
// ① 配置里写死了 zone id → 直接用,连列举都省了
|
||
if (this.fixedZoneId) {
|
||
return { zoneId: this.fixedZoneId, root: this.fixedZoneName || fqdn };
|
||
}
|
||
|
||
const parts = fqdn.replace(/\.$/, '').toLowerCase().split('.');
|
||
// ② 从最长后缀往短试:先试 a.b.c.com,再 b.c.com,再 c.com
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
const guess = parts.slice(i).join('.');
|
||
const zones = await this.call<{ id: string; name: string }[]>(`/zones?name=${encodeURIComponent(guess)}`);
|
||
if (zones && zones.length) return { zoneId: zones[0].id, root: zones[0].name };
|
||
}
|
||
|
||
// ③ 走到这里通常是「token 被限制到具体 zone」或「缺 Zone:Read」——
|
||
// 两者都表现为「列举返回空」,但处置完全不同,所以把话写全。
|
||
throw new Error(
|
||
`Cloudflare 里找不到 ${fqdn} 所属的 Zone。` +
|
||
`常见原因:① Token 缺少「Zone → Zone → Read」权限(列举会是空数组而不报错);` +
|
||
`② Token 的 Zone Resources 没包含这个域名;` +
|
||
`③ 该域名的 DNS 并不托管在这个 Cloudflare 账号下。` +
|
||
`若不确定,可在凭据里直接填 zoneId 跳过列举。`,
|
||
);
|
||
}
|
||
|
||
async addTxt(fqdn: string, value: string): Promise<void> {
|
||
const { zoneId } = await this.zoneIdFor(fqdn);
|
||
await this.call(`/zones/${zoneId}/dns_records`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ type: 'TXT', name: fqdn, content: value, ttl: 120 }),
|
||
});
|
||
}
|
||
|
||
async delTxt(fqdn: string, value: string): Promise<void> {
|
||
const { zoneId } = await this.zoneIdFor(fqdn);
|
||
const recs = await this.call<{ id: string; content: string }[]>(
|
||
`/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(fqdn)}`,
|
||
);
|
||
for (const rec of recs || []) {
|
||
// CF 会自己加引号,比较时剥掉
|
||
if (rec.content.replace(/^"|"$/g, '') === value) {
|
||
await this.call(`/zones/${zoneId}/dns_records/${rec.id}`, { method: 'DELETE' });
|
||
}
|
||
}
|
||
}
|
||
|
||
async listTxt(fqdn: string): Promise<string[]> {
|
||
const { zoneId } = await this.zoneIdFor(fqdn);
|
||
const recs = await this.call<{ content: string }[]>(
|
||
`/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(fqdn)}`,
|
||
);
|
||
return (recs || []).map((r) => r.content.replace(/^"|"$/g, ''));
|
||
}
|
||
}
|
||
|
||
// ==================================================================== 工厂
|
||
|
||
/**
|
||
* 按凭据记录造一个 DNS 客户端。
|
||
* ★ 只认识 tencentcloud / cloudflare 两类;其余类型抛错而不是静默返回 null ——
|
||
* 静默返回会让「配置错了」表现为「验证一直 pending 到超时」,排查起来很痛苦。
|
||
*/
|
||
export function makeDnsProvider(rec: AccessRecord): DnsProvider {
|
||
const t = String(rec.type || '');
|
||
if (t === 'tencentcloud') {
|
||
const id = String(rec.secretId || '');
|
||
const key = String(rec.secretKey || '');
|
||
if (!id || !key) throw new Error('腾讯云凭据缺少 secretId / secretKey');
|
||
return new TencentDns(id, key);
|
||
}
|
||
if (t === 'cloudflare') {
|
||
const token = String(rec.apiToken || '');
|
||
if (!token) throw new Error('Cloudflare 凭据缺少 apiToken');
|
||
// zoneId / zoneName 可选;填了就不去列举 zone(见 CloudflareDns 构造器注释)
|
||
return new CloudflareDns(
|
||
token,
|
||
rec.zoneId ? String(rec.zoneId) : undefined,
|
||
rec.zoneName ? String(rec.zoneName) : undefined,
|
||
);
|
||
}
|
||
throw new Error(`DNS-01 不支持凭据类型「${t}」(目前只支持 tencentcloud / cloudflare)`);
|
||
}
|
||
|
||
// ==================================================================== crypto 小工具
|
||
|
||
const enc = (s: string) => new TextEncoder().encode(s);
|
||
|
||
async function sha256Hex(s: string): Promise<string> {
|
||
return bufToHex(await crypto.subtle.digest('SHA-256', enc(s)));
|
||
}
|
||
|
||
async function hmacSha256(key: ArrayBuffer | Uint8Array, data: string): Promise<ArrayBuffer> {
|
||
// ★ TS 5.7 起 Uint8Array 是泛型(Uint8Array<ArrayBufferLike>),
|
||
// 而 crypto.subtle.importKey 要 ArrayBufferView<ArrayBuffer>。
|
||
// 统一转成 Uint8Array 再取 .buffer(保证是 ArrayBuffer,不是 SharedArrayBuffer)。
|
||
const bytes = key instanceof Uint8Array ? key : new Uint8Array(key);
|
||
const raw = new Uint8Array(bytes).buffer as ArrayBuffer;
|
||
const k = await crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||
return crypto.subtle.sign('HMAC', k, enc(data));
|
||
}
|
||
|
||
function bufToHex(buf: ArrayBuffer): string {
|
||
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|