feat: 证书探测改走国内机真 Node —— Workers 拿不到证书正文的兜底方案

根因:Workers 上 cloudflare:sockets 没有 getPeerCertificate,
node:tls 的同名方法是桩函数(调用即抛 not implemented)。

- editor-api 新增 /ssl-probe 本地端点(真 Node,读对端证书正文):
  不外发、不落盘;ssl 白名单放行,admin/ssl 可用,editor/匿名拒绝
- certprobe 改两级:首选国内机(带 X-Editor-Token),失败自动降级本地握手
- certProbe/certCheck 接线 EDITOR_API_BASE + EDITOR_TOKEN,撤掉 ?debug 诊断
- wrangler.toml 显式开 nodejs_compat(compat date 早于默认启用阈值)
- 手写 node:tls 最小类型声明(保持零依赖)
- seed-ssl-config.mjs 净化:真实凭据移到 secrets-backup/certkeeper-seeds.json,
  TOKEN_SECRET 改从 .dev.vars 读;脚本本体不含任何凭据
- role-perm 新增第 9 节 12 项(59/59),UI 探测 37 项全过
This commit is contained in:
zqlit committed 2026-10-06 15:55:22 +08:00
1 parent a40a92f526
commit 432cf5e398
9 files changed
+688 -63

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"selftest:ssl:build": "tsc src/routes/ssl.ts src/lib/role.ts src/lib/certstore.ts src/lib/certvault.ts src/lib/certprobe.ts --outDir .selftest-ssl --module commonjs --target es2022 --moduleResolution node --strict --types ./node_modules/@cloudflare/workers-types --skipLibCheck --esModuleInterop --resolveJsonModule",
"selftest:ssl:build": "tsc src/routes/ssl.ts src/lib/role.ts src/lib/certstore.ts src/lib/certvault.ts src/lib/certprobe.ts src/types/node-tls.d.ts --outDir .selftest-ssl --module commonjs --target es2022 --moduleResolution node --strict --types ./node_modules/@cloudflare/workers-types --skipLibCheck --esModuleInterop --resolveJsonModule",
"selftest:ssl": "npm run selftest:ssl:build && node tools/selftest-ssl.mjs",
"db:init:local": "wrangler d1 execute artalk-cf --local --file=./schema.sql",
"db:init:remote": "wrangler d1 execute artalk-cf --remote --file=./schema.sql",
+162 -56
View File
@@ -36,78 +36,184 @@ export interface TlsInfo {
}
/**
* 用 Workers 的 TCP socket 直连 443 拉对端证书。
* 拉对端证书正文,拿到期日 / SAN / 签发方。
*
* ★ 为什么不用 fetch:fetch 成功只说明 TLS 握手过了,拿不到证书正文,
* 而这正是证书管家最需要的东西(到期日、SAN、签发方)。
* `cloudflare:sockets` 的 `connect()` 返回的对象上有 `getPeerCertificate()`,
* 是 Workers 里唯一能拿到证书的官方途径。
* ★★ 2026-10-06 实测结论:**Cloudflare Workers 拿不到对端证书正文**。
* · `cloudflare:sockets` 的 `Socket` 只有 readable/writable/opened/closed/
* close()/startTls(),**没有** `getPeerCertificate`(网上示例是过期信息)。
* · `node:tls` 看着有 `getPeerCertificate`,但那是**桩函数** ——
* 一调用就 `Error: getPeerCertificate is not implemented
* at TLSSocket.getPeerCertificate (node-internal:internal_tls_wrap:358:11)`。
* · `fetch()` 更拿不到证书(只有响应头)。
*
* 注意:这个 API 在本地 `wrangler dev` 的部分版本里不可用,会抛错 ——
* 所以调用方必须能接受 `ok:false, error:'...'`,不能让它把整个接口带崩。
* 所以探测分两级(本函数内部自动降级):
* ① **首选**:调国内机 editor-api 的 `/ssl-probe`(真 Node,方法真实现了)
* ② 兜底:Workers 本地 `node:tls` 握手 —— 拿不到正文时至少确认「可达」
*
* ★ 为什么不把探针做成「Worker 直接 fetch 一个第三方回显服务」:
* 那等于把要监控的域名清单发给第三方,还得信任它不篡改结果。
* 国内机是自己的机器,editor-api 本来就在跑,加个只读端点最干净。
*/
export async function probeTls(host: string, timeoutMs = 8000): Promise<TlsInfo> {
export async function probeTls(
host: string,
timeoutMs = 8000,
probeBase?: string,
probeToken?: string,
): Promise<TlsInfo> {
const h = String(host || '').trim().toLowerCase();
if (!h || !/^[a-z0-9.*-]+$/.test(h)) return { ok: false, error: '域名不合法' };
if (h.includes('*')) return { ok: false, error: '通配符域名不能直接握手(请指定具体主机名)' };
let socket: { opened: Promise<unknown>; close: () => void; getPeerCertificate?: () => unknown } | null = null;
try {
// 动态 import:本地 dev 环境没有这个模块时,不至于整个 Worker 起不来
const mod = (await import('cloudflare:sockets')) as {
connect: (addr: { hostname: string; port: number }, opts?: { secureTransport?: string; allowHalfOpen?: boolean }) => unknown;
};
const raw = mod.connect(
{ hostname: h, port: 443 },
{ secureTransport: 'on', allowHalfOpen: false },
) as unknown as {
opened: Promise<unknown>;
close: () => void;
getPeerCertificate?: () => unknown;
};
socket = raw;
const timeout = new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error('握手超时')), timeoutMs),
);
await Promise.race([raw.opened, timeout]);
const certOf = raw.getPeerCertificate;
if (typeof certOf !== 'function') {
raw.close();
return { ok: true, handshakeFailed: false, error: '当前运行时拿不到证书正文(只确认了可达)' };
// ── ① 国内机探针(真 Node,能拿到证书正文)──
if (probeBase) {
const viaCn = await probeViaRemote(probeBase, h, timeoutMs, probeToken);
if (viaCn && viaCn.ok && viaCn.notAfter) return viaCn;
// 拿不到正文但确认可达 → 记下来,等本地兜底也没结果时再用它
if (viaCn && viaCn.ok) {
const local = await probeLocal(h, timeoutMs);
return local.notAfter ? local : { ...viaCn, error: local.error || viaCn.error };
}
const cert = certOf.call(raw) as {
notAfter?: number | string;
notBefore?: number | string;
subject?: string;
issuer?: string;
subjectaltname?: string;
} | null;
raw.close();
if (!cert) return { ok: false, error: '对端没返回证书' };
// 国内探针明确报错(DNS/连接失败)通常就是真相,本地再试一次也只是复核
if (viaCn && !viaCn.ok && !viaCn.error?.startsWith('国内探针不可用')) {
const local = await probeLocal(h, timeoutMs);
return local.ok || local.notAfter ? local : viaCn;
}
}
// ── ② 本地兜底 ──
return probeLocal(h, timeoutMs);
}
/** 通过国内机 editor-api 的 /ssl-probe 拿证书(那边是真 Node,方法可用) */
async function probeViaRemote(
base: string,
host: string,
timeoutMs: number,
token?: string,
): Promise<TlsInfo | null> {
try {
const url = base.replace(/\/+$/, '') + '/ssl-probe?host=' + encodeURIComponent(host);
// ★ 探针要走本机 editor-api。该机除 /health 外一律要 X-Editor-Token,
// 所以这里必须带上共享令牌 —— 它只存在于 Worker 环境变量里,浏览器拿不到。
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) headers['X-Editor-Token'] = token;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs + 1000); // 留一点余量给跨境那一跳
const r = await fetch(url, { signal: ctrl.signal, headers });
clearTimeout(t);
if (!r.ok) return null;
const d = (await r.json()) as Partial<TlsInfo> & { error?: string };
return {
ok: true,
notAfter: toMs(cert.notAfter),
notBefore: toMs(cert.notBefore),
subject: String(cert.subject || ''),
issuer: String(cert.issuer || ''),
altNames: parseAltNames(String(cert.subjectaltname || '')),
ok: !!d.ok,
handshakeFailed: !!d.handshakeFailed,
tlsVersion: d.tlsVersion,
notAfter: d.notAfter,
notBefore: d.notBefore,
subject: d.subject,
issuer: d.issuer,
altNames: d.altNames,
error: d.error,
};
} catch (e) {
try {
socket?.close();
} catch {
/* 已经关了 */
}
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: msg, handshakeFailed: /handshake|tls|certificate/i.test(msg) };
return { ok: false, error: '国内探针不可用:' + (e instanceof Error ? e.message : String(e)) };
}
}
/**
* Workers 本地握手。
* ★ 只能确认「可达 / 握手是否成功」,**拿不到证书正文**(原因见 probeTls 注释)。
*/
async function probeLocal(h: string, timeoutMs: number): Promise<TlsInfo> {
return new Promise<TlsInfo>((resolve) => {
let settled = false;
let sock: { destroy: () => void } | null = null;
const done = (r: TlsInfo) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
sock?.destroy();
} catch {
/* 已关闭 */
}
resolve(r);
};
const timer = setTimeout(() => done({ ok: false, error: '握手超时', handshakeFailed: true }), timeoutMs);
(async () => {
try {
// 动态 import:本地 dev 没开 nodejs_compat 时不至于整个 Worker 起不来
const tls = await import('node:tls');
const s = tls.connect(
{
host: h,
port: 443,
servername: h, // SNI:泛域名站点不带这个会拿到默认证书
// ★ 不要传 rejectUnauthorized / timeout —— Workers 的 node:tls
// 只实现了子集,带了会抛「options.<x> is not implemented」。
// 超时由外层定时器兜。
},
() => {
let proto: string | null = null;
try {
proto = s.getProtocol();
} catch {
/* 可能也没实现 */
}
// 方法存在但未实现(线上实测抛 getPeerCertificate is not implemented)
let cert: { valid_to?: string; valid_from?: string; subject?: { CN?: string }; issuer?: { O?: string; CN?: string }; subjectaltname?: string } | null = null;
try {
const fn = (s as unknown as { getPeerCertificate?: unknown }).getPeerCertificate;
if (typeof fn === 'function') cert = (fn as (d?: boolean) => typeof cert).call(s, false);
} catch {
/* 未实现 —— 见函数头注释 */
}
if (!cert?.valid_to) {
return done({
ok: true,
handshakeFailed: false,
tlsVersion: proto || undefined,
error: 'Workers 运行时拿不到证书正文(只确认了可达)',
});
}
done({
ok: true,
handshakeFailed: false,
tlsVersion: proto || undefined,
notAfter: toMs(cert.valid_to),
notBefore: toMs(cert.valid_from),
subject: cert.subject?.CN || '',
issuer: cert.issuer?.O || cert.issuer?.CN || '',
altNames: parseAltNames(String(cert.subjectaltname || '')),
});
},
);
sock = s;
s.on('error', (e: Error) => {
const msg = e?.message || String(e);
done({ ok: false, error: msg, handshakeFailed: /handshake|tls|ssl|certificate|alert/i.test(msg) });
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
done({ ok: false, error: msg, handshakeFailed: /handshake|tls|certificate/i.test(msg) });
}
})();
});
}
/**
* 证书时间 → 毫秒时间戳。
* Node 的 `getPeerCertificate()` 给的是 **OpenSSL 风格字符串**,形如
* `'Dec 7 06:59:59 2026 GMT'`(注意日号前面有**两个空格**)——
* `Date.parse` 能吃下这种,但为稳妥显式兜一层。
* cloudflare:sockets 早期版本给的是数字毫秒,这里也一并兼容。
*/
function toMs(v: number | string | undefined): number | undefined {
if (v == null) return undefined;
if (typeof v === 'number') return v;
if (typeof v === 'number') return v > 1e12 ? v : v * 1000; // 秒 vs 毫秒
const t = Date.parse(v);
return Number.isFinite(t) ? t : undefined;
}
+4 -2
View File
@@ -404,7 +404,9 @@ export async function certProbe(ctx: Ctx): Promise<Response> {
}
if (!host) return fail(400, '缺少 host 参数(或指定的域名没有可探测的 SAN)');
const info = await probeTls(host);
// ★ 探测走国内机 editor-api:Workers 拿不到对端证书正文(node:tls 是桩函数),
// 本机是真 Node 才行。传 base + 共享令牌,失败时 probeTls 内部自动降级。
const info = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
if (!info.ok) {
await log(ctx, a.ident, 'probe', `探测 ${host} 失败:${info.error || '未知原因'}`, 'warn', domain || host);
}
@@ -480,7 +482,7 @@ export async function certCheck(ctx: Ctx): Promise<Response> {
const rec = await getCert(ctx.env, d.name);
const storedLeft = daysLeft(rec?.expireAt);
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
const live = await probeTls(host);
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
let verdict = 'unknown';
if (!live.ok) verdict = 'unreachable';
+61
View File
@@ -0,0 +1,61 @@
/**
* `node:tls` 的最小类型声明。
*
* 为什么手写而不是装 @types/node:
* 1. 本项目**零 npm 依赖**是刻意的(见 editor-api/README 的同款理由),
* 只为了一个 import 就塞进整套 Node 类型(几百 KB)不划算;
* 2. Workers 只实现了 `node:tls` 的**子集**(connect / TLSSocket),
* @types/node 里有大量在 Workers 上会 `Not implemented` 的 API,
* 给了反而容易误用(比如 tls.createServer 在这里是抛错的)。
*
* 这里只声明证书探针真正用到的那几项。
* 见 wrangler.toml 的 compatibility_flags = ["nodejs_compat"]。
*/
declare module 'node:tls' {
export interface PeerCertificate {
/**
* ★ 字段名是 OpenSSL 风格 `valid_from` / `valid_to`,
* **不是** Node 文档里写的 `valid_from`+`valid_to`… 也不是
* `notBefore` / `notAfter`(2026-10-06 实测:后者在 Workers 上是 undefined,
* 曾因此误判成「拿不到证书正文」)。值的格式 Shape 如
* `'Sep 8 06:00:00 2026 GMT'`。
*/
valid_from?: string;
valid_to?: string;
subject?: { CN?: string; [k: string]: unknown };
issuer?: { CN?: string; O?: string; [k: string]: unknown };
/** 'DNS:usj.cc, DNS:*.usj.cc' */
subjectaltname?: string;
fingerprint?: string;
serialNumber?: string;
}
export interface TLSSocket {
/** detailed=false 时只返回对端**叶子**证书(正是我们要的) */
getPeerCertificate(detailed?: boolean): PeerCertificate;
/** 'TLSv1.3' / 'TLSv1.2' / null */
getProtocol(): string | null;
authorized: boolean;
destroy(err?: Error): void;
on(event: string, listener: (...args: never[]) => void): this;
once(event: string, listener: (...args: never[]) => void): this;
setTimeout(ms: number, cb?: () => void): this;
}
export interface ConnectionOptions {
host?: string;
port?: number;
/** SNI —— 泛域名站点必须带,否则拿到的是默认证书 */
servername?: string;
// ★ 下面两个选项在 Workers 上**未实现**,传了会抛
// 「The options.<name> option is not implemented」。声明在此仅作备忘,
// 实际代码里不要传(见 certprobe.ts 的注释)。
// rejectUnauthorized?: boolean;
// timeout?: number;
}
export function connect(
options: ConnectionOptions,
secureConnectionListener?: () => void,
): TLSSocket;
}
+17 -4
View File
@@ -38,9 +38,9 @@ const DAY = 86400000;
const MOCK = {
'/api/v2/ssl/overview': {
domains: [
{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tencent-usj', deploy: ['dogecloud', '1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 61 * DAY, expireText: '2026-12-07 00:00:00', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 61, level: 'ok' },
{ name: 't-t.live', san: ['t-t.live', '*.t-t.live'], dns: 'tencent-tt', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 82 * DAY, expireText: '2026-12-27 00:00:00', issuer: "Let's Encrypt", updatedAt: Date.now(), daysLeft: 82, level: 'ok' },
{ name: '200181.xyz', san: ['200181.xyz', '*.200181.xyz'], dns: 'cloudflare', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 19 * DAY, expireText: '2026-10-25 00:00:00', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 19, level: 'warn' },
{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tencent-usj', deploy: ['dogecloud', '1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 62 * DAY, expireText: '2026-12-07 06:59:59', issuer: 'TrustAsia Technologies, Inc.', updatedAt: Date.now(), daysLeft: 62, level: 'ok' },
{ name: 't-t.live', san: ['t-t.live', '*.t-t.live'], dns: 'tencent-tt', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 83 * DAY, expireText: '2026-12-27 16:02:51', issuer: "Let's Encrypt", updatedAt: Date.now(), daysLeft: 83, level: 'ok' },
{ name: '200181.xyz', san: ['200181.xyz'], dns: 'cloudflare', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 19 * DAY, expireText: '2026-10-25 12:56:26', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 19, level: 'warn' },
{ name: 'new.example.com', san: ['new.example.com'], dns: 'cloudflare', deploy: [], disabled: false, hasCert: false, expireAt: null, expireText: null, issuer: '', updatedAt: null, daysLeft: null, level: 'none' },
],
orphans: ['old.example.org'],
@@ -48,6 +48,14 @@ const MOCK = {
mailEnabled: true,
serverTime: '2026-10-06 12:00:00',
},
// 实测探针的返回形状 —— 与真接口一致(国内机 /ssl-probe 的字段)
'/api/v2/ssl/probe': {
ok: true, handshakeFailed: false, host: 'usj.cc', tlsVersion: 'TLSv1.3',
subject: 'usj.cc', issuer: 'TrustAsia Technologies, Inc.',
altNames: ['usj.cc', '*.usj.cc'],
notAfter: Date.now() + 62 * DAY, notBefore: Date.now() - 28 * DAY,
daysLeft: 62, notAfterText: '2026-12-07 06:59:59',
},
'/api/v2/ssl/access': {
items: [
{ name: 'tencent-usj', type: 'tencentcloud', note: '小赵腾讯云', fields: { secretId: 'AKID********3f2a', secretKey: 'Qk9Y****gAAA' } },
@@ -294,11 +302,16 @@ try {
await sleep(3000);
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); })()`);
await sleep(2000);
// 点一次「实测」——本轮改的就是探测链路,把结果弹窗截进画面才看得见。
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-probe"]'); if (b) b.click(); })()`);
await sleep(2000);
await ev(`document.documentElement.setAttribute('data-theme','light')`);
await sleep(500);
{
const s = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
const out = join(root, '..', '.editor-tmp', 'ssl-panel-light.png');
// ★ 落点必须用**项目**目录(root),不是临时目录:临时目录跑完整个被删,
// 截图写进去等于没写(上一版就是这样,人眼永远看不到)。
const out = join(root, '.editor-tmp', 'ssl-panel-light.png');
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, Buffer.from(s.result.data, 'base64'));
console.log('\n截图:' + out);
+184
View File
@@ -0,0 +1,184 @@
/**
* 证书管家初始配置 —— 把 certimate 里的真实凭据与域名配置迁移进 KV。
*
* 为什么单独写个脚本、不走后台页面:
* 凭据有 7 条、域名 3 组,手点一遍容易漏;脚本还能**当场验一遍**(解密回读)。
*
* ★ 加密必须与本项目 src/lib/certvault.ts 完全一致,否则 Worker 解不开:
* · PBKDF2(SHA-256, 100000 次, 固定盐 'artalk-cf:certvault:v1') 从 TOKEN_SECRET 派生
* · AES-GCM 256,每条自带 12 字节随机 IV
* · 格式 `v1.<iv_b64>.<ct_b64>`(标准 base64,不是 base64url)
*
* ★ 本脚本**不含任何真实凭据**(要进 git)。两个输入都从仓库外读:
* · 凭据:E:/GitHub/secrets-backup/certkeeper-seeds.json(7 条 access,含明文底账)
* · TOKEN_SECRET:blog-admin/.dev.vars(与线上 secret 同值)
*
* 用法:
* node tools/seed-ssl-config.mjs # 预演,只打印不写入
* node tools/seed-ssl-config.mjs --apply # 真正写入 KV
*/
import { readFileSync } from 'node:fs';
import { webcrypto as crypto } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const APPLY = process.argv.includes('--apply');
const NS = 'd7f86a0fb43f4450a10b786fc2635128';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
const SEEDS_FILE = 'E:/GitHub/secrets-backup/certkeeper-seeds.json';
const SALT = 'artalk-cf:certvault:v1';
const VERSION = 'v1';
const b64 = (bytes) => Buffer.from(bytes).toString('base64');
const unb64 = (s) => new Uint8Array(Buffer.from(s, 'base64'));
let _key = null;
async function vaultKey(secret) {
if (_key) return _key;
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), 'PBKDF2', false, ['deriveKey']);
_key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: new TextEncoder().encode(SALT), iterations: 100000 },
base,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
return _key;
}
async function sealJson(secret, value) {
const key = await vaultKey(secret);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(value)));
return `${VERSION}.${b64(iv)}.${b64(new Uint8Array(ct))}`;
}
async function openJson(secret, sealed) {
const key = await vaultKey(secret);
const p = String(sealed).split('.');
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: unb64(p[1]) }, key, unb64(p[2]));
return JSON.parse(new TextDecoder().decode(pt));
}
// ---------------------------------------------------------------- 配置数据
// 来源:certimate 数据库(/1panel/1panel/apps/certimate/certimate/data/data.db)
// access 表 11 条凭据 + workflow 表 3 个证书申请工作流
// 命名沿用 certimate 的语义,方便两边对照排查。
// ★ 真实凭据在 SEEDS_FILE(仓库外),域名结构属于非敏感信息,直接写在下面。
const SEEDS = JSON.parse(readFileSync(SEEDS_FILE, 'utf8'));
const ACCESS = SEEDS.access; // [{ name, type, note, fields: {...} }]
// ★ 注意:certimate 里那两条 1panel 凭据指向 `9.t-t.live:3721`(团团机器)和
// 119.29.215.187:3721(国内机),本配置只收国内机那条 —— 它才是证书真正落地的地方。
// Cloudflare 的 `imql`(apiTokenForZone)没进来:现有 `cloudflare` 那条已够用。
// (seeds 文件里同样只收了国内机那条,与当初迁移时的取舍一致。)
const CONFIG = {
version: 1,
notify: { emails: ['177018615@qq.com'], daysBefore: 30 },
domains: [
{
name: 'usj.cc',
san: ['usj.cc', '*.usj.cc'],
dns: 'tencent-usj',
deploy: ['dogecloud', '1panel'],
disabled: false,
},
{
name: 't-t.live',
san: ['t-t.live', '*.t-t.live'],
dns: 'tencent-tt',
deploy: ['1panel'],
disabled: false,
},
{
name: '200181.xyz',
san: ['200181.xyz', '*.200181.xyz'],
dns: 'cloudflare',
deploy: ['1panel'],
disabled: false,
},
],
};
// ---------------------------------------------------------------- 写入
const devVars = readFileSync(join(ROOT, '.dev.vars'), 'utf8');
const secret = (devVars.match(/^TOKEN_SECRET\s*=\s*"?([^"\r\n]+)"?/m) || [])[1];
const envText = readFileSync(join(ROOT, '.env'), 'utf8');
const accountId = (envText.match(/CLOUDFLARE_ACCOUNT_ID\s*=\s*"?([^"\r\n]+)"?/) || [])[1];
const apiToken = (envText.match(/CLOUDFLARE_API_TOKEN\s*=\s*"?([^"\r\n]+)"?/) || [])[1];
if (!accountId || !apiToken) throw new Error('没读到 Cloudflare 凭据(检查 blog-admin/.env)');
if (!secret) throw new Error('没读到 TOKEN_SECRET(检查 blog-admin/.dev.vars)');
const KV = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${NS}/values/`;
const put = async (key, value) => {
const r = await fetch(KV + encodeURIComponent(key), {
method: 'PUT',
headers: { Authorization: 'Bearer ' + apiToken, 'Content-Type': 'text/plain' },
body: value,
});
if (!r.ok) throw new Error(`写 ${key} 失败: ${r.status} ${await r.text()}`);
};
const getVal = async (key) => {
const r = await fetch(KV + encodeURIComponent(key), { headers: { Authorization: 'Bearer ' + apiToken } });
return r.ok ? await r.text() : null;
};
console.log('模式:', APPLY ? '★ 写入' : '预演(加 --apply 才真正写)');
console.log('TOKEN_SECRET 长度:', secret.length);
console.log();
// ── 凭据:加密后写 ──
console.log('凭据(' + ACCESS.length + ' 条,AES-GCM 加密):');
const sealedMap = new Map();
for (const a of ACCESS) {
const rec = { type: a.type, note: a.note, ...a.fields };
const sealed = await sealJson(secret, rec);
sealedMap.set(a.name, sealed);
// 当场回读验一次:解不开就说明格式和 Worker 不一致,宁可不写
const back = await openJson(secret, sealed);
const ok = JSON.stringify(back) === JSON.stringify(rec);
console.log(` ${ok ? '✓' : '✗'} ${a.name.padEnd(14)} ${a.type.padEnd(14)} ${sealed.slice(0, 22)}…`);
if (!ok) throw new Error('自校验失败:加解密往返不一致 —— 格式与 certvault.ts 不匹配');
}
// ── 域名配置:明文写(不含敏感信息)──
console.log();
console.log('域名配置(明文):');
for (const d of CONFIG.domains) {
const dnsOk = ACCESS.some((a) => a.name === d.dns);
console.log(` ${dnsOk ? '✓' : '✗'} ${d.name.padEnd(14)} dns=${d.dns.padEnd(12)} deploy=[${d.deploy.join(', ')}] SAN=${d.san.join(';')}`);
if (!dnsOk) throw new Error(`域名「${d.name}」引用的 DNS 凭据「${d.dns}」不在凭据清单里`);
}
if (!APPLY) {
console.log();
console.log('(预演结束,未写入任何数据)');
process.exit(0);
}
console.log();
console.log('写入 KV …');
for (const [name, sealed] of sealedMap) {
await put('certkeeper:access:' + name, sealed);
console.log(' ✓ certkeeper:access:' + name);
}
await put('certkeeper:config', JSON.stringify(CONFIG));
console.log(' ✓ certkeeper:config');
// ── 回读验证:确认 Worker 能解开 ──
console.log();
console.log('回读验证:');
const cfgBack = JSON.parse(await getVal('certkeeper:config'));
console.log(' config 域名数:', cfgBack.domains.length, '| 收件人:', cfgBack.notify.emails.join(','), '| 阈值:', cfgBack.notify.daysBefore, '天');
for (const name of sealedMap.keys()) {
const raw = await getVal('certkeeper:access:' + name);
const back = await openJson(secret, raw);
console.log(` ✓ ${name.padEnd(14)} 解出 type=${back.type}`);
}
console.log();
console.log('完成。');
+7
View File
@@ -2,6 +2,13 @@ name = "artalk-cf"
main = "src/index.ts"
compatibility_date = "2026-07-24"
# ★ 证书探针需要 node:tls —— 只有它能在 Workers 里拿到**对端证书正文**
# (`cloudflare:sockets` 的 Socket 接口没有 getPeerCertificate,
# 实测线上只能确认「可达」但读不到 notAfter/SAN/issuer,那样证书管家等于瞎的)。
# 2026-08-04 之后的 compatibility_date 才默认带 nodejs_compat,
# 本项目定在 2026-07-24,所以必须显式打开这个 flag。
compatibility_flags = ["nodejs_compat"]
# ---------------------------------------------------------------------------
# ★ Workers Cache —— 缓存 Worker 自己生成的响应(无需回源 fetch)
#