From 432cf5e3989598d87fcbaaa6472c73366a721518 Mon Sep 17 00:00:00 2001 From: zqlit Date: Tue, 6 Oct 2026 15:55:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=81=E4=B9=A6=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=E5=9B=BD=E5=86=85=E6=9C=BA=E7=9C=9F=20Node?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20Workers=20=E6=8B=BF=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=E6=AD=A3=E6=96=87=E7=9A=84=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: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 项全过 --- blog-admin/package.json | 2 +- blog-admin/src/lib/certprobe.ts | 218 ++++++++++++++++++++------- blog-admin/src/routes/ssl.ts | 6 +- blog-admin/src/types/node-tls.d.ts | 61 ++++++++ blog-admin/tools/probe-ssl-ui.mjs | 21 ++- blog-admin/tools/seed-ssl-config.mjs | 184 ++++++++++++++++++++++ blog-admin/wrangler.toml | 7 + editor-api/server.mjs | 139 +++++++++++++++++ editor-api/test/role-perm.mjs | 113 ++++++++++++++ 9 files changed, 688 insertions(+), 63 deletions(-) create mode 100644 blog-admin/src/types/node-tls.d.ts create mode 100644 blog-admin/tools/seed-ssl-config.mjs diff --git a/blog-admin/package.json b/blog-admin/package.json index 7af84376..dca8619e 100644 --- a/blog-admin/package.json +++ b/blog-admin/package.json @@ -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", diff --git a/blog-admin/src/lib/certprobe.ts b/blog-admin/src/lib/certprobe.ts index 207c3a4b..bc8b1cee 100644 --- a/blog-admin/src/lib/certprobe.ts +++ b/blog-admin/src/lib/certprobe.ts @@ -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 { +export async function probeTls( + host: string, + timeoutMs = 8000, + probeBase?: string, + probeToken?: string, +): Promise { 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; 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; - close: () => void; - getPeerCertificate?: () => unknown; - }; - socket = raw; - - const timeout = new Promise((_, 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 { + try { + const url = base.replace(/\/+$/, '') + '/ssl-probe?host=' + encodeURIComponent(host); + // ★ 探针要走本机 editor-api。该机除 /health 外一律要 X-Editor-Token, + // 所以这里必须带上共享令牌 —— 它只存在于 Worker 环境变量里,浏览器拿不到。 + const headers: Record = { 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 & { 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 { + return new Promise((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. 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; } diff --git a/blog-admin/src/routes/ssl.ts b/blog-admin/src/routes/ssl.ts index 87db6cd5..c1bd262c 100644 --- a/blog-admin/src/routes/ssl.ts +++ b/blog-admin/src/routes/ssl.ts @@ -404,7 +404,9 @@ export async function certProbe(ctx: Ctx): Promise { } 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 { 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'; diff --git a/blog-admin/src/types/node-tls.d.ts b/blog-admin/src/types/node-tls.d.ts new file mode 100644 index 00000000..6a079d7a --- /dev/null +++ b/blog-admin/src/types/node-tls.d.ts @@ -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. option is not implemented」。声明在此仅作备忘, + // 实际代码里不要传(见 certprobe.ts 的注释)。 + // rejectUnauthorized?: boolean; + // timeout?: number; + } + + export function connect( + options: ConnectionOptions, + secureConnectionListener?: () => void, + ): TLSSocket; +} diff --git a/blog-admin/tools/probe-ssl-ui.mjs b/blog-admin/tools/probe-ssl-ui.mjs index bab08f1a..72664d04 100644 --- a/blog-admin/tools/probe-ssl-ui.mjs +++ b/blog-admin/tools/probe-ssl-ui.mjs @@ -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); diff --git a/blog-admin/tools/seed-ssl-config.mjs b/blog-admin/tools/seed-ssl-config.mjs new file mode 100644 index 00000000..f3929a3b --- /dev/null +++ b/blog-admin/tools/seed-ssl-config.mjs @@ -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..`(标准 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('完成。'); diff --git a/blog-admin/wrangler.toml b/blog-admin/wrangler.toml index 35f001da..f078a67a 100644 --- a/blog-admin/wrangler.toml +++ b/blog-admin/wrangler.toml @@ -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) # diff --git a/editor-api/server.mjs b/editor-api/server.mjs index 6977bcdf..7fece193 100644 --- a/editor-api/server.mjs +++ b/editor-api/server.mjs @@ -14,6 +14,7 @@ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; +import tls from 'node:tls'; import { makePosts } from './src/posts.mjs'; import { makeGit } from './src/git.mjs'; @@ -415,6 +416,122 @@ async function rssProxy(req, res, url) { res.end(buf); } +// ------------------------------------------------------------------ 证书探针 + +/** + * 对 `host:443` 做一次真实 TLS 握手,把对端**证书正文**读出来。 + * + * ★ 为什么这件事必须在本机做: + * Cloudflare Workers 上拿不到对端证书 —— `cloudflare:sockets` 的 Socket 没有 + * `getPeerCertificate`,而 `node:tls` 的该方法在 Workers 里是个**桩函数** + * (一调用就 `Error: getPeerCertificate is not implemented`)。本机是真 Node, + * 方法是真实现的,所以证书管家把这些探测请求派发到这里来跑。 + * + * ★ 安全边界:这个端点**只读公开的 TLS 握手结果**(到期日 / SAN / 签发方), + * 不含任何密钥、不会带回证书私钥,也不会因为我们配了哪家 DNS 而泄露什么 —— + * 任何人 `openssl s_client -connect :443` 都能拿到同样的信息。 + * 它仍然排在鉴权之后(见下方调用处),不开放给匿名访问。 + * + * ★ 字段名注意:Node 的 `getPeerCertificate()` 返回的是 **OpenSSL 风格** + * `valid_from` / `valid_to`(形如 `Dec 7 06:59:59 2026 GMT`,日号前两个空格), + * **不是** `notBefore` / `notAfter`。 + */ +function sslProbe(host, timeoutMs = 8000) { + return new Promise((resolve) => { + const h = String(host || '').trim().toLowerCase(); + if (!h || !/^[a-z0-9.-]+$/.test(h)) { + return resolve({ ok: false, error: '域名不合法' }); + } + + let settled = false; + let socket = null; + const finish = (r) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + socket?.destroy(); + } catch { + /* 已经关了 */ + } + resolve(r); + }; + + const timer = setTimeout( + () => finish({ ok: false, handshakeFailed: true, error: '握手超时(' + timeoutMs + 'ms)' }), + timeoutMs, + ); + + try { + socket = tls.connect( + { + host: h, + port: 443, + servername: h, // SNI:同 IP 多站时必须带,否则拿到的是默认证书 + // 证书过期 / 自签也要能把正文读出来 —— 探针的任务是「报告」,不是「拒绝」 + rejectUnauthorized: false, + }, + () => { + let cert = null; + try { + cert = socket.getPeerCertificate(false); + } catch { + /* 理论上不会发生在真 Node 上 */ + } + if (!cert || !cert.valid_to) { + return finish({ ok: false, error: '对端没有返回证书' }); + } + let proto = null; + try { + proto = socket.getProtocol(); + } catch { + /* 忽略 */ + } + finish({ + ok: true, + handshakeFailed: false, + tlsVersion: proto || undefined, + notAfter: toMs(cert.valid_to), + notBefore: toMs(cert.valid_from), + subject: (cert.subject && cert.subject.CN) || '', + issuer: (cert.issuer && (cert.issuer.O || cert.issuer.CN)) || '', + altNames: parseAltNames(cert.subjectaltname || ''), + }); + }, + ); + + socket.on('error', (e) => { + const msg = (e && e.message) || String(e); + // 握手失败(SNI 不匹配 / 协议不支持)与 DNS/连接失败要分开说, + // 前者修证书,后者修网络,运维动作完全不同。 + finish({ + ok: false, + error: msg, + handshakeFailed: /handshake|tls|ssl|certificate|alert/i.test(msg), + }); + }); + } catch (e) { + finish({ ok: false, error: (e && e.message) || String(e) }); + } + }); +} + +/** OpenSSL 时间串 → 毫秒时间戳(`Dec 7 06:59:59 2026 GMT`) */ +function toMs(v) { + if (v == null) return undefined; + if (typeof v === 'number') return v > 1e12 ? v : v * 1000; + const t = Date.parse(v); + return Number.isFinite(t) ? t : undefined; +} + +/** `DNS:usj.cc, DNS:*.usj.cc` → `['usj.cc','*.usj.cc']` */ +function parseAltNames(s) { + return String(s || '') + .split(',') + .map((x) => x.trim().replace(/^DNS:/i, '')) + .filter(Boolean); +} + // ------------------------------------------------------------------ 路由 const routes = []; @@ -671,6 +788,7 @@ const server = http.createServer(async (req, res) => { url.pathname === '/health' || url.pathname === '/admin/session' || url.pathname === '/admin/logout' || + url.pathname === '/ssl-probe' || SSL_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/')); if (!allow) { json(res, 403, { error: 'SSL 管理员只能使用证书管家' }); @@ -678,6 +796,27 @@ const server = http.createServer(async (req, res) => { } } + // ---------------------------------------------------------- 证书探针(本地实现) + // ★ 放在 SSL 反代分支**之前**:这条路径不反代去 Cloudflare,而是在本机跑。 + // 原因见 sslProbe() 注释 —— Workers 拿不到对端证书正文,本机才行。 + // 鉴权已经在上面的 identify() 完成(Cookie 会话或 X-Editor-Token 二选一)。 + // 角色判定与证书管家一致:admin / ssl 放行,editor 拒绝。 + if (url.pathname === '/ssl-probe') { + if (me.role !== 'admin' && me.role !== 'ssl') { + json(res, 403, { error: '需要管理员或 SSL 管理员权限' }); + return; + } + const host = String(url.searchParams.get('host') || '').trim(); + if (!host) { + json(res, 400, { ok: false, error: '缺少 host 参数' }); + return; + } + const t0 = Date.now(); + const info = await sslProbe(host); + json(res, 200, { ...info, host, ms: Date.now() - t0 }); + return; + } + // 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。 // 这样国内机后台也能管订阅,且前端不需要持有订阅口令。 // ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。 diff --git a/editor-api/test/role-perm.mjs b/editor-api/test/role-perm.mjs index be1bcd33..abcfca47 100644 --- a/editor-api/test/role-perm.mjs +++ b/editor-api/test/role-perm.mjs @@ -288,6 +288,118 @@ async function sectionCfLogin() { // ------------------------------------------------------------------ 跑 +/** + * ⑨ SSL 管理员与证书探针。 + * + * 这一节单独起一个 server(RSS_API_BASE 指到死地址),因为要验证的是 + * **本机白名单**:/ssl-probe 是本地实现、不反代出去,所以它必须能在 + * 「连不上 Cloudflare」的情况下照常工作(证书管家就是为这种时候准备的)。 + */ +async function sectionSslRole() { + section('⑨ SSL 管理员与证书探针'); + + const PORT3 = 8096; + const srv = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], { + env: { + ...process.env, + PORT: String(PORT3), + BIND_HOST: '127.0.0.1', + BLOG_ROOT: BLOG, + TRASH_DIR: TRASH, + SESSION_FILE: path.join(TMP, 'sessions3.json'), + EDITOR_TOKEN: TOKEN, + ADMIN_USER: 'admin', + ADMIN_PASS, + GIT_BRANCH: 'main', + PUSH_REMOTES: 'origin', + // 故意指向连不上的地址:证明 /ssl-probe 不依赖 Cloudflare + RSS_API_BASE: 'http://127.0.0.1:1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let log3 = ''; + srv.stdout.on('data', (d) => (log3 += d)); + srv.stderr.on('data', (d) => (log3 += d)); + + const B3 = `http://127.0.0.1:${PORT3}`; + for (let i = 0; i < 50; i++) { + try { + if ((await fetch(B3 + '/health')).ok) break; + } catch {} + await new Promise((s) => setTimeout(s, 120)); + } + + const call3 = async (method, p, { ident, headers = {} } = {}) => { + const h = { ...headers }; + if (ident) { + h['X-Editor-Token'] = TOKEN; + h['X-Editor-Uid'] = ident.uid; + h['X-Editor-User'] = encodeURIComponent(ident.name); + h['X-Editor-Role'] = ident.role; + } + const res = await fetch(B3 + p, { method, headers: h, redirect: 'manual' }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + return { status: res.status, data }; + }; + + const SSLMGR = { uid: '9', name: '证书管理员', role: 'ssl' }; + + // ---- SSL 管理员:证书管家相关放行 ---- + // /ssl-probe 是本地实现,缺 host 应回 400(说明走到了实现处), + // 而不是 403(被白名单拦下)。 + let r = await call3('GET', '/ssl-probe', { ident: SSLMGR }); + check('SSL 管理员能进 /ssl-probe(缺 host → 400,不是 403)', r.status === 400, 'got ' + r.status + ' ' + JSON.stringify(r.data)); + + // 非法域名属于「探针跑完但结果是否定的」,用 200 + ok:false 表达业务结果, + // 不用 4xx —— 前端要能把它当普通探测结果渲染,而不是当接口报错。 + r = await call3('GET', '/ssl-probe?host=' + encodeURIComponent('不合法域名'), { ident: SSLMGR }); + check('非法域名 → 200 且 ok=false(业务结果,不是接口错误)', r.status === 200 && r.data.ok === false, 'got ' + r.status + ' ' + JSON.stringify(r.data)); + + // ---- SSL 管理员:写作/评论相关一律拒 ---- + r = await call3('GET', '/posts', { ident: SSLMGR }); + check('SSL 管理员列文章 → 403', r.status === 403, 'got ' + r.status); + + r = await call3('POST', '/posts', { ident: SSLMGR }); + check('SSL 管理员新建文章 → 403', r.status === 403, 'got ' + r.status); + + r = await call3('GET', '/git/status', { ident: SSLMGR }); + check('SSL 管理员看 git 状态 → 403', r.status === 403, 'got ' + r.status); + + r = await call3('GET', '/health', { ident: SSLMGR }); + check('SSL 管理员能看 /health(白名单)', r.status === 200, 'got ' + r.status); + + // ---- 编辑 / 管理员:探针也要按角色放行 ---- + r = await call3('GET', '/ssl-probe', { ident: ADMIN }); + check('管理员能进 /ssl-probe', r.status === 400, 'got ' + r.status); + + r = await call3('GET', '/ssl-probe?host=example.com', { ident: EDITOR }); + check('编辑进 /ssl-probe → 403(不是给编辑用的)', r.status === 403, 'got ' + r.status); + + // ---- 匿名:不能进(这条路径不是公开探测服务)---- + r = await call3('GET', '/ssl-probe?host=example.com'); + check('匿名进 /ssl-probe → 401', r.status === 401, 'got ' + r.status); + + // ---- 真网络探测:拿不到不算失败(沙箱/无外网),拿到就校验字段形状 ---- + r = await call3('GET', '/ssl-probe?host=usj.cc', { ident: ADMIN }); + if (r.status === 200 && r.data.ok && r.data.notAfter) { + check('真探测 usj.cc 拿到 notAfter(毫秒时间戳)', typeof r.data.notAfter === 'number' && r.data.notAfter > Date.now(), String(r.data.notAfter)); + check('真探测带上签发方', !!r.data.issuer, JSON.stringify({ issuer: r.data.issuer })); + check('真探测带上 SAN', Array.isArray(r.data.altNames) && r.data.altNames.length > 0, JSON.stringify(r.data.altNames)); + } else { + console.log(' ⚠️ 跳过真网络探测(本次连不上 usj.cc:' + (r.data && r.data.error) + ')'); + } + + srv.kill(); +} + +// ------------------------------------------------------------------ 跑 + async function main() { await setupRepo(); if (!(await waitUp())) { @@ -446,6 +558,7 @@ async function main() { check('没会话时 → 401 且 mode=direct', r.status === 401 && r.data.mode === 'direct', JSON.stringify(r.data)); await sectionCfLogin(); + await sectionSslRole(); console.log('\n' + '─'.repeat(60)); console.log(`通过 ${pass} / ${pass + fails.length}`);