一、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),
保留三条纯监控告警。
889 lines
41 KiB
TypeScript
889 lines
41 KiB
TypeScript
/**
|
||
* 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。
|
||
*
|
||
* 目前两个目标(对应 `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('');
|
||
}
|