/** * 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。 * * 目前两个目标(对应 `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; } 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,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。 * * ★ 幂等策略:上传前先列 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(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; } async deploy(cert: DeployCert, opts: DeployOptions): Promise { 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 { 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 { 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 || []; } /** 上传证书;同名同内容的已有 SSL 直接复用,避免面板里堆一堆 */ private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise { // ① 查同名(v2 的 ssl/search 同样需要 orderBy/order) const page = await this.call>('/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 { 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 = { 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 { 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(''); }