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
@@ -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}`);
|
||||
|
||||
Reference in new issue
Block a user