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

+139
View File
@@ -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 上的订阅服务,带上共享令牌。
// 这样国内机后台也能管订阅,且前端不需要持有订阅口令。
// ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。
+113
View File
@@ -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}`);