feat(ssl): ACME 自动签发与自动续期,实现证书全生命周期闭环
证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。
新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts 部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts 编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
→ 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts 新增 POST /ssl/issue、GET /ssl/renew-check、
POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron 每日 04:10 自动续期检查;cpu_ms 提到 60s
与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。
实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory
测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
This commit is contained in:
1 parent
432cf5e398
commit
43dbc8b75f
14 files changed
+3010
-6
No files matched your search
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。
|
||||
*
|
||||
* 目前两个目标(对应 `certstore.ts` 里的 `DEPLOY_TARGETS`):
|
||||
* · dogecloud 多吉云 CDN —— 上传证书 + 绑到指定加速域名
|
||||
* · 1panel 1Panel 面板 —— 上传证书 + 绑到指定网站(走 openresty)
|
||||
*
|
||||
* ★ 两家的 API 风格完全相反,各自的坑单独记在下面各自的类里。
|
||||
*
|
||||
* ★ 为什么部署要「幂等」(重复调用不出错):
|
||||
* 续期失败一次就可能连着重试;更要紧的是——**每天都会跑一遍**,
|
||||
* 如果每次都无脑新建证书,多吉云那边的证书列表会膨胀成几百条,
|
||||
* 1Panel 那边会不断覆盖同名 SSL。所以这里的每个动作都先查后写。
|
||||
*/
|
||||
|
||||
import type { AccessRecord } from './certstore';
|
||||
|
||||
// ==================================================================== 类型
|
||||
|
||||
export interface DeployCert {
|
||||
/** 主域名(1Panel 用来给 SSL 起名字,多吉云用来做备注) */
|
||||
domain: string;
|
||||
/** 证书链 PEM(叶 + 中间,多吉云要求含完整链) */
|
||||
cert: string;
|
||||
/** 私钥 PEM */
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
/** 目标标签,日志里用 */
|
||||
target: string;
|
||||
/** 这次实际做了什么(用于日志/UI 展示),如「绑定 usj.cc」 */
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export interface Deployer {
|
||||
readonly kind: string;
|
||||
/** 把证书推到这个目标的所有配置对象上 */
|
||||
deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult>;
|
||||
}
|
||||
|
||||
export interface DeployOptions {
|
||||
/** 多吉云:要绑的加速域名列表(空则只上传不绑定) */
|
||||
dogecloudDomains?: string[];
|
||||
/** 1Panel:要绑的网站(域名或 id)列表(空则只上传不绑定) */
|
||||
onePanelSites?: string[];
|
||||
/** 追加日志 */
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
// ==================================================================== 工具
|
||||
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
// ==================================================================== 多吉云 CDN
|
||||
|
||||
/** hex 输出(多吉云签名用) */
|
||||
function bufToHex(buf: ArrayBuffer | Uint8Array): string {
|
||||
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 多吉云(api.dogecloud.com)。
|
||||
*
|
||||
* ★ 签名方式(老派但有性格):
|
||||
* stringToSign = <path[+?query]> + "\n" + <body原始字符串>
|
||||
* signature = HMAC-SHA1(secretKey, stringToSign) 的 **hex**
|
||||
* Authorization: `TOKEN <accessKey>:<signature>`
|
||||
* 注意:**不含时间戳**(所以要靠 HTTPS 防重放);HMAC 用的是 **SHA1** 不是 SHA256;
|
||||
* 输出是 **hex** 不是 base64。这三点任一搞错都只会得到 `401 签名错误`。
|
||||
*
|
||||
* ★ body 必须**原样**参与签名:先序列化成字符串再一起发出去,
|
||||
* 不能签名 JSON.stringify(a) 却发送 JSON.stringify(b)。
|
||||
* 这里统一「先定 body 字符串 → 签名 → 发送同一个字符串」。
|
||||
*
|
||||
* ★ 端点与参数(2026-10-06 用真凭据逐个实测确认,别照抄网上的旧文档):
|
||||
* POST /cdn/domain/list.json {} → { domains: [{id,name,cname,…}] }
|
||||
* POST /cdn/cert/list.json {} → { certs: [{id,note,name,domains,…}] }
|
||||
* POST /cdn/cert/upload.json { note, cert, private } → { id }
|
||||
* POST /cdn/cert/bind.json { id, domain } → {}
|
||||
* POST /cdn/cert/delete.json { id } → {}
|
||||
* 几个容易写错的地方:
|
||||
* · 列域名是 `/cdn/domain/**list**.json`;`/cdn/domain.json` 会回
|
||||
* `400 domain 格式错误`(它其实是「查单个域名」的接口,要传 domain)。
|
||||
* · 上传的私钥字段叫 **`private`**(不是 pri/key/privateKey —— 那三个都会回
|
||||
* `400 私钥格式错误`)。
|
||||
* · 绑定的证书 id 字段是 **`id`**(官方文档如此)。★ 已实测一锤定音:
|
||||
* 用假 id 999999 试 `{cert_id,…}` 回「域名不存在」(参数被无视),
|
||||
* 试 `{id,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。
|
||||
*
|
||||
* ★ 幂等策略:上传前先列 cert 列表,若已存在「同一组域名 + 内容相同」的证书
|
||||
* 就直接复用它的 id,不再上传。多吉云上传限速约 300 次/日,
|
||||
* 每天续期检查跑 3 个域名,不做复用虽然也够,但证书列表会越堆越长。
|
||||
*/
|
||||
export class DogeCloudDeployer implements Deployer {
|
||||
readonly kind = 'dogecloud';
|
||||
private static readonly HOST = 'https://api.dogecloud.com';
|
||||
|
||||
constructor(
|
||||
private readonly accessKey: string,
|
||||
private readonly secretKey: string,
|
||||
) {}
|
||||
|
||||
private async call<T>(path: string, body: Record<string, unknown> | null): Promise<T> {
|
||||
// ★ body 字符串只算一次,签名和发送用同一个
|
||||
const bodyStr = body === null ? '' : JSON.stringify(body);
|
||||
const stringToSign = `${path}\n${bodyStr}`;
|
||||
const key = await crypto.subtle.importKey('raw', enc(this.secretKey), { name: 'HMAC', hash: 'SHA-1' }, false, [
|
||||
'sign',
|
||||
]);
|
||||
const sig = bufToHex(await crypto.subtle.sign('HMAC', key, enc(stringToSign)));
|
||||
|
||||
const r = await fetch(DogeCloudDeployer.HOST + path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `TOKEN ${this.accessKey}:${sig}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: bodyStr || undefined,
|
||||
});
|
||||
const text = await r.text();
|
||||
let d: { code?: number; msg?: string; data?: T };
|
||||
try {
|
||||
d = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`多吉云返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`);
|
||||
}
|
||||
// code === 200 是成功;0 也有接口用(历史遗留),一并认
|
||||
if (d.code !== 200 && d.code !== 0) {
|
||||
throw new Error(`多吉云 ${path} 失败:code=${d.code} ${d.msg || ''}`);
|
||||
}
|
||||
return d.data as T;
|
||||
}
|
||||
|
||||
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
|
||||
const log = opts.log || (() => {});
|
||||
const details: string[] = [];
|
||||
|
||||
// ① 先看有没有可复用的证书(同一组域名)—— 避免每天续期都堆一张新的
|
||||
const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log);
|
||||
details.push(`证书 #${certId}`);
|
||||
|
||||
// ② 逐个域名绑定(★ 字段名是 cert_id,下划线)
|
||||
const domains = (opts.dogecloudDomains || []).map((s) => s.trim()).filter(Boolean);
|
||||
if (!domains.length) {
|
||||
log('多吉云:没有配置要绑定的域名,只上传不绑定');
|
||||
return { target: 'dogecloud', details };
|
||||
}
|
||||
for (const domain of domains) {
|
||||
log(`多吉云:绑定 ${domain}…`);
|
||||
await this.call('/cdn/cert/bind.json', { id: certId, domain });
|
||||
details.push(`绑定 ${domain}`);
|
||||
}
|
||||
return { target: 'dogecloud', details };
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传证书;如果已经有「覆盖同一组域名」的证书,直接复用它的 id。
|
||||
*
|
||||
* ★ 复用判据只看**域名集合**,不比对证书内容:
|
||||
* 多吉云的 list 接口不返回 PEM 正文,比对不了内容;而我们的用途是
|
||||
* 「让这些域名用上新证书」,同一组域名本来就该共用同一张证书。
|
||||
*/
|
||||
private async uploadOrReuse(cert: DeployCert, wantDomains: string[], log: (m: string) => void): Promise<number> {
|
||||
const need = new Set(
|
||||
(wantDomains.length ? wantDomains : [cert.domain]).map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
try {
|
||||
const list = await this.call<{ certs?: { id: number; domains?: { name: string }[] }[] }>(
|
||||
'/cdn/cert/list.json',
|
||||
{},
|
||||
);
|
||||
const hit = (list?.certs || []).find((c) => {
|
||||
const have = new Set((c.domains || []).map((d) => String(d.name).toLowerCase()));
|
||||
if (have.size !== need.size) return false;
|
||||
for (const d of need) if (!have.has(d)) return false;
|
||||
return true;
|
||||
});
|
||||
if (hit?.id) {
|
||||
log(`多吉云:已有覆盖 ${[...need].join(', ')} 的证书 #${hit.id},复用`);
|
||||
return hit.id;
|
||||
}
|
||||
} catch (e) {
|
||||
// 列举失败不阻断部署 —— 大不了多传一张,比整个部署失败好
|
||||
log(`多吉云:列举已有证书失败(继续上传新的):${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
|
||||
log('多吉云:上传证书…');
|
||||
const up = await this.call<{ id?: number | string }>('/cdn/cert/upload.json', {
|
||||
note: `${cert.domain} (${new Date().toISOString().slice(0, 10)})`,
|
||||
cert: cert.cert,
|
||||
// ★ 私钥字段名是 `private` —— 实测 pri/key/privateKey 都会回「私钥格式错误」
|
||||
private: cert.key,
|
||||
});
|
||||
const id = up?.id;
|
||||
if (id === undefined || id === null || id === '') {
|
||||
throw new Error('多吉云上传成功但没返回证书 id(接口可能改了)');
|
||||
}
|
||||
return Number(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================== 1Panel
|
||||
|
||||
/**
|
||||
* 1Panel(自建面板)。
|
||||
*
|
||||
* ★ 签名:`1Panel-Token = md5("1panel" + apiKey + timestamp)`(hex),
|
||||
* 同时带 `1Panel-Timestamp`(unix 秒)。**没有别的材料**,
|
||||
* 与多吉云那种「body 进签名」完全无关 —— 它就是防重放 + 持有密钥即通过。
|
||||
*
|
||||
* ★ 版本:国内机(119.29.215.187:3721)**必须用 v2**。
|
||||
* 2026-10-06 逐条实测的结论(网上和早期笔记里的说法都不准,以实测为准):
|
||||
* · `/api/v2/dashboard/base/os` → `code:200`,真实数据 ✓
|
||||
* · `/api/v1/dashboard/base/os` → **HTTP 200 但正文是 HTML 提示页**
|
||||
* (`Access Temporarily Unavailable`)—— 看起来像限流,其实是 v1 已停用,
|
||||
* 面板把「路径不对」统一渲染成了那个页面。这一点极易误判成「被限流了」。
|
||||
*
|
||||
* ★ v2 的请求体要求和 v1 不同,实测踩过的点:
|
||||
* · `POST /websites/search` 的 `orderBy` / `order` 是 **required**
|
||||
* (漏了会回 `400 参数错误: Key: 'WebsiteSearch.OrderBy' … required`)。
|
||||
* → 固定传 `{orderBy:'created_at', order:'descending'}`。
|
||||
* · `/websites/list`、`GET /websites` 在 v2 下都是 404,别用。
|
||||
* · `GET /websites/:id` 是 404(不是 `GET /websites?id=`)。
|
||||
* · 站点里的 `ssl` 字段在列表里是空的 —— 要拿 https 配置得单独
|
||||
* `GET /websites/:id/https`。
|
||||
*
|
||||
* ★ 部署流程(**必须**先读后写):
|
||||
* ① `POST /websites/ssl/search` 找同名 SSL;有就 `POST /websites/ssl/update` 覆盖,
|
||||
* 没有就 `POST /websites/ssl/upload` 新建 → 得到 SSL id
|
||||
* ② `GET /websites/:id/https` 读现状,若 `enable && ssl.id === 目标` → **跳过**(幂等)
|
||||
* ③ `POST /websites/:id/https` 写入(`type:'existed'` 引用已有 SSL)
|
||||
*
|
||||
* ★ 网站匹配:域名优先(人配的是域名,id 会变),拿不到再当 id 用。
|
||||
*/
|
||||
|
||||
/** 1Panel 的搜索分页包装 */
|
||||
interface PageResult<T> {
|
||||
items?: T[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/** 1Panel v2 的网站对象(只列我们关心的字段) */
|
||||
interface WebSite {
|
||||
id: number;
|
||||
primaryDomain?: string;
|
||||
/** 别名,多个用逗号分隔 */
|
||||
alias?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export class OnePanelDeployer implements Deployer {
|
||||
readonly kind = '1panel';
|
||||
|
||||
constructor(
|
||||
private readonly serverUrl: string,
|
||||
private readonly apiKey: string,
|
||||
// ★ 默认 v2 —— 实测国内机只有 v2 能用(v1 会返回一个假的「限流」HTML 页)
|
||||
private readonly apiVersion: 'v1' | 'v2' = 'v2',
|
||||
) {}
|
||||
|
||||
private get base(): string {
|
||||
return `${this.serverUrl.replace(/\/+$/, '')}/api/${this.apiVersion}`;
|
||||
}
|
||||
|
||||
private async call<T>(path: string, method: 'GET' | 'POST', body?: unknown): Promise<T> {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const md5 = await md5Hex(`1panel${this.apiKey}${timestamp}`);
|
||||
const r = await fetch(this.base + path, {
|
||||
method,
|
||||
headers: {
|
||||
'1Panel-Token': md5,
|
||||
'1Panel-Timestamp': timestamp,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await r.text();
|
||||
let d: { code?: number; message?: string; data?: T };
|
||||
try {
|
||||
d = JSON.parse(text);
|
||||
} catch {
|
||||
// ★ 2026-10-06 实测:1Panel 在「不方便直接回错」时会**返回 HTTP 200
|
||||
// 但内容是 HTML**。两种成因,处置完全不同,所以必须分开报:
|
||||
// ① 开了「安全登录」→ 面板挪到随机入口路径,根路径只回提示页
|
||||
// (正文含 `secure login access` / `1pctl user-info`)
|
||||
// ② 短时间调用过密被限流 → 正文标题 `Access Temporarily Unavailable`
|
||||
// 只按「非 JSON 就抛错」处理的话,两种都会被抓成一句看不懂的
|
||||
// 「返回非 JSON」,排查成本极高。
|
||||
if (/secure login access|1pctl user-info/i.test(text)) {
|
||||
throw new Error(
|
||||
`1Panel 启用了「安全登录」,面板不在根路径下(serverUrl 少了入口路径)。` +
|
||||
`SSH 上机执行 \`1pctl user-info\` 拿到入口,再把 serverUrl 改成 ` +
|
||||
`http://<ip>:<port>/<入口路径>`,
|
||||
);
|
||||
}
|
||||
if (/Access Temporarily Unavailable|<!DOCTYPE/i.test(text)) {
|
||||
throw new Error(
|
||||
`1Panel 拒绝了本次请求(返回「Access Temporarily Unavailable」页面)。` +
|
||||
`通常是短时间调用过于频繁触发限流 —— 等一会儿再试。`,
|
||||
);
|
||||
}
|
||||
throw new Error(`1Panel 返回非 JSON(HTTP ${r.status},${r.headers.get('content-type') || '无 CT'}):${text.slice(0, 200)}`);
|
||||
}
|
||||
// 1Panel 统一信封:code === 200 才是成功
|
||||
if (d.code !== 200) {
|
||||
throw new Error(`1Panel ${path} 失败:code=${d.code} ${d.message || `HTTP ${r.status}`}`);
|
||||
}
|
||||
return d.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连通性 + 可用性探测(只读)。给「环境自检」用。
|
||||
*
|
||||
* ★ 为什么要单独有个 ping 而不是直接 try 某个业务接口:
|
||||
* `GET /dashboard/base/os` 是最轻的只读接口,用它做「面板是否可用」的
|
||||
* 探针最合适 —— 业务接口(网站/证书列表)失败时你分不清是「签名错」
|
||||
* 还是「没数据」,而这个接口必然有数据可回。
|
||||
*/
|
||||
async ping(): Promise<{ ok: boolean; error?: string; hint?: string }> {
|
||||
try {
|
||||
await this.call<unknown>('/dashboard/base/os', 'GET');
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
ok: false,
|
||||
error: msg,
|
||||
hint: msg.includes('Access Temporarily Unavailable')
|
||||
? '1Panel 启用了「安全登录」:面板被挪到了随机入口路径下,根路径只回提示页。' +
|
||||
'需要 SSH 上机执行 `1pctl user-info` 拿到入口,再把 serverUrl 改成 ' +
|
||||
'`http://<ip>:<port>/<入口路径>`'
|
||||
: msg.includes('401')
|
||||
? 'API Key 不对(1Panel → 设置 → API 接口 里重新生成)'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 域名 or 数字 id → 网站对象 */
|
||||
async findWebsite(key: string): Promise<{ id: number; primaryDomain?: string; alias?: string }> {
|
||||
// ★ v2 下 `/websites/:id` 是 404,所以「按 id 找」也得走 search:
|
||||
// 拉一页(orderBy/order 必填)再在前端按 id 过滤。
|
||||
// 顺带这一步就拿到了全量网站,后面按域名找也不用再请求一次。
|
||||
const all = await this.listWebsites();
|
||||
const key_l = key.toLowerCase();
|
||||
|
||||
const match = (w: WebSite) => {
|
||||
if (String(w.id) === key) return true;
|
||||
const names = [w.primaryDomain || '', ...(w.alias || '').split(',')].map((s) => s.trim().toLowerCase());
|
||||
return names.includes(key_l);
|
||||
};
|
||||
|
||||
const hit = all.find(match);
|
||||
if (hit) return hit;
|
||||
throw new Error(
|
||||
`1Panel 里找不到网站「${key}」(可用主域名或网站别名匹配;目前面板上有 ${all.length} 个网站)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 拉全量网站列表(v2 的 search 按 name 过滤不可靠,统一拉回来自己筛) */
|
||||
private async listWebsites(): Promise<WebSite[]> {
|
||||
const page = await this.call<PageResult<WebSite>>('/websites/search', 'POST', {
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
// ★ 这两个字段 v2 是 required,漏了直接 400
|
||||
orderBy: 'created_at',
|
||||
order: 'descending',
|
||||
});
|
||||
return page?.items || [];
|
||||
}
|
||||
|
||||
/** 上传证书;同名同内容的已有 SSL 直接复用,避免面板里堆一堆 */
|
||||
private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise<number> {
|
||||
// ① 查同名(v2 的 ssl/search 同样需要 orderBy/order)
|
||||
const page = await this.call<PageResult<{ id: number; primaryDomain?: string }>>('/websites/ssl/search', 'POST', {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
orderBy: 'created_at',
|
||||
order: 'descending',
|
||||
});
|
||||
const existing = (page?.items || []).find((s) => s.primaryDomain === cert.domain);
|
||||
|
||||
if (existing?.id) {
|
||||
// ② 同名存在 → 用 update 覆盖内容(保持 id 不变,网站那边的引用就不会断)
|
||||
log(`1Panel:更新已有 SSL #${existing.id}(${cert.domain})`);
|
||||
await this.call('/websites/ssl/update', 'POST', {
|
||||
id: existing.id,
|
||||
type: 'paste',
|
||||
certificate: cert.cert,
|
||||
privateKey: cert.key,
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
log('1Panel:上传新证书…');
|
||||
const up = await this.call<{ id?: number } | number>('/websites/ssl/upload', 'POST', {
|
||||
type: 'paste',
|
||||
certificate: cert.cert,
|
||||
privateKey: cert.key,
|
||||
});
|
||||
const id = typeof up === 'number' ? up : up?.id;
|
||||
if (!id) throw new Error('1Panel 上传成功但没拿到 SSL id');
|
||||
return id;
|
||||
}
|
||||
|
||||
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
|
||||
const log = opts.log || (() => {});
|
||||
const details: string[] = [];
|
||||
|
||||
const sslId = await this.uploadSsl(cert, log);
|
||||
details.push(`证书 SSL #${sslId}`);
|
||||
|
||||
const sites = (opts.onePanelSites || []).map((s) => s.trim()).filter(Boolean);
|
||||
if (!sites.length) {
|
||||
log('1Panel:没有配置要绑定的网站,只上传不绑定');
|
||||
return { target: '1panel', details };
|
||||
}
|
||||
|
||||
for (const key of sites) {
|
||||
const site = await this.findWebsite(key);
|
||||
// ★ 先读现状 —— 幂等的关键。已经在用同一张证书就什么都别做。
|
||||
//
|
||||
// ★★ 字段名是 **`SSL`(全大写)**,不是 `ssl`。写成小写会让
|
||||
// `cur.ssl?.id === sslId` 永远为 false → 每次续期都重绑一遍。
|
||||
// 危害不止是多余请求:重绑会 brief 地重载该站点的 nginx 配置。
|
||||
//
|
||||
// ★ 这个 GET 的响应里**带明文私钥**(`data.SSL.privateKey`)——
|
||||
// 绝不能把它写进日志、日志记录或 HTTP 响应。这里只取需要的几个
|
||||
// 标量字段,然后让整个对象尽快离开作用域。
|
||||
const resp = await this.call<{
|
||||
enable?: boolean;
|
||||
SSL?: { id?: number; primaryDomain?: string };
|
||||
httpConfig?: string;
|
||||
SSLProtocol?: string[];
|
||||
algorithm?: string;
|
||||
hsts?: boolean;
|
||||
}>(`/websites/${site.id}/https`, 'GET');
|
||||
|
||||
const cur = {
|
||||
enable: resp?.enable,
|
||||
sslId: resp?.SSL?.id,
|
||||
httpConfig: resp?.httpConfig,
|
||||
SSLProtocol: resp?.SSLProtocol,
|
||||
algorithm: resp?.algorithm,
|
||||
hsts: resp?.hsts,
|
||||
};
|
||||
|
||||
if (cur.enable && cur.sslId === sslId) {
|
||||
log(`1Panel:网站 ${key} 已经在用这张证书,跳过`);
|
||||
details.push(`跳过 ${key}(已生效)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ★ 保留原有配置:HTTP→HTTPS 跳转、协议版本、算法、HSTS —— 只换证书
|
||||
const body: Record<string, unknown> = {
|
||||
websiteId: site.id,
|
||||
type: 'existed',
|
||||
sslId,
|
||||
enable: true,
|
||||
httpConfig: cur.httpConfig || 'HTTPToHTTPS',
|
||||
SSLProtocol: cur.SSLProtocol?.length ? cur.SSLProtocol : ['TLSv1.2', 'TLSv1.3'],
|
||||
algorithm: cur.algorithm || 'RSA',
|
||||
hsts: cur.hsts ?? false,
|
||||
};
|
||||
log(`1Panel:给网站 ${key}(#${site.id})绑定证书…`);
|
||||
await this.call(`/websites/${site.id}/https`, 'POST', body);
|
||||
details.push(`绑定 ${key}`);
|
||||
}
|
||||
|
||||
return { target: '1panel', details };
|
||||
}
|
||||
|
||||
/**
|
||||
* 读某个网站当前的 SSL 绑定情况(只回标量,**绝不回私钥**)。
|
||||
* 给「环境自检」用 —— 让管理员能在续期之前就看出「站点绑的是不是我们要的那张」。
|
||||
*/
|
||||
async inspectSite(
|
||||
key: string,
|
||||
): Promise<{ id: number; primaryDomain: string; enable: boolean; sslId?: number; certCN?: string }> {
|
||||
const site = await this.findWebsite(key);
|
||||
const resp = await this.call<{ enable?: boolean; SSL?: { id?: number; primaryDomain?: string } }>(
|
||||
`/websites/${site.id}/https`,
|
||||
'GET',
|
||||
);
|
||||
return {
|
||||
id: site.id,
|
||||
primaryDomain: site.primaryDomain || '',
|
||||
enable: !!resp?.enable,
|
||||
sslId: resp?.SSL?.id,
|
||||
certCN: resp?.SSL?.primaryDomain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================== 工厂
|
||||
|
||||
/**
|
||||
* 按凭据记录造部署器。
|
||||
* ★ 只认 dogecloud / 1panel;其余抛错而非静默 —— 理由同 makeDnsProvider。
|
||||
*/
|
||||
export function makeDeployer(rec: AccessRecord): Deployer {
|
||||
const t = String(rec.type || '');
|
||||
if (t === 'dogecloud') {
|
||||
const ak = String(rec.accessKey || '');
|
||||
const sk = String(rec.secretKey || '');
|
||||
if (!ak || !sk) throw new Error('多吉云凭据缺少 accessKey / secretKey');
|
||||
return new DogeCloudDeployer(ak, sk);
|
||||
}
|
||||
if (t === '1panel') {
|
||||
const url = String(rec.serverUrl || '');
|
||||
const key = String(rec.apiKey || '');
|
||||
if (!url || !key) throw new Error('1Panel 凭据缺少 serverUrl / apiKey');
|
||||
const ver = String(rec.apiVersion || 'v2') === 'v1' ? 'v1' : 'v2';
|
||||
return new OnePanelDeployer(url, key, ver);
|
||||
}
|
||||
throw new Error(`部署目标不支持凭据类型「${t}」(目前只支持 dogecloud / 1panel)`);
|
||||
}
|
||||
|
||||
// ==================================================================== md5
|
||||
|
||||
/**
|
||||
* 1Panel 要的 md5(hex)。
|
||||
*
|
||||
* ★ 用 `crypto.subtle` 没有 MD5(它是过时算法,WebCrypto 故意不提供),
|
||||
* 所以自己写一份。这里只需要处理 ASCII("1panel" + apiKey + 时间戳),
|
||||
* 但为了将来可能复用它算别的,还是按 UTF-8 字节做了正确处理。
|
||||
* 实现照 RFC 1321;输出小写 hex。
|
||||
*/
|
||||
export function md5Hex(input: string): Promise<string> {
|
||||
return Promise.resolve(md5(enc(input)));
|
||||
}
|
||||
|
||||
function md5(bytes: Uint8Array): string {
|
||||
const S = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14,
|
||||
20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6,
|
||||
10, 15, 21,
|
||||
];
|
||||
const K = new Uint32Array(64);
|
||||
for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0;
|
||||
|
||||
// 补位:0x80 + 0x00... 到 56 mod 64,再附 64 位长度(小端)
|
||||
const len = bytes.length;
|
||||
const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6);
|
||||
withPad.set(bytes);
|
||||
withPad[len] = 0x80;
|
||||
const bitLen = len * 8;
|
||||
// 低 32 位 + 高 32 位(JS 里用除法拆,避免 >> 32 的坑)
|
||||
const lo = bitLen >>> 0;
|
||||
const hi = Math.floor(bitLen / 4294967296) >>> 0;
|
||||
const dv = new DataView(withPad.buffer);
|
||||
dv.setUint32(withPad.length - 8, lo, true);
|
||||
dv.setUint32(withPad.length - 4, hi, true);
|
||||
|
||||
let a0 = 0x67452301;
|
||||
let b0 = 0xefcdab89;
|
||||
let c0 = 0x98badcfe;
|
||||
let d0 = 0x10325476;
|
||||
|
||||
const rotl = (x: number, c: number) => ((x << c) | (x >>> (32 - c))) >>> 0;
|
||||
|
||||
for (let off = 0; off < withPad.length; off += 64) {
|
||||
const M = new Uint32Array(16);
|
||||
for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true);
|
||||
|
||||
let A = a0;
|
||||
let B = b0;
|
||||
let C = c0;
|
||||
let D = d0;
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
let F: number;
|
||||
let g: number;
|
||||
if (i < 16) {
|
||||
F = (B & C) | (~B & D);
|
||||
g = i;
|
||||
} else if (i < 32) {
|
||||
F = (D & B) | (~D & C);
|
||||
g = (5 * i + 1) % 16;
|
||||
} else if (i < 48) {
|
||||
F = B ^ C ^ D;
|
||||
g = (3 * i + 5) % 16;
|
||||
} else {
|
||||
F = C ^ (B | ~D);
|
||||
g = (7 * i) % 16;
|
||||
}
|
||||
F = (F + A + K[i] + M[g]) >>> 0;
|
||||
A = D;
|
||||
D = C;
|
||||
C = B;
|
||||
B = (B + rotl(F, S[i])) >>> 0;
|
||||
}
|
||||
|
||||
a0 = (a0 + A) >>> 0;
|
||||
b0 = (b0 + B) >>> 0;
|
||||
c0 = (c0 + C) >>> 0;
|
||||
d0 = (d0 + D) >>> 0;
|
||||
}
|
||||
|
||||
return [a0, b0, c0, d0].map((x) => {
|
||||
// 每个字按小端输出
|
||||
const b = new Uint8Array(4);
|
||||
new DataView(b.buffer).setUint32(0, x, true);
|
||||
return [...b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||
}).join('');
|
||||
}
|
||||
Reference in new issue
Block a user