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

+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;
}