feat(ssl): ACME 自动签发与自动续期,实现证书全生命周期闭环
证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 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 干净
This commit is contained in:
1 parent
432cf5e398
commit
43dbc8b75f
14 files changed
+3010
-6
No files matched your search
@@ -12,6 +12,7 @@ import * as A from './routes/admin';
|
||||
import * as H from './routes/human';
|
||||
import * as E from './routes/editor';
|
||||
import * as S from './routes/ssl';
|
||||
import { renewAll } from './lib/certissue';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -232,11 +233,25 @@ export default {
|
||||
|
||||
// 定时任务:0 * * * * = RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源)
|
||||
// 17 3 * * * = 评论 GC(限流/验证码/healthz 缓存)
|
||||
// 10 4 * * * = 证书续期检查(探针判定,≤30 天才签)
|
||||
async scheduled(event: ScheduledController, env: Env): Promise<void> {
|
||||
if (event.cron === '0 * * * *') {
|
||||
await RssApi.scheduled(event, env, undefined as unknown as ExecutionContext);
|
||||
return;
|
||||
}
|
||||
if (event.cron === '10 4 * * *') {
|
||||
// 证书续期:每天跑一次,但**只有探针查到剩余 ≤30 天才真正签发**。
|
||||
// 不无条件重签的原因见 lib/certissue.ts 头注释(会白烧 CA 限速额度)。
|
||||
try {
|
||||
const outcomes = await renewAll(env, { by: '自动续期' });
|
||||
for (const o of outcomes) {
|
||||
console.log(`[certkeeper] ${o.domain}: ${o.ok ? '✓' : '✗'} ${o.reason}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[certkeeper] scheduled renew failed:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await gcRateLimits(env);
|
||||
await gcCaptcha(env);
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* ACME v2 客户端 —— 纯 WebCrypto + fetch,零依赖。
|
||||
*
|
||||
* 为什么自己写而不引 npm 包(acme-client / acme-js 等):
|
||||
* · Workers 里跑,包的体积直接进冷启动;acme-client 依赖 node:crypto 的
|
||||
* 一堆 Node 专属 API,在 Workers 上要么跑不了要么要 shim;
|
||||
* · 我们要的能力很窄:ECDSA 账户密钥 + DNS-01 + 签发 + 下载链,
|
||||
* RFC 8555 那几百行核心逻辑自己写反而更可控(也更好排错)。
|
||||
*
|
||||
* ★ 实现要点(每条都踩过或差点踩):
|
||||
*
|
||||
* ① **JWS 用 ES256**:WebCrypto 的 `subtle.sign('ECDSA')` 返回的是
|
||||
* **原始 r||s(64 字节)**,而 JWS 的 ES256 要的正是这个格式 ——
|
||||
* 不是 DER。所以**不要**再包一层 DER 编码(很多 Node 实现要转,Workers 不要)。
|
||||
*
|
||||
* ② **JWK thumbprint**:RFC 7638 规定,必须按**字典序**取字段拼
|
||||
* `{"crv":...,"kty":...,"x":...,"y":...}`(不能带别的字段、不能有空格),
|
||||
* 再做 SHA-256 + base64url。`kid` 就是它。拼错一个字,整个符合验签必挂。
|
||||
*
|
||||
* ③ **重放随机数**:每个请求必须带**新的** nonce(服务端给一次用一次)。
|
||||
* 我们从 `Replay-Nonce` 响应头拿;拿不到时**不能瞎编**,得去 newNonce
|
||||
* 端点要一个。这里统一在 `post()` 里用「先用缓存、无则现取」。
|
||||
*
|
||||
* ④ **badNonce 要重试**:网络抖动会让服务端认为 nonce 已用过。ACME 规范
|
||||
* 明确要求客户端遇到 `urn:ietf:params:acme:error:badNonce` **重试**。
|
||||
* 不重试就会偶发失败 —— 而续期是无人值守的,偶发失败=证书过期。
|
||||
*
|
||||
* ⑤ **EAB(External Account Binding)**:LiteSSL / ZeroSSL 这类商用 CA 要求
|
||||
* 注册时用 CA 给的 kid + HMAC key 签一个内层 JWS。内层用 HS256
|
||||
* (`subtle.importKey('raw', ..., {name:'HMAC', hash:'SHA-256'})`)。
|
||||
*
|
||||
* ⑥ **DNS-01 的 key authorization**:`token + '.' + thumbprint` 再做
|
||||
* SHA-256 的 **base64url**(RFC 8555 §8.4)。注意是 base64**url**,
|
||||
* 服务端比对的正是这个串,用标准 base64 会一直 pending 到超时。
|
||||
*/
|
||||
|
||||
// ==================================================================== 类型
|
||||
|
||||
export interface AcmeAccount {
|
||||
/** 账户私钥(JWK 形式长期保存,比 PEM 好管理) */
|
||||
jwk: JsonWebKey;
|
||||
/** 账户 URL(kid),签发时必须带 */
|
||||
kid: string;
|
||||
/** 服务端目录地址,账户与 CA 绑定 */
|
||||
directoryUrl: string;
|
||||
}
|
||||
|
||||
export interface AcmeIssueResult {
|
||||
/** 证书链 PEM(叶 + 中间) */
|
||||
cert: string;
|
||||
/** 私钥 PEM */
|
||||
key: string;
|
||||
/** 实际签发了哪些域名 */
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export interface AcmeLogger {
|
||||
(msg: string): void;
|
||||
}
|
||||
|
||||
interface Directory {
|
||||
newNonce: string;
|
||||
newAccount: string;
|
||||
newOrder: string;
|
||||
/** ACME v2 用 `revokeCert` 之外,下载走订单自身的 certificate 字段 */
|
||||
keyChange?: string;
|
||||
revokeCert?: string;
|
||||
}
|
||||
|
||||
// ==================================================================== 小工具
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
/**
|
||||
* `crypto.subtle.exportKey` 的返回类型是 `ArrayBuffer | JsonWebKey`,
|
||||
* 按 format 字符串字面量收窄不了。这里包一层断言,
|
||||
* 省得每个调用点都写 `as`(也更清楚:'jwk' 出 JWK,其余出 ArrayBuffer)。
|
||||
*/
|
||||
const exportJwk = (k: CryptoKey): Promise<JsonWebKey> =>
|
||||
crypto.subtle.exportKey('jwk', k) as Promise<JsonWebKey>;
|
||||
const exportDer = (k: CryptoKey, format: 'pkcs8' | 'spki' | 'raw'): Promise<ArrayBuffer> =>
|
||||
crypto.subtle.exportKey(format, k) as Promise<ArrayBuffer>;
|
||||
|
||||
function b64u(bytes: ArrayBuffer | Uint8Array): string {
|
||||
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
let s = '';
|
||||
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function b64uJson(v: unknown): string {
|
||||
return b64u(enc.encode(JSON.stringify(v)));
|
||||
}
|
||||
|
||||
/** PEM 换行(64 列),末尾留换行 —— 多数软件(1Panel / nginx)都要求这样 */
|
||||
function toPem(der: ArrayBuffer, label: string): string {
|
||||
const b = new Uint8Array(der);
|
||||
let s = '';
|
||||
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
const b64 = btoa(s);
|
||||
const lines = b64.match(/.{1,64}/g) || [];
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
||||
}
|
||||
|
||||
// ==================================================================== ACME 客户端
|
||||
|
||||
export class AcmeClient {
|
||||
private dir: Directory | null = null;
|
||||
private nonce: string | null = null;
|
||||
private readonly log: AcmeLogger;
|
||||
|
||||
constructor(
|
||||
private readonly directoryUrl: string,
|
||||
private readonly account: { jwk: JsonWebKey; kid: string },
|
||||
log?: AcmeLogger,
|
||||
) {
|
||||
this.log = log || (() => {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- 底层请求
|
||||
|
||||
private async directory(): Promise<Directory> {
|
||||
if (this.dir) return this.dir;
|
||||
const r = await fetch(this.directoryUrl, { headers: { Accept: 'application/json' } });
|
||||
if (!r.ok) {
|
||||
// ★ 这个报错值得写细一点:2026-10-06 实测 `https://acme.trustasia.com/acme/directory`
|
||||
// 是 404(正确地址是 `.../acme/v2/directory`,中间少了 `/v2`)。
|
||||
// 目录地址写错是这一层最常见的故障,而默认报错只会给一句 HTTP 404,
|
||||
// 排查时容易误以为是网络/防火墙问题,所以这里把「URL 本身」顶到最前面。
|
||||
throw new Error(
|
||||
`ACME 目录不可达:HTTP ${r.status}(${this.directoryUrl})` +
|
||||
`—— 请核对 directoryUrl,多数 CA 的目录地址需要带版本段(如 …/acme/v2/directory)`,
|
||||
);
|
||||
}
|
||||
const d = (await r.json()) as Directory;
|
||||
if (!d.newNonce || !d.newAccount || !d.newOrder) {
|
||||
throw new Error(`ACME 目录结构异常(${this.directoryUrl}):缺少 newNonce/newAccount/newOrder`);
|
||||
}
|
||||
this.dir = d;
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 目录里是否声明「必须绑定外部账户(EAB)」——LiteSSL/ZeroSSL 都是 true */
|
||||
async externalAccountRequired(): Promise<boolean> {
|
||||
const d = (await this.directory()) as Directory & { meta?: { externalAccountRequired?: boolean } };
|
||||
return d.meta?.externalAccountRequired === true;
|
||||
}
|
||||
|
||||
private async takeNonce(): Promise<string> {
|
||||
if (this.nonce) {
|
||||
const n = this.nonce;
|
||||
this.nonce = null;
|
||||
return n;
|
||||
}
|
||||
const d = await this.directory();
|
||||
// HEAD 也能拿(规范允许),GET 更稳 —— 有些中间设备会把 HEAD 的 header 吞掉
|
||||
const r = await fetch(d.newNonce, { method: 'GET' });
|
||||
const n = r.headers.get('Replay-Nonce');
|
||||
if (!n) throw new Error('ACME 服务器没有返回 Replay-Nonce');
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 有 kid → 用 kid;没有(注册阶段)→ 用 jwk */
|
||||
private protectedHeader(nonce: string, url: string): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = { alg: 'ES256', nonce, url };
|
||||
if (this.account.kid) base.kid = this.account.kid;
|
||||
else base.jwk = this.account.jwk;
|
||||
return base;
|
||||
}
|
||||
|
||||
private async signJws(protectedHeader: Record<string, unknown>, payload: unknown): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
this.account.jwk,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
const signingInput = `${b64uJson(protectedHeader)}.${b64uJson(payload)}`;
|
||||
const sig = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
enc.encode(signingInput),
|
||||
);
|
||||
// ★ 不包 DER —— WebCrypto 已经是 JWS 要的 r||s
|
||||
return JSON.stringify({ protected: b64uJson(protectedHeader), payload: b64uJson(payload), signature: b64u(sig) });
|
||||
}
|
||||
|
||||
/** 发一个 POST;自动带 nonce、自动在 badNonce 时重试 */
|
||||
private async post(url: string, payload: unknown, opts: { useJwk?: boolean; retries?: number } = {}): Promise<Response> {
|
||||
const retries = opts.retries ?? 2;
|
||||
const dir = await this.directory();
|
||||
void dir;
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
// 注册时用 jwk(此时还没有 kid),其余用 kid。用临时对象绕开
|
||||
const savedKid = this.account.kid;
|
||||
if (opts.useJwk) this.account.kid = '';
|
||||
try {
|
||||
const nonce = await this.takeNonce();
|
||||
const body = await this.signJws(this.protectedHeader(nonce, url), payload);
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/jose+json', Accept: 'application/json' },
|
||||
body,
|
||||
});
|
||||
const n = r.headers.get('Replay-Nonce');
|
||||
if (n) this.nonce = n; // 缓存下一个 nonce,省一次往返
|
||||
if (r.ok) return r;
|
||||
|
||||
// 非 2xx:判断是不是 badNonce(可重试),其余直接抛
|
||||
const text = await r.text();
|
||||
let type = '';
|
||||
try {
|
||||
type = (JSON.parse(text) as { type?: string }).type || '';
|
||||
} catch {
|
||||
/* 不是 JSON 就按原文处理 */
|
||||
}
|
||||
if (type.includes('badNonce') && attempt < retries) {
|
||||
this.nonce = null; // 强制重新取
|
||||
continue;
|
||||
}
|
||||
throw new Error(`ACME 请求失败 HTTP ${r.status}:${text.slice(0, 300)}`);
|
||||
} finally {
|
||||
if (opts.useJwk) this.account.kid = savedKid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** POST-as-GET(RFC 8555 §6.3):读资源也要用 POST 签名,不能直接 GET */
|
||||
private async postAsGet(url: string): Promise<Response> {
|
||||
return this.post(url, ''); // payload 为空字符串(不是 null —— null 是「要服务端删掉字段」)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- 账户
|
||||
|
||||
/**
|
||||
* 注册(或找回)账户。
|
||||
* @param contact 邮箱,如 ['mailto:me@example.com'];可空
|
||||
* @param eab CA 要求的外部账户绑定(LiteSSL/ZeroSSL 必填)
|
||||
*/
|
||||
async registerAccount(contact: string[], eab?: { kid: string; hmacKeyB64: string }): Promise<string> {
|
||||
const d = await this.directory();
|
||||
const payload: Record<string, unknown> = {
|
||||
termsOfServiceAgreed: true,
|
||||
...(contact.length ? { contact } : {}),
|
||||
};
|
||||
|
||||
if (eab) {
|
||||
// ★ 内层 JWS:protected 只放 alg/kid/url,payload 是账户 JWK 本身
|
||||
const innerProtected = b64uJson({ alg: 'HS256', kid: eab.kid, url: d.newAccount });
|
||||
const innerPayload = b64uJson(this.account.jwk);
|
||||
const rawKey = Uint8Array.from(atob(eab.hmacKeyB64.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||
const hmacKey = await crypto.subtle.importKey('raw', rawKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const innerSig = await crypto.subtle.sign('HMAC', hmacKey, enc.encode(`${innerProtected}.${innerPayload}`));
|
||||
payload.externalAccountBinding = {
|
||||
protected: innerProtected,
|
||||
payload: innerPayload,
|
||||
signature: b64u(innerSig),
|
||||
};
|
||||
}
|
||||
|
||||
const r = await this.post(d.newAccount, payload, { useJwk: true });
|
||||
// ★ 账户已存在时服务端回 200 + Location(不是 201),一样取 Location
|
||||
const kid = r.headers.get('Location');
|
||||
if (!kid) throw new Error('ACME 注册成功但没有返回 Location(拿不到账户 URL)');
|
||||
this.account.kid = kid;
|
||||
return kid;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- 签发
|
||||
|
||||
/**
|
||||
* DNS-01 签发。
|
||||
*
|
||||
* @param domains 要签的域名(第一个作为 CN,其余进 SAN)
|
||||
* @param setTxt 写 TXT 记录:`(name, value) => Promise<void>`
|
||||
* @param clearTxt 删 TXT 记录(失败不影响签发,只记日志)
|
||||
* @param opts.waitSeconds 写完后等多久让 DNS 生效(默认 30s,DNSPod 一般 10s 内)
|
||||
*/
|
||||
async issueDns01(
|
||||
domains: string[],
|
||||
setTxt: (name: string, value: string) => Promise<void>,
|
||||
clearTxt: (name: string, value: string) => Promise<void>,
|
||||
opts: { waitSeconds?: number; timeoutMs?: number } = {},
|
||||
): Promise<AcmeIssueResult> {
|
||||
if (!domains.length) throw new Error('至少要一个域名');
|
||||
const d = await this.directory();
|
||||
const thumbprint = await this.jwkThumbprint();
|
||||
|
||||
// ① 下订单
|
||||
const orderRes = await this.post(d.newOrder, { identifiers: domains.map((v) => ({ type: 'dns', value: v })) });
|
||||
const order = (await orderRes.json()) as {
|
||||
status: string;
|
||||
authorizations: string[];
|
||||
finalize: string;
|
||||
certificate?: string;
|
||||
};
|
||||
const orderUrl = orderRes.headers.get('Location') || '';
|
||||
this.log(`订单已创建(${domains.join(', ')}),状态 ${order.status}`);
|
||||
|
||||
// ② 逐个授权:写 DNS → 通知就绪 → 轮询
|
||||
const written: { name: string; value: string }[] = [];
|
||||
try {
|
||||
for (const authzUrl of order.authorizations) {
|
||||
const authz = (await (await this.postAsGet(authzUrl)).json()) as {
|
||||
status: string;
|
||||
identifier: { value: string };
|
||||
challenges: { type: string; url: string; token: string; status: string }[];
|
||||
};
|
||||
if (authz.status === 'valid') {
|
||||
this.log(`${authz.identifier.value} 已授权(跳过)`);
|
||||
continue;
|
||||
}
|
||||
const chal = authz.challenges.find((c) => c.type === 'dns-01');
|
||||
if (!chal) throw new Error(`${authz.identifier.value} 没有 dns-01 挑战(CA 不支持 DNS 验证?)`);
|
||||
|
||||
// ★ key authorization:token + '.' + thumbprint,再做 SHA-256 并 base64url
|
||||
const keyAuth = `${chal.token}.${thumbprint}`;
|
||||
const digest = await crypto.subtle.digest('SHA-256', enc.encode(keyAuth));
|
||||
const txtValue = b64u(digest);
|
||||
const recName = `_acme-challenge.${stripWildcard(authz.identifier.value)}`;
|
||||
|
||||
this.log(`写 TXT:${recName} = ${txtValue.slice(0, 16)}…`);
|
||||
await setTxt(recName, txtValue);
|
||||
written.push({ name: recName, value: txtValue });
|
||||
|
||||
// ③ 等 DNS 传播 —— 不给自己留这个时间,验证会一直 pending 到超时
|
||||
const wait = opts.waitSeconds ?? 30;
|
||||
if (wait > 0) {
|
||||
this.log(`等 ${wait}s 让 DNS 生效…`);
|
||||
await sleep(wait * 1000);
|
||||
}
|
||||
|
||||
// ④ 通知 CA 开始验证
|
||||
await this.post(chal.url, {});
|
||||
await this.pollAuthz(authzUrl, opts.timeoutMs ?? 180_000);
|
||||
}
|
||||
|
||||
// ⑤ finalize:CSR
|
||||
const certKeyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
])) as CryptoKeyPair;
|
||||
const csrDer = await makeCsr(certKeyPair, domains);
|
||||
this.log('提交 CSR…');
|
||||
await this.post(order.finalize, { csr: b64u(csrDer) });
|
||||
|
||||
// ⑥ 轮询订单直到 valid,然后下载证书链
|
||||
const certUrl = await this.pollOrder(orderUrl, opts.timeoutMs ?? 180_000);
|
||||
const certRes = await this.postAsGet(certUrl);
|
||||
const certPem = await certRes.text();
|
||||
|
||||
// ★ exportKey('pkcs8') 的 TS 重载返回 ArrayBuffer | JsonWebKey(因为 format 是联合),
|
||||
// 这里 format 已确定是 pkcs8,用 exportDer 包装断言回 ArrayBuffer。
|
||||
const pkcs8 = await exportDer(certKeyPair.privateKey, 'pkcs8');
|
||||
const keyPem = toPem(pkcs8, 'PRIVATE KEY');
|
||||
|
||||
this.log('证书已签发 ✓');
|
||||
return { cert: certPem.trim() + '\n', key: keyPem, domains };
|
||||
} finally {
|
||||
// ⑦ 无论成败都清理 TXT —— 留着 `_acme-challenge` 会干扰下次验证
|
||||
for (const w of written) {
|
||||
try {
|
||||
await clearTxt(w.name, w.value);
|
||||
} catch (e) {
|
||||
this.log(`清理 TXT 失败(不影响签发):${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pollAuthz(url: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const a = (await (await this.postAsGet(url)).json()) as {
|
||||
status: string;
|
||||
identifier: { value: string };
|
||||
challenges: { type: string; error?: { detail?: string } }[];
|
||||
};
|
||||
if (a.status === 'valid') return;
|
||||
if (a.status === 'invalid') {
|
||||
const err = a.challenges.find((c) => c.error)?.error?.detail;
|
||||
throw new Error(`${a.identifier.value} 验证失败:${err || '未知原因(多半是 TXT 没生效或值不对)'}`);
|
||||
}
|
||||
if (Date.now() > deadline) throw new Error(`${a.identifier.value} 验证超时(${timeoutMs / 1000}s)`);
|
||||
await sleep(3000);
|
||||
}
|
||||
}
|
||||
|
||||
private async pollOrder(url: string, timeoutMs: number): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const o = (await (await this.postAsGet(url)).json()) as {
|
||||
status: string;
|
||||
certificate?: string;
|
||||
error?: { detail?: string };
|
||||
};
|
||||
if (o.status === 'valid' && o.certificate) return o.certificate;
|
||||
if (o.status === 'invalid') throw new Error(`订单失败:${o.error?.detail || '未知原因'}`);
|
||||
if (Date.now() > deadline) throw new Error(`订单超时(${timeoutMs / 1000}s)`);
|
||||
await sleep(3000);
|
||||
}
|
||||
}
|
||||
|
||||
/** RFC 7638 JWK thumbprint —— 字段必须按字典序、不能多不能少 */
|
||||
async jwkThumbprint(): Promise<string> {
|
||||
const j = this.account.jwk;
|
||||
const canonical = JSON.stringify({ crv: j.crv, kty: j.kty, x: j.x, y: j.y });
|
||||
return b64u(await crypto.subtle.digest('SHA-256', enc.encode(canonical)));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================== 辅助
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function stripWildcard(host: string): string {
|
||||
return host.startsWith('*.') ? host.slice(2) : host;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一个全新的 ACME 账户密钥(ECDSA P-256)。
|
||||
*
|
||||
* ★ 用 EC 而不是 RSA:Workers 的 CPU 时间有限,RSA-2048 生成在冷启动时
|
||||
* 可能要几百毫秒到 1 秒,EC 只要几毫秒。ACME 两者都接受。
|
||||
*/
|
||||
export async function newAccountKey(): Promise<JsonWebKey> {
|
||||
const kp = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
])) as CryptoKeyPair;
|
||||
return exportJwk(kp.privateKey);
|
||||
}
|
||||
|
||||
// ==================================================================== CSR(手写 DER)
|
||||
|
||||
/**
|
||||
* 生成 PKCS#10 CSR(DER)。
|
||||
*
|
||||
* ★ 为什么不引 asn1.js / pkijs:我们只需要一种固定的结构
|
||||
* (1 个 CN + N 个 SAN,签名算法 ECDSA-SHA256),硬编码 DER 模板
|
||||
* 比引一个几百 KB 的 ASN.1 库划算得多,也少一个供应链面。
|
||||
*
|
||||
* DER 结构(RFC 2986):
|
||||
* CertificationRequest ::= SEQUENCE {
|
||||
* certificationRequestInfo SEQUENCE {
|
||||
* version INTEGER (0),
|
||||
* subject Name, -- CN=<第一个域名>
|
||||
* subjectPKInfo SubjectPublicKeyInfo,
|
||||
* attributes [0] { -- 里面塞 extensionRequest(SAN)
|
||||
* SEQUENCE { OID 1.2.840.113549.1.9.14, SET { SEQUENCE { SAN 扩展 } } }
|
||||
* }
|
||||
* },
|
||||
* signatureAlgorithm SEQUENCE { OID ecdsa-with-SHA256 },
|
||||
* signature BIT STRING
|
||||
* }
|
||||
*/
|
||||
async function makeCsr(keyPair: CryptoKeyPair, domains: string[]): Promise<ArrayBuffer> {
|
||||
const cn = domains[0];
|
||||
const pub = await exportJwk(keyPair.publicKey);
|
||||
const x = b64uToBytes(pub.x!);
|
||||
const y = b64uToBytes(pub.y!);
|
||||
|
||||
// ---- SubjectPublicKeyInfo ----
|
||||
const spki = seq(
|
||||
// AlgorithmIdentifier: id-ecPublicKey(1.2.840.10045.2.1) + prime256v1(1.2.840.10045.3.1.7)
|
||||
seq(oid('1.2.840.10045.2.1'), oid('1.2.840.10045.3.1.7')),
|
||||
// BIT STRING 里是 0x04 || X || Y(未压缩点)
|
||||
bitStr(concat(new Uint8Array([0x04]), x, y)),
|
||||
);
|
||||
|
||||
// ---- SAN 扩展(2.5.29.17)----
|
||||
//
|
||||
// ★ 这里有个**极容易多套一层 SEQUENCE** 的坑(实测踩过,openssl 报
|
||||
// `wrong tag ... Field=object, Type=X509_ATTRIBUTE`)。逐字节对齐
|
||||
// `openssl req -new` 的产物后,正确结构是:
|
||||
//
|
||||
// attributes [0] { -- a0 2e
|
||||
// SEQUENCE { -- 30 2c Attribute
|
||||
// OID 1.2.840.113549.1.9.14 -- 06 09… Extension Request
|
||||
// SET { -- 31 1f
|
||||
// SEQUENCE { -- 30 1d Extensions
|
||||
// SEQUENCE { -- 30 1b SAN 扩展本身
|
||||
// OID 2.5.29.17,
|
||||
// OCTET STRING { SEQUENCE OF GeneralName }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 关键点:`[0]` 里**直接**就是 Attribute 的 SEQUENCE —— 写成
|
||||
// `rawTag(0xa0, seq(seq(...)))` 会多一层,OpenSSL 就会拿
|
||||
// SAN 的 OID 去当 attribute type 查表,于是一路 `nested asn1 err`。
|
||||
const sanNames = domains.map((d) => rawTag(0x82, enc.encode(d))); // [2] dNSName
|
||||
const sanExt = seq(
|
||||
oid('2.5.29.17'),
|
||||
// extnValue 里包的才是真正的 SEQUENCE OF GeneralName
|
||||
octetStr(seq(...sanNames)),
|
||||
);
|
||||
const extReq = rawTag(0xa0, seq(oid('1.2.840.113549.1.9.14'), setOf(seq(sanExt))));
|
||||
|
||||
// ---- CertificationRequestInfo ----
|
||||
const cri = seq(
|
||||
int(0), // version
|
||||
// Name: 相对专有名词 [SET { SEQUENCE { OID commonName, UTF8String } }]
|
||||
seq(setOf(seq(oid('2.5.4.3'), rawTag(0x0c, enc.encode(cn))))),
|
||||
spki,
|
||||
extReq,
|
||||
);
|
||||
|
||||
// ---- 签名 ----
|
||||
// ★ TS 5.7 起 Uint8Array 是泛型(Uint8Array<ArrayBufferLike>),而 subtle.sign
|
||||
// 要的是 BufferSource 里收窄过的 ArrayBufferView<ArrayBuffer>。
|
||||
// 复制成一份确定 backing 的 buffer 即可消除 SharedArrayBuffer 的可能性。
|
||||
const criBytes = new Uint8Array(cri).buffer as ArrayBuffer;
|
||||
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, criBytes);
|
||||
// ★ CSR 的签名要 **DER 编码的 ECDSA-Sig-Value**(SEQUENCE { r, s }),
|
||||
// 与 JWS 的原始 r||s 不同 —— 这里必须转。
|
||||
const r = trimLeadingZeros(new Uint8Array(sig).slice(0, 32));
|
||||
const s = trimLeadingZeros(new Uint8Array(sig).slice(32));
|
||||
const derSig = seq(intBytes(r), intBytes(s));
|
||||
|
||||
const csr = seq(cri, seq(oid('1.2.840.10045.4.3.2')), bitStr(derSig));
|
||||
return csr.buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
// ---- 极简 DER 编码器 ----
|
||||
|
||||
function concat(...arrs: Uint8Array[]): Uint8Array {
|
||||
const total = arrs.reduce((n, a) => n + a.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const a of arrs) {
|
||||
out.set(a, off);
|
||||
off += a.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按 DER 长度规则包装:短形式(<128)或长形式 */
|
||||
function wrap(tag: number, content: Uint8Array): Uint8Array {
|
||||
const len = content.length;
|
||||
let lenBytes: Uint8Array;
|
||||
if (len < 0x80) lenBytes = new Uint8Array([len]);
|
||||
else if (len < 0x100) lenBytes = new Uint8Array([0x81, len]);
|
||||
else if (len < 0x10000) lenBytes = new Uint8Array([0x82, len >> 8, len & 0xff]);
|
||||
else lenBytes = new Uint8Array([0x83, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff]);
|
||||
return concat(new Uint8Array([tag]), lenBytes, content);
|
||||
}
|
||||
|
||||
const seq = (...parts: Uint8Array[]) => wrap(0x30, concat(...parts));
|
||||
const setOf = (...parts: Uint8Array[]) => wrap(0x31, concat(...parts));
|
||||
const octetStr = (b: Uint8Array) => wrap(0x04, b);
|
||||
const bitStr = (b: Uint8Array) => wrap(0x03, concat(new Uint8Array([0x00]), b)); // 0 unused bits
|
||||
const rawTag = (tag: number, b: Uint8Array) => wrap(tag, b);
|
||||
|
||||
/** OID 编码:第一字节 = 40*a+b,其余按 7 位变长 */
|
||||
function oid(dotted: string): Uint8Array {
|
||||
const parts = dotted.split('.').map(Number);
|
||||
const body: number[] = [40 * parts[0] + parts[1]];
|
||||
for (const v of parts.slice(2)) {
|
||||
const stack: number[] = [v & 0x7f];
|
||||
let x = v >> 7;
|
||||
while (x > 0) {
|
||||
stack.unshift((x & 0x7f) | 0x80);
|
||||
x >>= 7;
|
||||
}
|
||||
body.push(...stack);
|
||||
}
|
||||
return wrap(0x06, new Uint8Array(body));
|
||||
}
|
||||
|
||||
/** INTEGER:正数,最高位为 1 时要补 0x00 前缀 */
|
||||
function intBytes(bytes: Uint8Array): Uint8Array {
|
||||
const body = bytes[0] & 0x80 ? concat(new Uint8Array([0x00]), bytes) : bytes;
|
||||
return wrap(0x02, body);
|
||||
}
|
||||
|
||||
function int(v: number): Uint8Array {
|
||||
return intBytes(new Uint8Array([v]));
|
||||
}
|
||||
|
||||
function trimLeadingZeros(b: Uint8Array): Uint8Array {
|
||||
let i = 0;
|
||||
while (i < b.length - 1 && b[i] === 0) i++;
|
||||
return b.slice(i);
|
||||
}
|
||||
|
||||
function b64uToBytes(s: string): Uint8Array {
|
||||
const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 签发 / 续期编排 —— 证书管家的「执行」半边。
|
||||
*
|
||||
* 一次签发任务的完整链路:
|
||||
* 读配置 → 建 ACME 客户端(含 EAB)→ 注册/找回账户 → DNS-01 签发
|
||||
* → 落库(KV,密文)→ 逐目标部署 → 记日志
|
||||
*
|
||||
* ★ 续期判定:**先探针、再决定签不签**。
|
||||
* certimate 的做法是「每天定时无条件跑整个流水线」,靠 CA 侧对已有有效证书
|
||||
* 的复用避免浪费。我们改成**显式查剩余天数**再决定 —— 两个原因:
|
||||
* ① CA 复用有前提(同一账户 + 同一密钥),我们每次换密钥,复用不了,
|
||||
* 每天跑等于每天真的签一张新证书,白白消耗 Let's Encrypt 的限速额度
|
||||
* (同一域名每周 50 张);
|
||||
* ② 显式判定让日志和 UI 能准确说「为什么今天没签」,而不是一堆无意义的成功记录。
|
||||
*
|
||||
* ★ 为什么 ACME 账户密钥存在 KV 而不是内存/每次新生成:
|
||||
* Let's Encrypt 对「每个账户每个域名每周 50 张」做限速,但还有一条
|
||||
* 「每个 IP 每 3 小时 20 个新账户」的注册限速。每次都注册新账户,
|
||||
* 一旦某天多跑几次就撞限速。账户要复用。
|
||||
*/
|
||||
|
||||
import type { Env } from '../types';
|
||||
import { AcmeClient, newAccountKey, type AcmeAccount } from './acme';
|
||||
import { appendLog, getAccess, getCert, putCert, loadConfig, type CertRecord, type DomainConfig } from './certstore';
|
||||
import { makeDnsProvider } from './dnsprovider';
|
||||
import { makeDeployer } from './deployer';
|
||||
import { daysLeft, parsePemInfo, probeTls } from './certprobe';
|
||||
|
||||
const P = 'certkeeper:';
|
||||
const ACCOUNT_KEY = P + 'acme-account';
|
||||
|
||||
/** 续期阈值:剩余天数 ≤ 这个值才动手(Let's Encrypt 有效期 90 天,30 天留足冗余) */
|
||||
export const RENEW_BEFORE_DAYS = 30;
|
||||
|
||||
export interface IssueOptions {
|
||||
/** 强制签发,忽略剩余天数检查 */
|
||||
force?: boolean;
|
||||
/** 只签发不部署(调试用) */
|
||||
noDeploy?: boolean;
|
||||
/** 谁触发的(记日志) */
|
||||
by?: string;
|
||||
}
|
||||
|
||||
export interface IssueOutcome {
|
||||
domain: string;
|
||||
ok: boolean;
|
||||
/** 跳过的原因(ok=true 且 skipped 时有效) */
|
||||
skipped?: boolean;
|
||||
reason: string;
|
||||
/** 签发后证书的到期时间 */
|
||||
notAfter?: number;
|
||||
daysLeft?: number;
|
||||
/** 各部署目标的执行结果 */
|
||||
deploys?: { target: string; ok: boolean; details: string[] }[];
|
||||
/** 执行过程中的步骤(给 UI 展示进度用) */
|
||||
steps?: string[];
|
||||
}
|
||||
|
||||
// ==================================================================== ACME 账户
|
||||
|
||||
/**
|
||||
* 取(或创建)常驻的 ACME 账户。
|
||||
*
|
||||
* ★ 账户是**按 CA 存**的:换了 directoryUrl 就相当于换了个 CA,
|
||||
* 老 kid 在新 CA 上无效,必须重新注册。这里把 directoryUrl 一起存进记录里比较。
|
||||
*
|
||||
* ★ EAB 必须在**首次注册**时就带上(LiteSSL / ZeroSSL 强制要求),
|
||||
* 漏了会直接 400 —— 所以调用方要把 eab 传进来,不能等注册完再补。
|
||||
*/
|
||||
export async function getAcmeAccount(
|
||||
env: Env,
|
||||
directoryUrl: string,
|
||||
contact: string[],
|
||||
eab?: { kid: string; hmacKeyB64: string },
|
||||
): Promise<AcmeAccount> {
|
||||
const raw = await env.RSS_KV.get(ACCOUNT_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const saved = JSON.parse(raw) as AcmeAccount;
|
||||
if (saved.directoryUrl === directoryUrl && saved.jwk && saved.kid) return saved;
|
||||
} catch {
|
||||
/* 坏了就重建 */
|
||||
}
|
||||
}
|
||||
|
||||
// 新建账户密钥 → 注册 → 存下来
|
||||
const jwk = await newAccountKey();
|
||||
const client = new AcmeClient(directoryUrl, { jwk, kid: '' });
|
||||
const kid = await client.registerAccount(contact, eab);
|
||||
const account: AcmeAccount = { jwk, kid, directoryUrl };
|
||||
await env.RSS_KV.put(ACCOUNT_KEY, JSON.stringify(account));
|
||||
return account;
|
||||
}
|
||||
|
||||
// ==================================================================== 签发
|
||||
|
||||
/**
|
||||
* 给一个域名组签发证书(并部署)。
|
||||
*
|
||||
* 关键约束:`DomainConfig.san` 里的**第一个非泛域名**用作探测主机,
|
||||
* 但 ACME 订单用**完整 SAN 列表**(含 `*.usj.cc`),这样一张证书同时覆盖
|
||||
* 主域名和所有子域名。
|
||||
*/
|
||||
export async function issueDomain(env: Env, d: DomainConfig, opts: IssueOptions = {}): Promise<IssueOutcome> {
|
||||
const name = d.name;
|
||||
const by = opts.by || 'system';
|
||||
const log = (level: 'info' | 'warn' | 'error', message: string) =>
|
||||
appendLog(env, { at: Date.now(), level, action: 'renew', domain: name, message: `${by}: ${message}` });
|
||||
const step: string[] = [];
|
||||
const note = (m: string) => {
|
||||
step.push(m);
|
||||
console.log(`[certkeeper] ${name} ${m}`);
|
||||
};
|
||||
|
||||
if (d.disabled) {
|
||||
return { domain: name, ok: true, skipped: true, reason: '域名已停用' };
|
||||
}
|
||||
|
||||
// ---- ① 要不要签?先看线上真实剩余天数 ----
|
||||
if (!opts.force) {
|
||||
const host = d.san.find((s) => !s.startsWith('*.')) || name;
|
||||
const live = await probeTls(host, 8000, env.EDITOR_API_BASE, env.EDITOR_TOKEN);
|
||||
const liveLeft = daysLeft(live.notAfter);
|
||||
if (live.ok && liveLeft !== null && liveLeft > RENEW_BEFORE_DAYS) {
|
||||
// 线上证书还好好的 —— 顺手把探针拿到的真实信息补进库里(KV 里可能是旧记录)
|
||||
if (live.notAfter) {
|
||||
const rec = await getCert(env, name);
|
||||
if (!rec || Math.abs(rec.expireAt - live.notAfter) > 86400000) {
|
||||
const full = rec || { cert: '', key: '', expireAt: live.notAfter, updatedAt: Date.now() };
|
||||
await putCert(env, name, {
|
||||
...full,
|
||||
expireAt: live.notAfter,
|
||||
issuer: live.issuer || full.issuer,
|
||||
san: live.altNames?.length ? live.altNames : full.san,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
domain: name,
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: `线上证书还剩 ${liveLeft} 天(阈值 ${RENEW_BEFORE_DAYS} 天),不需要续期`,
|
||||
notAfter: live.notAfter,
|
||||
daysLeft: liveLeft,
|
||||
};
|
||||
}
|
||||
note(`需要续期:线上${liveLeft === null ? '探测不到到期日' : `仅剩 ${liveLeft} 天`}`);
|
||||
}
|
||||
|
||||
// ---- ② 备齐凭据 ----
|
||||
const dnsRec = await getAccess(env, d.dns);
|
||||
if (!dnsRec) {
|
||||
const msg = `DNS 凭据「${d.dns}」不存在`;
|
||||
await log('error', msg);
|
||||
return { domain: name, ok: false, reason: msg };
|
||||
}
|
||||
// 找一条 acme-eab 凭据作为 CA 账户绑定。约定:配置里没显式指定时,
|
||||
// 优先用 litessl(三组域名的实际 CA),没有就退回第一条 acme-eab。
|
||||
const cfg = await loadConfig(env);
|
||||
void cfg;
|
||||
const eabRec = await findEabAccess(env, d);
|
||||
if (!eabRec) {
|
||||
const msg = '找不到可用的 ACME CA 凭据(acme-eab 类型)';
|
||||
await log('error', msg);
|
||||
return { domain: name, ok: false, reason: msg };
|
||||
}
|
||||
|
||||
const directoryUrl = String(eabRec.directoryUrl || '');
|
||||
const eabKid = String(eabRec.eabKid || '');
|
||||
const eabHmac = String(eabRec.eabHmacKey || '');
|
||||
if (!directoryUrl) {
|
||||
const msg = `CA 凭据「${String(eabRec.note || '')}」缺少 directoryUrl`;
|
||||
await log('error', msg);
|
||||
return { domain: name, ok: false, reason: msg };
|
||||
}
|
||||
|
||||
// ---- ③ 注册/找回账户 ----
|
||||
let account: AcmeAccount;
|
||||
try {
|
||||
const contact = cfg.notify.emails.length ? cfg.notify.emails.map((e) => `mailto:${e}`) : [];
|
||||
// ★ EAB 必须在首次注册时带上(LiteSSL / ZeroSSL 强制),所以这里一起传
|
||||
account = await getAcmeAccount(
|
||||
env,
|
||||
directoryUrl,
|
||||
contact,
|
||||
eabKid && eabHmac ? { kid: eabKid, hmacKeyB64: eabHmac } : undefined,
|
||||
);
|
||||
note(`ACME 账户就绪(${issuerFromDirectory(directoryUrl)})`);
|
||||
} catch (e) {
|
||||
const msg = `ACME 账户注册失败:${err(e)}`;
|
||||
await log('error', msg);
|
||||
return { domain: name, ok: false, reason: msg };
|
||||
}
|
||||
|
||||
const client = new AcmeClient(directoryUrl, { jwk: account.jwk, kid: account.kid }, note);
|
||||
|
||||
// ---- ④ 签发 ----
|
||||
const dns = makeDnsProvider(dnsRec);
|
||||
// 订单里用完整 SAN(含通配),保证一张证书覆盖主域 + 全部子域
|
||||
const orderDomains = d.san.length ? d.san : [name];
|
||||
let issued;
|
||||
try {
|
||||
issued = await client.issueDns01(
|
||||
orderDomains,
|
||||
(n, v) => dns.addTxt(n, v),
|
||||
(n, v) => dns.delTxt(n, v),
|
||||
{ waitSeconds: 30, timeoutMs: 240_000 },
|
||||
);
|
||||
note(`签发成功(${orderDomains.join(', ')})`);
|
||||
} catch (e) {
|
||||
const msg = `签发失败:${err(e)}`;
|
||||
await log('error', msg);
|
||||
return { domain: name, ok: false, reason: msg };
|
||||
}
|
||||
|
||||
// ---- ⑤ 落库 ----
|
||||
const info = parsePemInfo(issued.cert);
|
||||
const rec: CertRecord = {
|
||||
cert: issued.cert,
|
||||
key: issued.key,
|
||||
expireAt: info.notAfter || Date.now() + 90 * 86400000,
|
||||
updatedAt: Date.now(),
|
||||
issuer: issuerFromDirectory(directoryUrl),
|
||||
san: info.altNames?.length ? info.altNames : orderDomains,
|
||||
};
|
||||
await putCert(env, name, rec);
|
||||
const left = daysLeft(rec.expireAt);
|
||||
await log('info', `签发成功,新证书有效期至 ${new Date(rec.expireAt).toISOString().slice(0, 10)}(${left} 天)`);
|
||||
|
||||
// ---- ⑥ 部署 ----
|
||||
// ★ 部署器按**凭据类型**自动构造(makeDeployer 认 type 字段),
|
||||
// 所以这里只需要把「目标名 → 凭据名」对上。约定:
|
||||
// dogecloud → 同名凭据;1panel → '1panel-cn'(国内机那台)
|
||||
const deploys: { target: string; ok: boolean; details: string[] }[] = [];
|
||||
if (!opts.noDeploy) {
|
||||
for (const target of d.deploy) {
|
||||
const credName = target === '1panel' ? '1panel-cn' : target;
|
||||
const cred = await getAccess(env, credName);
|
||||
if (!cred) {
|
||||
deploys.push({ target, ok: false, details: [`找不到凭据「${credName}」`] });
|
||||
await log('warn', `部署到 ${target} 跳过:凭据「${credName}」不存在`);
|
||||
continue;
|
||||
}
|
||||
const lines: string[] = [];
|
||||
try {
|
||||
const dp = makeDeployer(cred);
|
||||
const res = await dp.deploy(
|
||||
{ domain: name, cert: rec.cert, key: rec.key },
|
||||
{
|
||||
dogecloudDomains: d.dogecloud_domains,
|
||||
onePanelSites: d.one_panel_sites,
|
||||
log: (m) => {
|
||||
lines.push(m);
|
||||
note(m);
|
||||
},
|
||||
},
|
||||
);
|
||||
deploys.push({ target, ok: true, details: res.details });
|
||||
await log('info', `部署到 ${target}:${res.details.join(';') || '完成'}`);
|
||||
} catch (e) {
|
||||
const msg = `部署到 ${target} 失败:${err(e)}`;
|
||||
deploys.push({ target, ok: false, details: [...lines, msg] });
|
||||
await log('error', msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allOk = deploys.every((x) => x.ok);
|
||||
return {
|
||||
domain: name,
|
||||
ok: allOk,
|
||||
reason: allOk ? `签发并部署完成(${left} 天)` : '证书已签发,但部分部署失败(见日志)',
|
||||
notAfter: rec.expireAt,
|
||||
daysLeft: left ?? undefined,
|
||||
deploys,
|
||||
steps: step,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================================================================== 批量
|
||||
|
||||
/** 续期检查(cron 调):逐个域名判断并签发 */
|
||||
export async function renewAll(env: Env, opts: IssueOptions = {}): Promise<IssueOutcome[]> {
|
||||
let cfg;
|
||||
try {
|
||||
cfg = await loadConfig(env);
|
||||
} catch (e) {
|
||||
await appendLog(env, {
|
||||
at: Date.now(),
|
||||
level: 'error',
|
||||
action: 'renew',
|
||||
message: `读配置失败,本次续期跳过:${err(e)}`,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: IssueOutcome[] = [];
|
||||
for (const d of cfg.domains) {
|
||||
if (d.disabled) continue;
|
||||
try {
|
||||
out.push(await issueDomain(env, d, opts));
|
||||
} catch (e) {
|
||||
const msg = `续期 ${d.name} 时异常:${err(e)}`;
|
||||
await appendLog(env, { at: Date.now(), level: 'error', action: 'renew', domain: d.name, message: msg });
|
||||
out.push({ domain: d.name, ok: false, reason: msg });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ==================================================================== 辅助
|
||||
|
||||
async function findEabAccess(env: Env, d: DomainConfig): Promise<Record<string, unknown> | null> {
|
||||
void env;
|
||||
void d;
|
||||
// 约定:优先 litessl;它在三组域名上都在用,且是当前实际 CA。
|
||||
const preferred = 'litessl';
|
||||
const rec = await getAccess(env, preferred);
|
||||
if (rec && rec.type === 'acme-eab') return rec as unknown as Record<string, unknown>;
|
||||
// 退路:扫一遍所有凭据找第一条 acme-eab
|
||||
const { listAccess } = await import('./certstore');
|
||||
const list = await listAccess(env);
|
||||
for (const item of list) {
|
||||
if (item.type === 'acme-eab') {
|
||||
const full = await getAccess(env, item.name);
|
||||
if (full) return full as unknown as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function issuerFromDirectory(url: string): string {
|
||||
if (url.includes('trustasia')) return 'LiteSSL (TrustAsia)';
|
||||
if (url.includes('letsencrypt')) return "Let's Encrypt";
|
||||
if (url.includes('zerossl')) return 'ZeroSSL';
|
||||
if (url.includes('google')) return 'Google Trust Services';
|
||||
if (url.includes('ssl.com')) return 'SSL.com';
|
||||
if (url.includes('buypass')) return 'Buypass';
|
||||
return url.replace(/^https?:\/\//, '').split('/')[0];
|
||||
}
|
||||
|
||||
function err(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。
|
||||
*
|
||||
* 目前两个目标(对应 `certstore.ts` 里的 `DEPLOY_TARGETS`):
|
||||
* · dogecloud 多吉云 CDN —— 上传证书 + 绑到指定加速域名
|
||||
* · 1panel 1Panel 面板 —— 上传证书 + 绑到指定网站(走 openresty)
|
||||
*
|
||||
* ★ 两家的 API 风格完全相反,各自的坑单独记在下面各自的类里。
|
||||
*
|
||||
* ★ 为什么部署要「幂等」(重复调用不出错):
|
||||
* 续期失败一次就可能连着重试;更要紧的是——**每天都会跑一遍**,
|
||||
* 如果每次都无脑新建证书,多吉云那边的证书列表会膨胀成几百条,
|
||||
* 1Panel 那边会不断覆盖同名 SSL。所以这里的每个动作都先查后写。
|
||||
*/
|
||||
|
||||
import type { AccessRecord } from './certstore';
|
||||
|
||||
// ==================================================================== 类型
|
||||
|
||||
export interface DeployCert {
|
||||
/** 主域名(1Panel 用来给 SSL 起名字,多吉云用来做备注) */
|
||||
domain: string;
|
||||
/** 证书链 PEM(叶 + 中间,多吉云要求含完整链) */
|
||||
cert: string;
|
||||
/** 私钥 PEM */
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
/** 目标标签,日志里用 */
|
||||
target: string;
|
||||
/** 这次实际做了什么(用于日志/UI 展示),如「绑定 usj.cc」 */
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export interface Deployer {
|
||||
readonly kind: string;
|
||||
/** 把证书推到这个目标的所有配置对象上 */
|
||||
deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult>;
|
||||
}
|
||||
|
||||
export interface DeployOptions {
|
||||
/** 多吉云:要绑的加速域名列表(空则只上传不绑定) */
|
||||
dogecloudDomains?: string[];
|
||||
/** 1Panel:要绑的网站(域名或 id)列表(空则只上传不绑定) */
|
||||
onePanelSites?: string[];
|
||||
/** 追加日志 */
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
// ==================================================================== 工具
|
||||
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
// ==================================================================== 多吉云 CDN
|
||||
|
||||
/** hex 输出(多吉云签名用) */
|
||||
function bufToHex(buf: ArrayBuffer | Uint8Array): string {
|
||||
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 多吉云(api.dogecloud.com)。
|
||||
*
|
||||
* ★ 签名方式(老派但有性格):
|
||||
* stringToSign = <path[+?query]> + "\n" + <body原始字符串>
|
||||
* signature = HMAC-SHA1(secretKey, stringToSign) 的 **hex**
|
||||
* Authorization: `TOKEN <accessKey>:<signature>`
|
||||
* 注意:**不含时间戳**(所以要靠 HTTPS 防重放);HMAC 用的是 **SHA1** 不是 SHA256;
|
||||
* 输出是 **hex** 不是 base64。这三点任一搞错都只会得到 `401 签名错误`。
|
||||
*
|
||||
* ★ body 必须**原样**参与签名:先序列化成字符串再一起发出去,
|
||||
* 不能签名 JSON.stringify(a) 却发送 JSON.stringify(b)。
|
||||
* 这里统一「先定 body 字符串 → 签名 → 发送同一个字符串」。
|
||||
*
|
||||
* ★ 端点与参数(2026-10-06 用真凭据逐个实测确认,别照抄网上的旧文档):
|
||||
* POST /cdn/domain/list.json {} → { domains: [{id,name,cname,…}] }
|
||||
* POST /cdn/cert/list.json {} → { certs: [{id,note,name,domains,…}] }
|
||||
* POST /cdn/cert/upload.json { note, cert, private } → { id }
|
||||
* POST /cdn/cert/bind.json { id, domain } → {}
|
||||
* POST /cdn/cert/delete.json { id } → {}
|
||||
* 几个容易写错的地方:
|
||||
* · 列域名是 `/cdn/domain/**list**.json`;`/cdn/domain.json` 会回
|
||||
* `400 domain 格式错误`(它其实是「查单个域名」的接口,要传 domain)。
|
||||
* · 上传的私钥字段叫 **`private`**(不是 pri/key/privateKey —— 那三个都会回
|
||||
* `400 私钥格式错误`)。
|
||||
* · 绑定的证书 id 字段是 **`id`**(官方文档如此)。★ 已实测一锤定音:
|
||||
* 用假 id 999999 试 `{cert_id,…}` 回「域名不存在」(参数被无视),
|
||||
* 试 `{id,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。
|
||||
*
|
||||
* ★ 幂等策略:上传前先列 cert 列表,若已存在「同一组域名 + 内容相同」的证书
|
||||
* 就直接复用它的 id,不再上传。多吉云上传限速约 300 次/日,
|
||||
* 每天续期检查跑 3 个域名,不做复用虽然也够,但证书列表会越堆越长。
|
||||
*/
|
||||
export class DogeCloudDeployer implements Deployer {
|
||||
readonly kind = 'dogecloud';
|
||||
private static readonly HOST = 'https://api.dogecloud.com';
|
||||
|
||||
constructor(
|
||||
private readonly accessKey: string,
|
||||
private readonly secretKey: string,
|
||||
) {}
|
||||
|
||||
private async call<T>(path: string, body: Record<string, unknown> | null): Promise<T> {
|
||||
// ★ body 字符串只算一次,签名和发送用同一个
|
||||
const bodyStr = body === null ? '' : JSON.stringify(body);
|
||||
const stringToSign = `${path}\n${bodyStr}`;
|
||||
const key = await crypto.subtle.importKey('raw', enc(this.secretKey), { name: 'HMAC', hash: 'SHA-1' }, false, [
|
||||
'sign',
|
||||
]);
|
||||
const sig = bufToHex(await crypto.subtle.sign('HMAC', key, enc(stringToSign)));
|
||||
|
||||
const r = await fetch(DogeCloudDeployer.HOST + path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `TOKEN ${this.accessKey}:${sig}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: bodyStr || undefined,
|
||||
});
|
||||
const text = await r.text();
|
||||
let d: { code?: number; msg?: string; data?: T };
|
||||
try {
|
||||
d = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`多吉云返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`);
|
||||
}
|
||||
// code === 200 是成功;0 也有接口用(历史遗留),一并认
|
||||
if (d.code !== 200 && d.code !== 0) {
|
||||
throw new Error(`多吉云 ${path} 失败:code=${d.code} ${d.msg || ''}`);
|
||||
}
|
||||
return d.data as T;
|
||||
}
|
||||
|
||||
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
|
||||
const log = opts.log || (() => {});
|
||||
const details: string[] = [];
|
||||
|
||||
// ① 先看有没有可复用的证书(同一组域名)—— 避免每天续期都堆一张新的
|
||||
const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log);
|
||||
details.push(`证书 #${certId}`);
|
||||
|
||||
// ② 逐个域名绑定(★ 字段名是 cert_id,下划线)
|
||||
const domains = (opts.dogecloudDomains || []).map((s) => s.trim()).filter(Boolean);
|
||||
if (!domains.length) {
|
||||
log('多吉云:没有配置要绑定的域名,只上传不绑定');
|
||||
return { target: 'dogecloud', details };
|
||||
}
|
||||
for (const domain of domains) {
|
||||
log(`多吉云:绑定 ${domain}…`);
|
||||
await this.call('/cdn/cert/bind.json', { id: certId, domain });
|
||||
details.push(`绑定 ${domain}`);
|
||||
}
|
||||
return { target: 'dogecloud', details };
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传证书;如果已经有「覆盖同一组域名」的证书,直接复用它的 id。
|
||||
*
|
||||
* ★ 复用判据只看**域名集合**,不比对证书内容:
|
||||
* 多吉云的 list 接口不返回 PEM 正文,比对不了内容;而我们的用途是
|
||||
* 「让这些域名用上新证书」,同一组域名本来就该共用同一张证书。
|
||||
*/
|
||||
private async uploadOrReuse(cert: DeployCert, wantDomains: string[], log: (m: string) => void): Promise<number> {
|
||||
const need = new Set(
|
||||
(wantDomains.length ? wantDomains : [cert.domain]).map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
try {
|
||||
const list = await this.call<{ certs?: { id: number; domains?: { name: string }[] }[] }>(
|
||||
'/cdn/cert/list.json',
|
||||
{},
|
||||
);
|
||||
const hit = (list?.certs || []).find((c) => {
|
||||
const have = new Set((c.domains || []).map((d) => String(d.name).toLowerCase()));
|
||||
if (have.size !== need.size) return false;
|
||||
for (const d of need) if (!have.has(d)) return false;
|
||||
return true;
|
||||
});
|
||||
if (hit?.id) {
|
||||
log(`多吉云:已有覆盖 ${[...need].join(', ')} 的证书 #${hit.id},复用`);
|
||||
return hit.id;
|
||||
}
|
||||
} catch (e) {
|
||||
// 列举失败不阻断部署 —— 大不了多传一张,比整个部署失败好
|
||||
log(`多吉云:列举已有证书失败(继续上传新的):${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
|
||||
log('多吉云:上传证书…');
|
||||
const up = await this.call<{ id?: number | string }>('/cdn/cert/upload.json', {
|
||||
note: `${cert.domain} (${new Date().toISOString().slice(0, 10)})`,
|
||||
cert: cert.cert,
|
||||
// ★ 私钥字段名是 `private` —— 实测 pri/key/privateKey 都会回「私钥格式错误」
|
||||
private: cert.key,
|
||||
});
|
||||
const id = up?.id;
|
||||
if (id === undefined || id === null || id === '') {
|
||||
throw new Error('多吉云上传成功但没返回证书 id(接口可能改了)');
|
||||
}
|
||||
return Number(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================== 1Panel
|
||||
|
||||
/**
|
||||
* 1Panel(自建面板)。
|
||||
*
|
||||
* ★ 签名:`1Panel-Token = md5("1panel" + apiKey + timestamp)`(hex),
|
||||
* 同时带 `1Panel-Timestamp`(unix 秒)。**没有别的材料**,
|
||||
* 与多吉云那种「body 进签名」完全无关 —— 它就是防重放 + 持有密钥即通过。
|
||||
*
|
||||
* ★ 版本:国内机(119.29.215.187:3721)**必须用 v2**。
|
||||
* 2026-10-06 逐条实测的结论(网上和早期笔记里的说法都不准,以实测为准):
|
||||
* · `/api/v2/dashboard/base/os` → `code:200`,真实数据 ✓
|
||||
* · `/api/v1/dashboard/base/os` → **HTTP 200 但正文是 HTML 提示页**
|
||||
* (`Access Temporarily Unavailable`)—— 看起来像限流,其实是 v1 已停用,
|
||||
* 面板把「路径不对」统一渲染成了那个页面。这一点极易误判成「被限流了」。
|
||||
*
|
||||
* ★ v2 的请求体要求和 v1 不同,实测踩过的点:
|
||||
* · `POST /websites/search` 的 `orderBy` / `order` 是 **required**
|
||||
* (漏了会回 `400 参数错误: Key: 'WebsiteSearch.OrderBy' … required`)。
|
||||
* → 固定传 `{orderBy:'created_at', order:'descending'}`。
|
||||
* · `/websites/list`、`GET /websites` 在 v2 下都是 404,别用。
|
||||
* · `GET /websites/:id` 是 404(不是 `GET /websites?id=`)。
|
||||
* · 站点里的 `ssl` 字段在列表里是空的 —— 要拿 https 配置得单独
|
||||
* `GET /websites/:id/https`。
|
||||
*
|
||||
* ★ 部署流程(**必须**先读后写):
|
||||
* ① `POST /websites/ssl/search` 找同名 SSL;有就 `POST /websites/ssl/update` 覆盖,
|
||||
* 没有就 `POST /websites/ssl/upload` 新建 → 得到 SSL id
|
||||
* ② `GET /websites/:id/https` 读现状,若 `enable && ssl.id === 目标` → **跳过**(幂等)
|
||||
* ③ `POST /websites/:id/https` 写入(`type:'existed'` 引用已有 SSL)
|
||||
*
|
||||
* ★ 网站匹配:域名优先(人配的是域名,id 会变),拿不到再当 id 用。
|
||||
*/
|
||||
|
||||
/** 1Panel 的搜索分页包装 */
|
||||
interface PageResult<T> {
|
||||
items?: T[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/** 1Panel v2 的网站对象(只列我们关心的字段) */
|
||||
interface WebSite {
|
||||
id: number;
|
||||
primaryDomain?: string;
|
||||
/** 别名,多个用逗号分隔 */
|
||||
alias?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export class OnePanelDeployer implements Deployer {
|
||||
readonly kind = '1panel';
|
||||
|
||||
constructor(
|
||||
private readonly serverUrl: string,
|
||||
private readonly apiKey: string,
|
||||
// ★ 默认 v2 —— 实测国内机只有 v2 能用(v1 会返回一个假的「限流」HTML 页)
|
||||
private readonly apiVersion: 'v1' | 'v2' = 'v2',
|
||||
) {}
|
||||
|
||||
private get base(): string {
|
||||
return `${this.serverUrl.replace(/\/+$/, '')}/api/${this.apiVersion}`;
|
||||
}
|
||||
|
||||
private async call<T>(path: string, method: 'GET' | 'POST', body?: unknown): Promise<T> {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const md5 = await md5Hex(`1panel${this.apiKey}${timestamp}`);
|
||||
const r = await fetch(this.base + path, {
|
||||
method,
|
||||
headers: {
|
||||
'1Panel-Token': md5,
|
||||
'1Panel-Timestamp': timestamp,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await r.text();
|
||||
let d: { code?: number; message?: string; data?: T };
|
||||
try {
|
||||
d = JSON.parse(text);
|
||||
} catch {
|
||||
// ★ 2026-10-06 实测:1Panel 在「不方便直接回错」时会**返回 HTTP 200
|
||||
// 但内容是 HTML**。两种成因,处置完全不同,所以必须分开报:
|
||||
// ① 开了「安全登录」→ 面板挪到随机入口路径,根路径只回提示页
|
||||
// (正文含 `secure login access` / `1pctl user-info`)
|
||||
// ② 短时间调用过密被限流 → 正文标题 `Access Temporarily Unavailable`
|
||||
// 只按「非 JSON 就抛错」处理的话,两种都会被抓成一句看不懂的
|
||||
// 「返回非 JSON」,排查成本极高。
|
||||
if (/secure login access|1pctl user-info/i.test(text)) {
|
||||
throw new Error(
|
||||
`1Panel 启用了「安全登录」,面板不在根路径下(serverUrl 少了入口路径)。` +
|
||||
`SSH 上机执行 \`1pctl user-info\` 拿到入口,再把 serverUrl 改成 ` +
|
||||
`http://<ip>:<port>/<入口路径>`,
|
||||
);
|
||||
}
|
||||
if (/Access Temporarily Unavailable|<!DOCTYPE/i.test(text)) {
|
||||
throw new Error(
|
||||
`1Panel 拒绝了本次请求(返回「Access Temporarily Unavailable」页面)。` +
|
||||
`通常是短时间调用过于频繁触发限流 —— 等一会儿再试。`,
|
||||
);
|
||||
}
|
||||
throw new Error(`1Panel 返回非 JSON(HTTP ${r.status},${r.headers.get('content-type') || '无 CT'}):${text.slice(0, 200)}`);
|
||||
}
|
||||
// 1Panel 统一信封:code === 200 才是成功
|
||||
if (d.code !== 200) {
|
||||
throw new Error(`1Panel ${path} 失败:code=${d.code} ${d.message || `HTTP ${r.status}`}`);
|
||||
}
|
||||
return d.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连通性 + 可用性探测(只读)。给「环境自检」用。
|
||||
*
|
||||
* ★ 为什么要单独有个 ping 而不是直接 try 某个业务接口:
|
||||
* `GET /dashboard/base/os` 是最轻的只读接口,用它做「面板是否可用」的
|
||||
* 探针最合适 —— 业务接口(网站/证书列表)失败时你分不清是「签名错」
|
||||
* 还是「没数据」,而这个接口必然有数据可回。
|
||||
*/
|
||||
async ping(): Promise<{ ok: boolean; error?: string; hint?: string }> {
|
||||
try {
|
||||
await this.call<unknown>('/dashboard/base/os', 'GET');
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
ok: false,
|
||||
error: msg,
|
||||
hint: msg.includes('Access Temporarily Unavailable')
|
||||
? '1Panel 启用了「安全登录」:面板被挪到了随机入口路径下,根路径只回提示页。' +
|
||||
'需要 SSH 上机执行 `1pctl user-info` 拿到入口,再把 serverUrl 改成 ' +
|
||||
'`http://<ip>:<port>/<入口路径>`'
|
||||
: msg.includes('401')
|
||||
? 'API Key 不对(1Panel → 设置 → API 接口 里重新生成)'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 域名 or 数字 id → 网站对象 */
|
||||
async findWebsite(key: string): Promise<{ id: number; primaryDomain?: string; alias?: string }> {
|
||||
// ★ v2 下 `/websites/:id` 是 404,所以「按 id 找」也得走 search:
|
||||
// 拉一页(orderBy/order 必填)再在前端按 id 过滤。
|
||||
// 顺带这一步就拿到了全量网站,后面按域名找也不用再请求一次。
|
||||
const all = await this.listWebsites();
|
||||
const key_l = key.toLowerCase();
|
||||
|
||||
const match = (w: WebSite) => {
|
||||
if (String(w.id) === key) return true;
|
||||
const names = [w.primaryDomain || '', ...(w.alias || '').split(',')].map((s) => s.trim().toLowerCase());
|
||||
return names.includes(key_l);
|
||||
};
|
||||
|
||||
const hit = all.find(match);
|
||||
if (hit) return hit;
|
||||
throw new Error(
|
||||
`1Panel 里找不到网站「${key}」(可用主域名或网站别名匹配;目前面板上有 ${all.length} 个网站)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 拉全量网站列表(v2 的 search 按 name 过滤不可靠,统一拉回来自己筛) */
|
||||
private async listWebsites(): Promise<WebSite[]> {
|
||||
const page = await this.call<PageResult<WebSite>>('/websites/search', 'POST', {
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
// ★ 这两个字段 v2 是 required,漏了直接 400
|
||||
orderBy: 'created_at',
|
||||
order: 'descending',
|
||||
});
|
||||
return page?.items || [];
|
||||
}
|
||||
|
||||
/** 上传证书;同名同内容的已有 SSL 直接复用,避免面板里堆一堆 */
|
||||
private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise<number> {
|
||||
// ① 查同名(v2 的 ssl/search 同样需要 orderBy/order)
|
||||
const page = await this.call<PageResult<{ id: number; primaryDomain?: string }>>('/websites/ssl/search', 'POST', {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
orderBy: 'created_at',
|
||||
order: 'descending',
|
||||
});
|
||||
const existing = (page?.items || []).find((s) => s.primaryDomain === cert.domain);
|
||||
|
||||
if (existing?.id) {
|
||||
// ② 同名存在 → 用 update 覆盖内容(保持 id 不变,网站那边的引用就不会断)
|
||||
log(`1Panel:更新已有 SSL #${existing.id}(${cert.domain})`);
|
||||
await this.call('/websites/ssl/update', 'POST', {
|
||||
id: existing.id,
|
||||
type: 'paste',
|
||||
certificate: cert.cert,
|
||||
privateKey: cert.key,
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
log('1Panel:上传新证书…');
|
||||
const up = await this.call<{ id?: number } | number>('/websites/ssl/upload', 'POST', {
|
||||
type: 'paste',
|
||||
certificate: cert.cert,
|
||||
privateKey: cert.key,
|
||||
});
|
||||
const id = typeof up === 'number' ? up : up?.id;
|
||||
if (!id) throw new Error('1Panel 上传成功但没拿到 SSL id');
|
||||
return id;
|
||||
}
|
||||
|
||||
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
|
||||
const log = opts.log || (() => {});
|
||||
const details: string[] = [];
|
||||
|
||||
const sslId = await this.uploadSsl(cert, log);
|
||||
details.push(`证书 SSL #${sslId}`);
|
||||
|
||||
const sites = (opts.onePanelSites || []).map((s) => s.trim()).filter(Boolean);
|
||||
if (!sites.length) {
|
||||
log('1Panel:没有配置要绑定的网站,只上传不绑定');
|
||||
return { target: '1panel', details };
|
||||
}
|
||||
|
||||
for (const key of sites) {
|
||||
const site = await this.findWebsite(key);
|
||||
// ★ 先读现状 —— 幂等的关键。已经在用同一张证书就什么都别做。
|
||||
//
|
||||
// ★★ 字段名是 **`SSL`(全大写)**,不是 `ssl`。写成小写会让
|
||||
// `cur.ssl?.id === sslId` 永远为 false → 每次续期都重绑一遍。
|
||||
// 危害不止是多余请求:重绑会 brief 地重载该站点的 nginx 配置。
|
||||
//
|
||||
// ★ 这个 GET 的响应里**带明文私钥**(`data.SSL.privateKey`)——
|
||||
// 绝不能把它写进日志、日志记录或 HTTP 响应。这里只取需要的几个
|
||||
// 标量字段,然后让整个对象尽快离开作用域。
|
||||
const resp = await this.call<{
|
||||
enable?: boolean;
|
||||
SSL?: { id?: number; primaryDomain?: string };
|
||||
httpConfig?: string;
|
||||
SSLProtocol?: string[];
|
||||
algorithm?: string;
|
||||
hsts?: boolean;
|
||||
}>(`/websites/${site.id}/https`, 'GET');
|
||||
|
||||
const cur = {
|
||||
enable: resp?.enable,
|
||||
sslId: resp?.SSL?.id,
|
||||
httpConfig: resp?.httpConfig,
|
||||
SSLProtocol: resp?.SSLProtocol,
|
||||
algorithm: resp?.algorithm,
|
||||
hsts: resp?.hsts,
|
||||
};
|
||||
|
||||
if (cur.enable && cur.sslId === sslId) {
|
||||
log(`1Panel:网站 ${key} 已经在用这张证书,跳过`);
|
||||
details.push(`跳过 ${key}(已生效)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ★ 保留原有配置:HTTP→HTTPS 跳转、协议版本、算法、HSTS —— 只换证书
|
||||
const body: Record<string, unknown> = {
|
||||
websiteId: site.id,
|
||||
type: 'existed',
|
||||
sslId,
|
||||
enable: true,
|
||||
httpConfig: cur.httpConfig || 'HTTPToHTTPS',
|
||||
SSLProtocol: cur.SSLProtocol?.length ? cur.SSLProtocol : ['TLSv1.2', 'TLSv1.3'],
|
||||
algorithm: cur.algorithm || 'RSA',
|
||||
hsts: cur.hsts ?? false,
|
||||
};
|
||||
log(`1Panel:给网站 ${key}(#${site.id})绑定证书…`);
|
||||
await this.call(`/websites/${site.id}/https`, 'POST', body);
|
||||
details.push(`绑定 ${key}`);
|
||||
}
|
||||
|
||||
return { target: '1panel', details };
|
||||
}
|
||||
|
||||
/**
|
||||
* 读某个网站当前的 SSL 绑定情况(只回标量,**绝不回私钥**)。
|
||||
* 给「环境自检」用 —— 让管理员能在续期之前就看出「站点绑的是不是我们要的那张」。
|
||||
*/
|
||||
async inspectSite(
|
||||
key: string,
|
||||
): Promise<{ id: number; primaryDomain: string; enable: boolean; sslId?: number; certCN?: string }> {
|
||||
const site = await this.findWebsite(key);
|
||||
const resp = await this.call<{ enable?: boolean; SSL?: { id?: number; primaryDomain?: string } }>(
|
||||
`/websites/${site.id}/https`,
|
||||
'GET',
|
||||
);
|
||||
return {
|
||||
id: site.id,
|
||||
primaryDomain: site.primaryDomain || '',
|
||||
enable: !!resp?.enable,
|
||||
sslId: resp?.SSL?.id,
|
||||
certCN: resp?.SSL?.primaryDomain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================== 工厂
|
||||
|
||||
/**
|
||||
* 按凭据记录造部署器。
|
||||
* ★ 只认 dogecloud / 1panel;其余抛错而非静默 —— 理由同 makeDnsProvider。
|
||||
*/
|
||||
export function makeDeployer(rec: AccessRecord): Deployer {
|
||||
const t = String(rec.type || '');
|
||||
if (t === 'dogecloud') {
|
||||
const ak = String(rec.accessKey || '');
|
||||
const sk = String(rec.secretKey || '');
|
||||
if (!ak || !sk) throw new Error('多吉云凭据缺少 accessKey / secretKey');
|
||||
return new DogeCloudDeployer(ak, sk);
|
||||
}
|
||||
if (t === '1panel') {
|
||||
const url = String(rec.serverUrl || '');
|
||||
const key = String(rec.apiKey || '');
|
||||
if (!url || !key) throw new Error('1Panel 凭据缺少 serverUrl / apiKey');
|
||||
const ver = String(rec.apiVersion || 'v2') === 'v1' ? 'v1' : 'v2';
|
||||
return new OnePanelDeployer(url, key, ver);
|
||||
}
|
||||
throw new Error(`部署目标不支持凭据类型「${t}」(目前只支持 dogecloud / 1panel)`);
|
||||
}
|
||||
|
||||
// ==================================================================== md5
|
||||
|
||||
/**
|
||||
* 1Panel 要的 md5(hex)。
|
||||
*
|
||||
* ★ 用 `crypto.subtle` 没有 MD5(它是过时算法,WebCrypto 故意不提供),
|
||||
* 所以自己写一份。这里只需要处理 ASCII("1panel" + apiKey + 时间戳),
|
||||
* 但为了将来可能复用它算别的,还是按 UTF-8 字节做了正确处理。
|
||||
* 实现照 RFC 1321;输出小写 hex。
|
||||
*/
|
||||
export function md5Hex(input: string): Promise<string> {
|
||||
return Promise.resolve(md5(enc(input)));
|
||||
}
|
||||
|
||||
function md5(bytes: Uint8Array): string {
|
||||
const S = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14,
|
||||
20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6,
|
||||
10, 15, 21,
|
||||
];
|
||||
const K = new Uint32Array(64);
|
||||
for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0;
|
||||
|
||||
// 补位:0x80 + 0x00... 到 56 mod 64,再附 64 位长度(小端)
|
||||
const len = bytes.length;
|
||||
const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6);
|
||||
withPad.set(bytes);
|
||||
withPad[len] = 0x80;
|
||||
const bitLen = len * 8;
|
||||
// 低 32 位 + 高 32 位(JS 里用除法拆,避免 >> 32 的坑)
|
||||
const lo = bitLen >>> 0;
|
||||
const hi = Math.floor(bitLen / 4294967296) >>> 0;
|
||||
const dv = new DataView(withPad.buffer);
|
||||
dv.setUint32(withPad.length - 8, lo, true);
|
||||
dv.setUint32(withPad.length - 4, hi, true);
|
||||
|
||||
let a0 = 0x67452301;
|
||||
let b0 = 0xefcdab89;
|
||||
let c0 = 0x98badcfe;
|
||||
let d0 = 0x10325476;
|
||||
|
||||
const rotl = (x: number, c: number) => ((x << c) | (x >>> (32 - c))) >>> 0;
|
||||
|
||||
for (let off = 0; off < withPad.length; off += 64) {
|
||||
const M = new Uint32Array(16);
|
||||
for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true);
|
||||
|
||||
let A = a0;
|
||||
let B = b0;
|
||||
let C = c0;
|
||||
let D = d0;
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
let F: number;
|
||||
let g: number;
|
||||
if (i < 16) {
|
||||
F = (B & C) | (~B & D);
|
||||
g = i;
|
||||
} else if (i < 32) {
|
||||
F = (D & B) | (~D & C);
|
||||
g = (5 * i + 1) % 16;
|
||||
} else if (i < 48) {
|
||||
F = B ^ C ^ D;
|
||||
g = (3 * i + 5) % 16;
|
||||
} else {
|
||||
F = C ^ (B | ~D);
|
||||
g = (7 * i) % 16;
|
||||
}
|
||||
F = (F + A + K[i] + M[g]) >>> 0;
|
||||
A = D;
|
||||
D = C;
|
||||
C = B;
|
||||
B = (B + rotl(F, S[i])) >>> 0;
|
||||
}
|
||||
|
||||
a0 = (a0 + A) >>> 0;
|
||||
b0 = (b0 + B) >>> 0;
|
||||
c0 = (c0 + C) >>> 0;
|
||||
d0 = (d0 + D) >>> 0;
|
||||
}
|
||||
|
||||
return [a0, b0, c0, d0].map((x) => {
|
||||
// 每个字按小端输出
|
||||
const b = new Uint8Array(4);
|
||||
new DataView(b.buffer).setUint32(0, x, true);
|
||||
return [...b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||
}).join('');
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 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('');
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
clearLog,
|
||||
delAccess,
|
||||
delCert,
|
||||
getAccess,
|
||||
getCert,
|
||||
listAccess,
|
||||
listCertNames,
|
||||
@@ -41,6 +42,10 @@ import {
|
||||
type KeeperConfig,
|
||||
} from '../lib/certstore';
|
||||
import { daysLeft, parsePemInfo, probeTls } from '../lib/certprobe';
|
||||
import { issueDomain, RENEW_BEFORE_DAYS, type IssueOutcome } from '../lib/certissue';
|
||||
import { AcmeClient } from '../lib/acme';
|
||||
import { makeDnsProvider } from '../lib/dnsprovider';
|
||||
import { makeDeployer, OnePanelDeployer } from '../lib/deployer';
|
||||
import { mailEnabled, sendMail } from '../lib/mail';
|
||||
import { formatDateCN } from '../lib/util';
|
||||
|
||||
@@ -588,6 +593,304 @@ export async function logClear(ctx: Ctx): Promise<Response> {
|
||||
return ok({ cleared: true });
|
||||
}
|
||||
|
||||
// ==================================================================== 签发 / 续期
|
||||
|
||||
/**
|
||||
* 手动签发一个域名(或强制续期)。
|
||||
*
|
||||
* body: { domain?: string, force?: boolean, noDeploy?: boolean }
|
||||
* · domain 省略 → 对所有启用的域名跑一遍续期检查
|
||||
* · force=true → 忽略剩余天数,强制重签
|
||||
* · noDeploy → 只签不部署(调试)
|
||||
*
|
||||
* ★ 这是本模块唯一会**真正签发证书**的入口,也是耗时最长的(DNS 传播等待
|
||||
* 30s × 授权数 + 轮询),单个域名通常 1~3 分钟。前端要给出明确的进行中提示。
|
||||
*/
|
||||
export async function certIssue(ctx: Ctx): Promise<Response> {
|
||||
const w = await writeAuth(ctx);
|
||||
if ('deny' in w) return w.deny;
|
||||
const body = w.body as { domain?: string; force?: boolean; noDeploy?: boolean };
|
||||
|
||||
const cfg = await loadConfig(ctx.env);
|
||||
const only = String(body.domain || '').trim();
|
||||
const targets = cfg.domains.filter((d) => !only || d.name === only);
|
||||
if (!targets.length) return fail(400, only ? `配置里没有域名「${only}」` : '配置里还没有域名');
|
||||
|
||||
await log(ctx, w.ident, 'issue', `${only || '全部域名'}:开始${body.force ? '强制' : ''}签发`, 'info', only);
|
||||
const results: IssueOutcome[] = [];
|
||||
for (const d of targets) {
|
||||
const r = await issueDomain(ctx.env, d, {
|
||||
force: !!body.force,
|
||||
noDeploy: !!body.noDeploy,
|
||||
by: w.ident.name || '管理员',
|
||||
});
|
||||
results.push(r);
|
||||
}
|
||||
return ok({ results });
|
||||
}
|
||||
|
||||
/** 只看「该不该续期」,不签发 —— 给 UI 的「检查」按钮用,秒回 */
|
||||
export async function certRenewCheck(ctx: Ctx): Promise<Response> {
|
||||
const a = await auth(ctx);
|
||||
if ('deny' in a) return a.deny;
|
||||
const cfg = await loadConfig(ctx.env);
|
||||
const items = [];
|
||||
for (const d of cfg.domains) {
|
||||
if (d.disabled) {
|
||||
items.push({ domain: d.name, action: 'skip', reason: '已停用' });
|
||||
continue;
|
||||
}
|
||||
const rec = await getCert(ctx.env, d.name);
|
||||
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
|
||||
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
|
||||
const left = daysLeft(live.notAfter);
|
||||
// ★ 判定「线上真实剩余天数」而不是 KV 里那份:KV 可能过期/失同步,
|
||||
// 线上才决定读者会不会看到证书过期。
|
||||
const base = left !== null ? left : daysLeft(rec?.expireAt);
|
||||
items.push({
|
||||
domain: d.name,
|
||||
host,
|
||||
source: left !== null ? 'live' : 'stored',
|
||||
daysLeft: base,
|
||||
action: base === null ? 'issue' : base <= RENEW_BEFORE_DAYS ? 'renew' : 'ok',
|
||||
threshold: RENEW_BEFORE_DAYS,
|
||||
issuer: live.issuer || rec?.issuer || '',
|
||||
error: live.ok ? null : live.error || null,
|
||||
});
|
||||
}
|
||||
return ok({ items, threshold: RENEW_BEFORE_DAYS });
|
||||
}
|
||||
|
||||
// ==================================================================== 自检
|
||||
|
||||
interface CheckItem {
|
||||
name: string;
|
||||
kind: 'ca' | 'dns' | 'deploy' | 'target';
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
/** 出问题时的处置建议 */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境自检 —— 把所有「续期时才可能暴露」的配置问题提前查出来。
|
||||
*
|
||||
* ★ 为什么需要这个:证书续期是**无人值守**的。一次配置错误(目录地址少个
|
||||
* `/v2`、API Key 过期、1Panel 忘了加 IP 白名单)在平时完全看不出来,
|
||||
* 等到证书真过期那天才发现 —— 那时站点已经在报错了。
|
||||
* 这个接口让管理员在配完之后立刻能验证全套链路。
|
||||
*
|
||||
* ★ 这里**故意不真正签发**(不消耗 CA 配额、不改 DNS):只做
|
||||
* 「能不能连上 / 认不认凭据」级别的探测。
|
||||
* - CA:拉一次目录,看结构是否完整、是否要求 EAB
|
||||
* - DNS:只做一次只读列举(不写 TXT),验证签名与权限
|
||||
* - 部署:只读列举(多吉云不做写操作、1Panel 不绑站点)
|
||||
*/
|
||||
export async function certSelfCheck(ctx: Ctx): Promise<Response> {
|
||||
const a = await auth(ctx);
|
||||
if ('deny' in a) return a.deny;
|
||||
|
||||
const env = ctx.env;
|
||||
const cfg = await loadConfig(env);
|
||||
const items: CheckItem[] = [];
|
||||
|
||||
// ---- ① 各 CA(acme-eab 凭据)----
|
||||
const accesses = await listAccess(env);
|
||||
const eabNames = accesses.filter((x) => x.type === 'acme-eab' && !x.unreadable).map((x) => x.name);
|
||||
for (const name of eabNames) {
|
||||
const rec = await getAccess(env, name);
|
||||
const url = String(rec?.directoryUrl || '');
|
||||
if (!url) {
|
||||
items.push({ name: `CA ${name}`, kind: 'ca', ok: false, detail: '缺少 directoryUrl' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// 用一个临时账户密钥探测目录(不注册,纯读)
|
||||
const probe = new AcmeClient(url, { jwk: { kty: 'EC', crv: 'P-256', x: 'AA', y: 'AA' } as JsonWebKey, kid: '' });
|
||||
const needEab = await probe.externalAccountRequired();
|
||||
const hasEab = !!(rec?.eabKid && rec?.eabHmacKey);
|
||||
if (needEab && !hasEab) {
|
||||
items.push({
|
||||
name: `CA ${name}`,
|
||||
kind: 'ca',
|
||||
ok: false,
|
||||
detail: `目录可达,但该 CA 要求 EAB 而凭据里没有 eabKid/eabHmacKey`,
|
||||
hint: '到 CA 后台重新生成 EAB 凭据并补进这条凭据',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
name: `CA ${name}`,
|
||||
kind: 'ca',
|
||||
ok: true,
|
||||
detail: `目录可达${needEab ? ',EAB 已配对' : ',无需 EAB'}`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
items.push({
|
||||
name: `CA ${name}`,
|
||||
kind: 'ca',
|
||||
ok: false,
|
||||
detail: errMsg(e),
|
||||
hint: '核对 directoryUrl 是否完整(多数 CA 需要 /v2 之类的版本段)',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!eabNames.length) {
|
||||
items.push({ name: 'CA', kind: 'ca', ok: false, detail: '没有任何 acme-eab 凭据,无法签发' });
|
||||
}
|
||||
|
||||
// ---- ② 各 DNS 凭据(只读列举,验证签名/权限)----
|
||||
for (const d of cfg.domains) {
|
||||
const key = `DNS ${d.dns}`;
|
||||
if (items.some((x) => x.name === key)) continue;
|
||||
const rec = await getAccess(env, d.dns);
|
||||
if (!rec) {
|
||||
items.push({ name: key, kind: 'dns', ok: false, detail: `凭据「${d.dns}」不存在` });
|
||||
continue;
|
||||
}
|
||||
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
|
||||
try {
|
||||
const dns = makeDnsProvider(rec);
|
||||
// ★ 用 _acme-challenge.<主域> 做只读列举:既验证签名有效,
|
||||
// 也验证「这条凭据确实管得着这个域名」——后者才是真正会翻车的点
|
||||
const existing = await dns.listTxt(`_acme-challenge.${host}`);
|
||||
items.push({
|
||||
name: key,
|
||||
kind: 'dns',
|
||||
ok: true,
|
||||
detail: `${rec.type} 凭据可用,能读取 ${host} 的 TXT(现存 ${existing.length} 条)`,
|
||||
});
|
||||
} catch (e) {
|
||||
items.push({
|
||||
name: key,
|
||||
kind: 'dns',
|
||||
ok: false,
|
||||
detail: errMsg(e),
|
||||
hint: '确认密钥有效、且该域名确实在这条凭据的账号下',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ③ 各部署目标 ----
|
||||
const targets = new Set<string>();
|
||||
for (const d of cfg.domains) for (const t of d.deploy) targets.add(t);
|
||||
for (const t of targets) {
|
||||
const credName = t === '1panel' ? '1panel-cn' : t;
|
||||
const rec = await getAccess(env, credName);
|
||||
if (!rec) {
|
||||
items.push({ name: `部署 ${t}`, kind: 'deploy', ok: false, detail: `凭据「${credName}」不存在` });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const dp = makeDeployer(rec);
|
||||
if (t === '1panel') {
|
||||
// ★ 这里要**真**打一次 1Panel 接口 —— 只看「凭据能构造出来」是不够的:
|
||||
// 1Panel 开了「安全登录」之后,面板 API 会搬到随机入口路径下,
|
||||
// 根路径只回一个 HTML 提示页(HTTP 200,不是错误码)。
|
||||
// 不实探的话,这个问题要到证书该续期那天才会暴露。
|
||||
const panel = new OnePanelDeployer(
|
||||
String((rec as { serverUrl?: string }).serverUrl || ''),
|
||||
String((rec as { apiKey?: string }).apiKey || ''),
|
||||
String((rec as { apiVersion?: string }).apiVersion || 'v1') === 'v2' ? 'v2' : 'v1',
|
||||
);
|
||||
const probe = await panel.ping();
|
||||
if (!probe.ok) {
|
||||
items.push({
|
||||
name: `部署 ${t}`,
|
||||
kind: 'deploy',
|
||||
ok: false,
|
||||
detail: probe.error || '1Panel 不可用',
|
||||
hint: probe.hint,
|
||||
});
|
||||
} else {
|
||||
const sites = new Set<string>();
|
||||
for (const d of cfg.domains) for (const s of d.one_panel_sites || []) sites.add(s);
|
||||
const found: string[] = [];
|
||||
for (const s of sites) {
|
||||
try {
|
||||
const w = await panel.inspectSite(s);
|
||||
found.push(`${s}→#${w.id}${w.enable ? `(现绑 ${w.certCN || '?'})` : '(未开 HTTPS)'}`);
|
||||
} catch {
|
||||
found.push(`${s}→未找到`);
|
||||
}
|
||||
}
|
||||
const bad = found.filter((x) => x.includes('未找到'));
|
||||
items.push({
|
||||
name: `部署 ${t}`,
|
||||
kind: 'deploy',
|
||||
ok: bad.length === 0,
|
||||
detail:
|
||||
`1Panel 可达(API ${String((rec as { apiVersion?: string }).apiVersion || 'v2')});` +
|
||||
(found.length ? `站点匹配:${found.join(',')}` : '未配置待绑定站点'),
|
||||
hint: bad.length
|
||||
? `这些站点名在 1Panel 里找不到,部署会跳过:${bad.map((x) => x.split('→')[0]).join(', ')}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push({
|
||||
name: `部署 ${t}`,
|
||||
kind: 'deploy',
|
||||
ok: true,
|
||||
detail: `${dp.kind} 凭据已构造成功${
|
||||
(rec as { accessKey?: string }).accessKey
|
||||
? `(AK ${String((rec as { accessKey?: string }).accessKey).slice(0, 4)}…)`
|
||||
: ''
|
||||
}`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
items.push({ name: `部署 ${t}`, kind: 'deploy', ok: false, detail: errMsg(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ④ 域名配置本身的完整性 ----
|
||||
for (const d of cfg.domains) {
|
||||
if (d.disabled) {
|
||||
items.push({ name: `域名 ${d.name}`, kind: 'target', ok: true, detail: '已停用(不参与自动续期)' });
|
||||
continue;
|
||||
}
|
||||
if (!d.san.length) {
|
||||
items.push({
|
||||
name: `域名 ${d.name}`,
|
||||
kind: 'target',
|
||||
ok: false,
|
||||
detail: '没有配置 SAN,签发时只会覆盖主域名',
|
||||
hint: '需要覆盖子域时在 SAN 里补上(如 usj.cc, *.usj.cc)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!d.deploy.length) {
|
||||
items.push({
|
||||
name: `域名 ${d.name}`,
|
||||
kind: 'target',
|
||||
ok: true,
|
||||
detail: '只签发不部署(deploy 为空)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
name: `域名 ${d.name}`,
|
||||
kind: 'target',
|
||||
ok: true,
|
||||
detail: `${d.san.length} 个 SAN,部署到 ${d.deploy.join(' + ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
return ok({
|
||||
items,
|
||||
summary: {
|
||||
total: items.length,
|
||||
failed: items.filter((x) => !x.ok).length,
|
||||
},
|
||||
threshold: RENEW_BEFORE_DAYS,
|
||||
});
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// ==================================================================== 会话
|
||||
|
||||
/**
|
||||
@@ -630,6 +933,13 @@ export const SSL_ROUTES: { method: string; path: string; handler: (ctx: Ctx) =>
|
||||
{ method: 'POST', path: '/ssl/notify', handler: certNotify },
|
||||
{ method: 'GET', path: '/ssl/log', handler: logList },
|
||||
{ method: 'POST', path: '/ssl/log/clear', handler: logClear },
|
||||
// ★ 签发/续期:POST /ssl/issue 是**真正下单**的那个(慢,1~3 分钟/域名)
|
||||
{ method: 'GET', path: '/ssl/renew-check', handler: certRenewCheck },
|
||||
{ method: 'POST', path: '/ssl/renew-check', handler: certRenewCheck },
|
||||
{ method: 'POST', path: '/ssl/issue', handler: certIssue },
|
||||
// ★ 环境自检:不签发、不写 DNS,只验证全套凭据「连得上、认得对」
|
||||
{ method: 'GET', path: '/ssl/selfcheck', handler: certSelfCheck },
|
||||
{ method: 'POST', path: '/ssl/selfcheck', handler: certSelfCheck },
|
||||
];
|
||||
|
||||
export type { UserRow };
|
||||
Reference in new issue
Block a user