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:
1 parent
a40a92f526
commit
432cf5e398
9 files changed
+688
-63
No files matched your search
+162
-56
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Vendored
+61
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user