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
@@ -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 <host>: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 上的订阅服务,带上共享令牌。
|
||||
// 这样国内机后台也能管订阅,且前端不需要持有订阅口令。
|
||||
// ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。
|
||||
|
||||
Reference in new issue
Block a user