Files
blog/blog-admin/src/lib/deployer.ts
T
zqlit b821b78718 fix(ssl): 修掉 5 处静默失败,本项目全面接管签发部署,Worker 退回只读
一、1Panel 部署器三个缺陷(其中两个此前完全不可见)

1. `POST /websites/{id}/https` 的字段名是 `websiteSSLId`,不是 `sslId`。
   发 `sslId` 会被 Go 静默忽略成零值 0,于是
   `websiteSSLRepo.GetFirst(WithByID(0))` → 返回
   `HTTP 200 + code 500「服务错误: record not found」`,
   报错文案落在 DB 层,完全指不到参数名 —— 整个 t-t.live 部署被这条卡住。
   对照实验:`sslId=13 → 500` / `websiteSSLId=13 → 200 code=200`。

2. 换证书内容的接口选错。`POST /websites/ssl/update` 的结构体
   `WebsiteSSLUpdate` **根本没有** `certificate` / `privateKey` 字段,
   传了被丢弃、且 `domains` 只从 `otherDomains` 取(不传就清空),
   还会顺带把 `autoRenew` 置 false —— 而它**照样返回 200 success**。
   实测:原样 update 后 5 个站点的 `ssl/*.pem` mtime+md5 一个都没变。
   正确接口是 `POST /websites/ssl/upload` + `sslID > 0`:取记录 → 覆盖
   → 重算 ExpireDate/domains → `UpdateSSLConfig()` → 重新物化站点文件。
   副作用:`Upload()` 把 `primaryDomain` 重算成证书第一个 SAN
   (#11 因此从 `usj.cc` 漂成 `*.usj.cc`)→ 必须补 `domains` 兜底匹配,
   否则每次续期都新建一条重复记录。

3. `deploy()` 幂等捷径漏了「记录被换过」这一维:`sslId` 没变但内容变了时
   会跳过绑定,站点文件就停留在旧证书。改为引入 `replaced` 标志强制重绑。

二、另外两处静默失败

4. `parsePemInfo()` 对**完整链**返回 `{}`:旧实现把 PEM 各段 base64 拼接后
   一次 `atob`,中间段尾部的 `=` 填充导致抛错,整函数返回空 →
   `rec.expireAt` 退化成「签发时刻 + 90 天」。而完整链恰恰是部署器最常
   拿到的形态。改为只解析第一段(叶证书)。
   顺带新增 `derLen()` / `parseSanFromDer()`,精确定位 SAN 扩展
   OID `2.5.29.17` 再读 `[2] dNSName`,替掉原来的字节扫描启发式。
   这条同时是「多吉云复用失效」的根因:判据缺到期时间,只看域名集合
   就永远认为已覆盖 → 续期静默空转。修好后判据带上 `notAfter` 比对(1 天容差)。

5. DNS-01 挑战通知会撞 `400 authorization must be pending`(200181.xyz 连中两次)。
   这是**竞态**不是逻辑错:「先读状态再 POST」挡不住毫秒级窗口。
   已在 POST 侧做幂等容错(只认这一句),最终由 `pollAuthz` 定论。

三、Worker 退回只读

- `crons` 去掉 `10 4 * * *`,`index.ts` 里 renew 分支整体删除
- `POST /ssl/issue` 改 **501 硬拒绝**(而不是静默降级),响应给出国内机命令
- 签发 + 部署整条链路跑在国内机容器 `cn-certkeeper`

四、顺带修掉的两个「配置被悄悄抹掉」

- `configSave()` 不再丢掉表单不管理的 `probe_connect` / `probe_sni`。
  之前管理员在面板改任何一项,这两个字段就会被清空,
  后果是挂在 CDN 后的 t-t.live 探针退回公网、被误判成「还剩 80 多天」,
  源站证书到期也不续。现在保存时从旧配置带过来。

五、新增 4 个常驻运维工具(deploy/cn-certkeeper/src/)

- `renew-one.mjs`      只对单个域名签发+部署(原 `/renew` 无域名过滤,会全量重签)
- `redeploy.mjs`        复用已签好的证书只重跑部署(不碰 ACME,不白烧配额)
- `rollback-dogecloud.mjs` 应急把 CDN 域名绑回指定证书 id
- `txt-inspect.mjs`     `_acme-challenge` 下的 TXT 残留盘点/清理
- `selfcheck-acme.mjs`  CA 层诊断:只读目录 + 复用账户,不签发不部署

六、测试与文档

- 自测新增 [11] 节 8 项 PEM 解析回归(样本是 openssl 现场生成、内联写死的
  叶+中间证书,两段都以 `=` 结尾,正是 bug 现场),含精确值断言:
  > 125 项通过,0 失败
- `npm run typecheck` 零错误
- 方案文档:§9.11 由「待验证」改为定案(`ssl/update` 不物化、
  `ssl/upload+sslID` 才物化);新增 §9.12「本轮又修掉的 5 个静默失败」、
  §9.13「本轮最终状态」、§9.14「certimate 工作流清查」

线上验收:三个域名(t-t.live / usj.cc / 200181.xyz)线上证书均为
LiteSSL ECC、2027-01-04 到期、daysLeft=90、needRenew=false;
1Panel 证书库 5 条精简为 3 条且全部在用;
certimate 停掉全部「会签发并部署」的工作流(团团 / 优世界 / 200181.xyz),
保留三条纯监控告警。
2026-10-06 20:08:28 +08:00

889 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。
*
* 目前两个目标(对应 `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<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,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。
*
* ★ 幂等策略(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<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;
}
/**
* 只读探活:列一次 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<DeployResult> {
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<number> {
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<void> {
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<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 || [];
}
/** 拉证书列表(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<PageResult<{ id: number; primaryDomain?: string }>>('/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<T extends { id: number; primaryDomain?: string; domains?: string; expireDate?: string }>(
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<string, unknown> = {
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<DeployResult> {
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<string, unknown> = {
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<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('');
}