/** * 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。 * * 目前两个目标(对应 `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; /** * 证书到期时间(epoch ms)。 * * ★ 为什么部署器需要知道「我们这张有多新」(2026-10-06 加): * 多吉云为了不给证书列表堆垃圾,上传前会先「找一张覆盖同组域名的已有证书复用」。 * 但多吉云的 list 接口**不返回 PEM 正文**,没法比对内容 —— 如果只看 * 「域名集合相同」,那么**第一次上传之后,后续续期永远会命中那张旧证书并复用**, * 新证书一张也传不上去:CDN 一直用旧证书,直到旧证书过期。 * 于是改成用**到期时间**当新鲜度代理指标:只有「已有那张到期不早于我们这张」 * 才复用。拿不到这个值时就保守地**不复用**(多传一张的代价是列表多一条, * 而复用错了的代价是线上证书静默过期)。 */ notAfter?: number; } export interface DeployResult { /** 目标标签,日志里用 */ target: string; /** 这次实际做了什么(用于日志/UI 展示),如「绑定 usj.cc」 */ details: string[]; } export interface Deployer { readonly kind: string; /** 把证书推到这个目标的所有配置对象上 */ deploy(cert: DeployCert, opts: DeployOptions): Promise; } 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 = + "\n" + * signature = HMAC-SHA1(secretKey, stringToSign) 的 **hex** * Authorization: `TOKEN :` * 注意:**不含时间戳**(所以要靠 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,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。 * * ★ 幂等策略(2026-10-06 修正):上传前先列 cert 列表,只有「同一组域名 **且到期不早于 * 本次**」的证书才复用。✗ 早期只判域名集合 —— 那会让**第一次上传之后的每次续期 * 都复用那张旧证书**,新证书永远传不上去(CDN 一路用旧证书到过期,日志却写「复用」)。 * 多吉云的 list 不返回 PEM,比不了内容,所以用到期时间当新鲜度代理。 * 复用失败就上传新的,并在绑定完成后清掉被取代的、且已无人引用的旧证书。 * 多吉云上传限速约 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(path: string, body: Record | null): Promise { // ★ 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; } /** * 只读探活:列一次 CDN 域名,验证 AK/SK 与连通性。 * * ★ 与 OnePanelDeployer.ping 保持**同一签名**(返回对象、不抛错), * 这样调用方(cn-certkeeper 的 /preflight、Worker 的 /ssl/selfcheck) * 能统一处理,不必为每种部署器各写一套判错逻辑。 */ async ping(): Promise<{ ok: boolean; error?: string; hint?: string; detail?: string }> { try { const d = await this.call<{ domains?: unknown[] }>('/cdn/domain/list.json', {}); const n = Array.isArray(d?.domains) ? d.domains.length : 0; return { ok: true, detail: `${n} 个 CDN 域名` }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { ok: false, error: msg, hint: /签名|signature|auth|TOKEN/i.test(msg) ? 'AK/SK 不对或签名算法有变(多吉云 → 个人中心 → API 密钥)' : undefined, }; } } async deploy(cert: DeployCert, opts: DeployOptions): Promise { const log = opts.log || (() => {}); const details: string[] = []; // ① 找一张「覆盖同组域名且不比本次旧」的证书复用;没有就传新的。 // (早期只看域名集合,导致第一次之后永远复用旧证书 —— 见 uploadOrReuse 注释) const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log); details.push(`证书 #${certId}`); // ② 逐个域名绑定(★ 字段名是 `id` —— 实测传 `cert_id` 会被服务端**无视**, // 见本文件顶部「用假 id 999999 做对照实验」那段) 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}`); } // ③ 绑定成功后再清理被取代的旧证书(此时它们已不被引用)。 // 放最后且整体 try 住:清理失败绝不推翻上面已经成功的绑定。 await this.cleanupSuperseded(cert, domains, certId, log); return { target: 'dogecloud', details }; } /** * 上传证书;如果已经有「覆盖同一组域名 **且不比我们这张旧**」的证书,复用它。 * * ★★★ 判据**必须**带上到期时间(2026-10-06 修)。 * 多吉云的 list 接口不返回 PEM 正文,没法比对内容;早期实现只比「域名集合」, * 结果是:**第一次上传之后就再也不会传新的了** —— 每次续期都命中那张旧证书 * 然后「复用」,CDN 侧一路用旧证书到过期,而任务日志写着「复用,完成」。 * 现在多比一条:已有证书的 `expire`(秒)要 **>=** 我们这张的 `notAfter`, * 才认为它「至少一样新」而复用。实测证书对象里确实有 `expire` / * `expireText` / `issue` / `info.SAN` 这些字段。 * 拿不到 `cert.notAfter` 时保守处理:**不复用,直接上传**。 * * ★ 上传新证书后顺手清掉**被取代的**同域名集旧证书(`cleanupSuperseded`): * 否则一年 4 次续期会往 CDN 证书列表里堆 4 条。清理有严格前置条件 * (同域名集 + 更旧 + 当前没有任何加速域名在引用它),见那个方法。 */ private async uploadOrReuse(cert: DeployCert, wantDomains: string[], log: (m: string) => void): Promise { const need = new Set( (wantDomains.length ? wantDomains : [cert.domain]).map((s) => s.trim().toLowerCase()).filter(Boolean), ); let existing: { id: number; name?: string; expire?: number; domains?: { name: string }[] }[] = []; try { const list = await this.call<{ certs?: typeof existing }>('/cdn/cert/list.json', {}); existing = list?.certs || []; const hit = existing.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; // ★ 域名集相同还不够:还得确认它不比我们这张旧 if (!cert.notAfter) return false; // 不知道自己的到期时间 → 不敢复用 const theirs = Number(c?.expire || 0) * 1000; // 容差 1 天:`cert.notAfter` 来自 KV 里的 `expireAt`,而**历史记录**里存的是 // 旧版 parsePemInfo 在完整链上解析失败后退化的「签发时刻 + 90 天」(差 1~2 小时)。 // 1 天足以盖住这种偏差;而两次续期之间差着 30 天以上,绝不会误判成可复用。 return theirs > 0 && theirs >= cert.notAfter - 86_400_000; }); if (hit?.id) { log(`多吉云:已有覆盖 ${[...need].join(', ')} 的证书 #${hit.id}(到期不早于本次),复用`); return hit.id; } if (existing.length) { log(`多吉云:列表里 ${existing.length} 张证书都不够新(或域名集不匹配),上传新的`); } } 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); } /** * 清掉被本次上传取代的旧证书(同域名集、更旧、且**当前没有任何加速域名引用**)。 * * 为什么加:`uploadOrReuse` 现在每次续期都会传一张新的,不清理的话 * 多吉云证书列表会一年涨 4 条/域名。 * * ★ 但**刻意保留最新的一代旧证书**(只删更早的)。 * 多吉云的 cert list 不给 PEM(`downloadable: 0`),删掉就真没了 —— * 万一新证书在 CDN 侧出问题(比如最终端不支持 ECDSA), * 手里那张刚被换下来的证书就是**唯一能一键绑回去的回退点**。 * 所以留 1 条:列表最多 2 条/域名集,既不失控也留了退路。 * * 为什么敢删:四个条件同时满足才删 —— * ① 域名集合与本次**完全相同**(不会误删别的域名的证书) * ② `expire` 严格早于我们这张(它确实是旧的那张) * ③ `/cdn/domain/list.json` 里**没有任何** `cert_id` 指向它 * (还在被用的证书绝不删) * ④ 它不是「最新的一代旧证书」(见上) * 任何一步出岔子都只是「少清理一条」,绝不影响线上。 */ private async cleanupSuperseded(cert: DeployCert, wantDomains: string[], keepId: number, log: (m: string) => void): Promise { if (!cert.notAfter) return; 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; expire?: number; domains?: { name: string }[] }[] }>( '/cdn/cert/list.json', {}, ); const dl = await this.call<{ domains?: { cert_id?: number }[] }>('/cdn/domain/list.json', {}); const inUse = new Set((dl?.domains || []).map((d) => Number(d.cert_id)).filter(Boolean)); const superseded: { id: number; expire: number }[] = []; for (const c of list?.certs || []) { if (c.id === keepId) continue; const have = new Set((c.domains || []).map((d) => String(d.name).toLowerCase())); if (have.size !== need.size) continue; let same = true; for (const d of need) if (!have.has(d)) { same = false; break; } if (!same) continue; const exp = Number(c.expire || 0) * 1000; if (!(exp < cert.notAfter)) continue; if (inUse.has(c.id)) { log(`多吉云:旧证书 #${c.id} 仍被加速域名引用,保留不动`); continue; } superseded.push({ id: c.id, expire: exp }); } // 新的在前;保留第一条(= 回退点),只清理更早的 superseded.sort((a, b) => b.expire - a.expire); const keepRollback = superseded[0]; if (keepRollback) log(`多吉云:保留上一代证书 #${keepRollback.id} 作为回退点`); for (const c of superseded.slice(1)) { try { await this.call('/cdn/cert/delete.json', { id: c.id }); log(`多吉云:已清理更早的旧证书 #${c.id}`); } catch (e) { log(`多吉云:清理旧证书 #${c.id} 失败(不影响本次部署):${e instanceof Error ? e.message : e}`); } } } catch (e) { log(`多吉云:清理旧证书失败(不影响本次部署):${e instanceof Error ? e.message : e}`); } } } // ==================================================================== 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` 找本域名对应的证书记录 * ② `POST /websites/ssl/upload` 写内容 —— **带 `sslID` 就是原地更新那条, * 不带才是新建**。这个接口一次就把内容、`domains`、到期时间和各站点的 * `ssl/*.pem` 全都刷新好。 * ✗ 别用 `/websites/ssl/update`:它是「改 ACME 申请设置」的接口, * `certificate`/`privateKey` 会被静默丢弃(续期会变成空转)。 * ③ `GET /websites/:id/https` 读现状,若 `enable && SSL.id === 目标` * **且这次没有换过证书内容** → 跳过(幂等) * ④ `POST /websites/:id/https` 写入(`type:'existed'` 引用已有 SSL) * ★ 引用字段必须叫 **`websiteSSLId`**(不是 `sslId`,写错会回 500 record not found) * * ★ 网站匹配:域名优先(人配的是域名,id 会变),拿不到再当 id 用。 */ /** 1Panel 的搜索分页包装 */ interface PageResult { 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(path: string, method: 'GET' | 'POST', body?: unknown): Promise { 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://:/<入口路径>`, ); } if (/Access Temporarily Unavailable| { try { await this.call('/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://:/<入口路径>`' : 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 { const page = await this.call>('/websites/search', 'POST', { page: 1, pageSize: 200, // ★ 这两个字段 v2 是 required,漏了直接 400 orderBy: 'created_at', order: 'descending', }); return page?.items || []; } /** 拉证书列表(v2 的 ssl/search 同样必须带 orderBy/order,漏了 400) */ private async listSsl(): Promise< { id: number; primaryDomain?: string; domains?: string; provider?: string; description?: string; expireDate?: string; }[] > { const page = await this.call>('/websites/ssl/search', 'POST', { page: 1, pageSize: 100, orderBy: 'created_at', order: 'descending', }); return page?.items || []; } /** * 在证书库里找到「本域名对应的那条记录」。 * * 为什么不能只按 `primaryDomain === cert.domain`: * 1Panel 在 `Upload` 里会把 `primaryDomain` **重算成证书的第一个 SAN** * (`websiteSSL.PrimaryDomain = cert.DNSNames[0]`)。 * 也就是说记录的 primaryDomain 是**CA 给的 SAN 顺序**决定的,不是我们配的。 * 一旦某次签发的 SAN 顺序被调换(先给通配),primaryDomain 就会变成 * `*.usj.cc`,此后按裸域名匹配就再也找不到 → **每次都新建一条重复记录** * (这正是库里堆出 `#12` 那种空壳的成因)。 * 所以补一条兜底:记录自己的 `domains` 里写着目标域名,也算命中。 * 两级匹配 + 多命中时优先到期更晚的一条。 */ private matchSslRecord( list: T[], domain: string, ): T | undefined { const d = domain.toLowerCase(); const byPrimary = list.filter((s) => (s.primaryDomain || '').toLowerCase() === d); const candidates = byPrimary.length ? byPrimary : list.filter((s) => (s.domains || '') .split(',') .map((x) => x.trim().toLowerCase()) .includes(d), ); if (!candidates.length) return undefined; // 多命中时取到期最晚的一条(最可能是「当前在用的」那条) return candidates.slice().sort((a, b) => String(b.expireDate || '').localeCompare(String(a.expireDate || '')))[0]; } /** * 把证书内容写进 1Panel 证书库;同名已有记录就**原地更新**,避免堆重复。 * * ★★★ 唯一正确的接口是 **`POST /websites/ssl/upload`**,用 `sslID` 区分新建/更新 * (2026-10-06 读 v2.1.13 源码 + 实测确认,此前整段逻辑都是错的): * * service/website_ssl.go `Upload(req)`: * if req.SSLID > 0 { websiteSSL = websiteSSLRepo.GetFirst(WithByID(req.SSLID)) } * websiteSSL.PrivateKey = req.PrivateKey; websiteSSL.Pem = req.Certificate * …重新解析证书…(重算 ExpireDate / Type / PrimaryDomain / Domains) * if websiteSSL.ID > 0 { UpdateSSLConfig(*websiteSSL); return Save(websiteSSL) } ← 原地更新 * return Create(...) ← 新建 * * 实测(#13,5 个站点):带 sslID 调一次即可 * · 记录数 5 → 5(不新增) · domains 由空**自动重算**回 `*.t-t.live` * · **5 个站点的 ssl/*.pem 全部刷新** —— `UpdateSSLConfig` 负责物化 * * ✗ 千万**不要**再用 `POST /websites/ssl/update` 来换内容 —— 它名字像, * 实际是「改 ACME 申请设置」的接口。`WebsiteSSLUpdate` 结构体里 * **根本没有 `certificate` / `privateKey` 字段**,传了会被 Go 静默丢弃: * · 证书内容一个字节都不会变(续期 = 完全空转) * · 而且它的 `domains` 来自 `otherDomains`,我们没传 → **把 domains 清空** * · 还会顺手把 `auto_renew` 置 false、`dns_account_id` 置 0 * 最坏的情况是「续期日志一切正常、线上证书永远不变」,只有旧证书到期才暴露。 * * 返回值里的 `replaced`:true 表示「这条记录内容刚被换过、id 没变」。 * 虽然 `upload` 本身已经会刷新站点 `ssl/` 文件,但调用方**再强制绑一次** * 可以顺带让 nginx 重新加载、并确认站点配置确实指向这张证书 —— * 证书链路的静默失败代价是站点直接不可访问,这里的冗余是刻意留的。 */ private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise<{ id: number; replaced: boolean }> { const list = await this.listSsl(); const existing = this.matchSslRecord(list, cert.domain); // 带 sslID = 原地更新;不带 = 新建(同一个接口两种语义) const base: Record = { type: 'paste', certificate: cert.cert, privateKey: cert.key, }; if (existing?.id) { log(`1Panel:更新已有 SSL #${existing.id}(${cert.domain})`); await this.call('/websites/ssl/upload', 'POST', { ...base, sslID: existing.id, // ★ `Upload` 在「更新」分支里会无条件 `websiteSSL.Description = req.Description`, // 所以不把原值带回来就会把记录的说明清掉。带回来。 description: existing.description ?? '', }); return { id: existing.id, replaced: true }; } log('1Panel:上传新证书…'); const before = new Set(list.map((s) => s.id)); await this.call('/websites/ssl/upload', 'POST', { ...base, description: '' }); // ★★ `/websites/ssl/upload` **不回 id**(2026-10-06 实测): // {"code":200,"message":"success","data":null} // 原来的实现直接读 `up?.id`,于是必然抛 // 「1Panel 上传成功但没拿到 SSL id」—— 证书其实**已经建好了**, // 我们却拿不到它,白建一条记录还部署不下去(多跑几次就堆一堆重复证书)。 // 正确姿势:上传后**再查一次库**,把新出现的那条捞回来。 // 挑法用 id 集合差集(比按时间猜稳),兜底再按域名匹配一次。 const after = await this.listSsl(); const fresh = this.matchSslRecord( after.filter((s) => !before.has(s.id)), cert.domain, ); const id = fresh?.id ?? this.matchSslRecord(after, cert.domain)?.id; if (!id) throw new Error('1Panel 上传成功但库里查不到新证书(回查也没找到同名记录)'); return { id, replaced: false }; } async deploy(cert: DeployCert, opts: DeployOptions): Promise { const log = opts.log || (() => {}); const details: string[] = []; const { id: sslId, replaced } = 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, }; // ★★★ 幂等判定的**两个条件缺一不可**: // `enable && SSL.id === 目标 id` **且** `!replaced`(这次没有换过证书内容)。 // // 为什么不能只看 id —— 续期时是「同一条证书记录原地换内容」, // **id 保持不变**(这正是用 `sslID` 更新的好处:站点对它的引用不会断)。 // 于是 `cur.sslId === sslId` 恒为 true → 每个站点都被判「已生效」跳过。 // // 那「跳过」到底有没有风险?取决于换内容那一步有没有顺带刷新站点的 // `www/sites/<域名>/ssl/{fullchain,privkey}.pem`: // · 走 **`/websites/ssl/upload` + `sslID` → 会刷新**(实测 5 个站点 // 的 mtime 全部前进)。也就是这条路径下「跳过」本来是安全的。 // · 但如果哪天又用回 `/websites/ssl/update`(结构体里根本没有 // certificate/privateKey,内容被静默丢弃 → 续期完全空转), // 「跳过」就会掩盖问题:日志全是「跳过(已生效)」、任务报成功, // 而线上 nginx 端到端仍是**旧证书**,一直到旧证书过期才暴露。 // 所以这里刻意保留一次强制重绑:既让 nginx 重新加载、又确认站点配置 // 确实指向这张证书。证书链路的静默失败 = 站点直接不可访问, // 这点冗余代价(每个站点一次 graceful reload,一年 6 次左右)是值得的。 if (cur.enable && cur.sslId === sslId && !replaced) { log(`1Panel:网站 ${key} 已经在用这张证书,跳过`); details.push(`跳过 ${key}(已生效)`); continue; } if (replaced && cur.enable && cur.sslId === sslId) { log(`1Panel:网站 ${key} 指向的证书 #${sslId} 内容刚被更新,强制重绑以刷新 ssl/ 文件`); } // ★ 保留原有配置:HTTP→HTTPS 跳转、协议版本、算法、HSTS —— 只换证书 const body: Record = { websiteId: site.id, type: 'existed', // ★★★ 字段名是 **`websiteSSLId`**,不是 `sslId`(2026-10-06 实测,阻断了一整轮部署)。 // // v2.1.13 的 `dto/request/website.go`: // type WebsiteHTTPSOp struct { // WebsiteID uint `json:"websiteId" validate:"required"` // WebsiteSSLID uint `json:"websiteSSLId"` // ← 这里 // Type string `json:"type" validate:"oneof=existed auto manual"` // ... // } // // 发 `sslId` 时 Go 静默忽略它 → `WebsiteSSLID` 保持零值 0 → // 服务端 `websiteSSLRepo.GetFirst(WithByID(0))` 查不到行 → // 回 **HTTP 200 + code 500「服务错误: record not found」**。 // // 为什么特别坑: // · 报错文案是数据库层的 `record not found`,完全没有「字段名不对」的线索; // 直觉会去怀疑「证书 id 不存在」或「站点 id 不对」,而那两处当时都是对的。 // · `type:'existed'` 是**合法取值**,所以 validate 过了,请求进到了业务层才炸。 // · 1Panel 自己的 dto 里同一个语义有三种写法: // `WebsiteHTTPSOp` → `websiteSSLId` // `BatchWebsiteHttps`→ `websiteSSLId` // `WebsiteCreate.SSLConfig` → `websiteSSLID`(大写 ID) // 照抄 `GetWebsiteHTTPSOp` 的读法或凭感觉写 `sslId` 都会踩。 // 实测对照:发 `sslId` → 500 record not found;发 `websiteSSLId` → 200。 websiteSSLId: 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 { 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(''); }