## 做了什么(顺序有意义) 1. **备份**:`pg_dump -Fc` → 152MB dump,落在仓库外 `.backup-rename-20261108/`,并留了改名前后的表行数文件。 2. **停应用**(nginx/business/gateway),保留 PG/Redis。 3. `ALTER DATABASE pmon RENAME TO inkwatch` + `ALTER ROLE pmon RENAME TO inkwatch` + `ALTER ROLE inkwatch WITH PASSWORD 'inkwatch_dev_only'`。 4. **复制卷**(不是改名):`pmon_pg_data → inkwatch_pg_data`(3.807GB)、 Redis、nginx 日志三个卷。**旧卷全部保留**,作回滚点。 5. 改 compose 的卷名与环境变量默认值、`deploy/` 下全部脚本/配置、测试默认连接串、 `.env.example`、`docs/`。 6. 用新 compose 起栈,删旧容器(`pmon-*` 共 6 个,含 9 天前的 migrate 容器)。 ## 数据校验:这是本次唯一不能靠"应该没事"的部分 第一次我拿 `pg_stat_user_tables.n_live_tup` 当基线,**那是错的** —— 它是估算值, 实测 `devices` 估 58、实际 14559,`device_metrics` 估 0、实际 257 万。用它做校验 等于没有校验。 改成的办法:把 dump **恢复到一个临时库**,然后两张库**逐表精确 `count(*)` 对比** (排除 `_hyper_*` chunk,父表计数已覆盖)。结果: ``` 唯一差异:device_metrics 当前 2572652 / 备份 2572456 → 当前多 196 行 ``` 多出来而不是少了 —— 因为 dump 是 20:04:57 取的,而停服在 20:07:53,期间设备仍在 正常上报。最新数据时间戳 `12:07:53 UTC` 佐证了这一点。**196 行是正常写入,不是差异。** 逐表其它 40+ 张表行数完全一致。 校验库随后删除,容器内临时 dump 也删了。 ## 两个踩到的坑(都已记录在流程里) **① `docker exec` 不加 `-i` 时 stdin 不转发。** 我第一次用 heredoc 把四条 SQL 喂给 `psql`,结果**一条都没执行、也不报错** —— 看起来像"执行了但没生效"。改成 `-c` 逐条。 **② `ALTER ROLE` 不能重命名当前会话用户**(`session user cannot be renamed`)。 解法是建一个临时超级用户来执行,用完立刻删(已验证 `pg_roles` 里查不到它)。 注意 `DROP ROLE` 同样不能删当前用户,必须换回原身份再删。 ## 有意没做的事 - **旧卷没删**:`pmon_pg_data`(3.807GB) / `pmon_redis_data` / `pmon_nginx_logs` 都留着。 这是唯一的整体回滚点。确认新栈稳定运行一段时间后,可自行 `docker volume rm pmon_pg_data pmon_redis_data pmon_nginx_logs`。 - **`pmon_dbg1` / `pmon_claim_verify` 两个遗留库没动**:它们是历史测试/调试残留, 不是产品库。要清可以直接 `DROP DATABASE`,但删数据这种事我不代做决定。 ## 验证 新栈 5 个容器全 `healthy`,`/health` business 与 gateway 均 **200**; 库内 `users=128 devices=14559`,与迁移前一致。 服务端套件(全部自起源码实例或纯离线): 认领 **57/0**、埋点 **29/0**、配对码来源 **16/0**、权限 **60/0**、schema **116/0** —— 与改名前的数字**完全一致**。 客户端上一提交已验证 **595 通过 / 2 失败**(两条为 `libinkwatch_core.dylib` 未编译)。
276 lines
12 KiB
JavaScript
276 lines
12 KiB
JavaScript
/**
|
||
* 配对码来源(M4″)回归套件 —— 码只能来自设备那条连接
|
||
*
|
||
* ── 修的是什么 ────────────────────────────────────────────────────
|
||
* `POST /api/pairing/code {device_id}` 原先零鉴权地**生成码并在响应体里返回**:
|
||
* 只要知道一个存在的 `device_id`,任何人不登录、不持有任何凭据,就能拿到该设备
|
||
* 的配对码,再去查看端输入,换到该设备的查看授权。比认领洞更浅(认领洞还要登录)。
|
||
*
|
||
* 现在码由设备本地生成、随 `agent:hello` 上报、网关登记、**断连即删**。
|
||
*
|
||
* ── 这套测试为什么必须**自起服务** ────────────────────────────────
|
||
* 本仓库的 `pairing-security-test.js`(84 项)默认打 `http://127.0.0.1:4000`,
|
||
* 那是 docker-compose 的实例 —— 实测其镜像构建于 2026-10-30,而本轮改动在
|
||
* 2026-11-08。**对着旧镜像跑出来的绿,证明不了仓库里的代码是对的。**
|
||
* 所以本套件不用任何外部实例:自己起 business + gateway(源码直跑),
|
||
* 端口随机,跑完关掉。
|
||
*
|
||
* ── 判据的方向:先证明"拿不到",再证明"该拿到的人拿得到"────────────
|
||
* 只验"端点 410"是不够的 —— 一个把配对功能整体做死的实现也能让 410 通过。
|
||
* 所以 B/C/D/E 四条把合法链路整条走通(hello 登记 → verify 成功),
|
||
* 再单独证明攻击者路径已封。
|
||
*/
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const DATABASE_URL = process.env.DATABASE_URL
|
||
|| 'postgres://inkwatch:inkwatch_dev_only@127.0.0.1:5432/inkwatch';
|
||
const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379/12';
|
||
|
||
let pass = 0;
|
||
let fail = 0;
|
||
const failures = [];
|
||
function check(name, cond, detail) {
|
||
if (cond) { pass += 1; console.log(` ✓ ${name}`); } else {
|
||
fail += 1; failures.push(name);
|
||
console.log(` ✗ ${name}${detail === undefined ? '' : ` ${detail}`}`);
|
||
}
|
||
}
|
||
function section(t) { console.log(`\n=== ${t} ===`); }
|
||
|
||
const uniq = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
let biz = null; let gw = null;
|
||
let BIZ = ''; let GW = '';
|
||
|
||
function stopChildren() {
|
||
for (const c of [biz, gw]) {
|
||
if (!c) continue;
|
||
try { c.kill('SIGTERM'); } catch { /* 已退 */ }
|
||
}
|
||
biz = null; gw = null;
|
||
}
|
||
|
||
async function waitHealth(url, timeoutMs = 20000) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const r = await fetch(`${url}/health`);
|
||
if (r.ok || r.status === 503) return true;
|
||
} catch { /* 还没起来 */ }
|
||
await sleep(150);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function post(base, p, body) {
|
||
const r = await fetch(`${base}${p}`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(body || {}),
|
||
});
|
||
let json = null;
|
||
try { json = await r.json(); } catch { /* 非 JSON */ }
|
||
return { status: r.status, body: json };
|
||
}
|
||
|
||
/** 起一条设备连接并在 hello 里报码;返回 {code, close} */
|
||
function helloWithCode(deviceId, code) {
|
||
// 与 tests/claim-proof-test.js 同一解析路径(本仓库既有用法)
|
||
const { io } = require('socket.io-client');
|
||
return new Promise((resolve) => {
|
||
const s = io(GW, { transports: ['websocket'], reconnection: false });
|
||
let done = false;
|
||
const out = {
|
||
code, accepted: false, error: null,
|
||
close: () => { try { s.close(); } catch { /* ignore */ } },
|
||
};
|
||
const finish = (v) => {
|
||
if (done) return;
|
||
done = true;
|
||
resolve({ ...out, ...v });
|
||
};
|
||
s.on('connect', () => {
|
||
s.emit('agent:hello', {
|
||
v: 1, type: 'agent:hello', ts: Date.now(), device_id: deviceId,
|
||
payload: {
|
||
device_name: `m4pp-${uniq}`, platform: 'test',
|
||
encoding: ['json'], claim_code: code,
|
||
},
|
||
});
|
||
});
|
||
s.on('server:config', () => finish({ accepted: true }));
|
||
s.on('error', (d) => finish({ error: d && d.payload }));
|
||
setTimeout(() => finish({}), 4000);
|
||
});
|
||
}
|
||
|
||
function sixDigits(seed) {
|
||
const n = Number(String(seed).replace(/\D/g, '').slice(-6).padEnd(6, '0')) % 1000000;
|
||
return String(n).padStart(6, '0');
|
||
}
|
||
|
||
async function main() {
|
||
let redisMod = null;
|
||
try {
|
||
redisMod = require(path.join(ROOT, 'server/business/node_modules/redis'));
|
||
} catch {
|
||
try { redisMod = require('redis'); } catch { /* 无 */ }
|
||
}
|
||
if (!redisMod) {
|
||
console.log('\n[skip] 找不到 redis 模块(server/business/node_modules 未安装?)');
|
||
return;
|
||
}
|
||
const redis = redisMod.createClient({ url: REDIS_URL });
|
||
try {
|
||
await redis.connect();
|
||
} catch (e) {
|
||
console.log(`\n[skip] 连不上 Redis(${REDIS_URL}):${e.message}`);
|
||
return;
|
||
}
|
||
|
||
const bizPort = 59400 + Math.floor(Math.random() * 200);
|
||
const gwPort = 59600 + Math.floor(Math.random() * 200);
|
||
BIZ = `http://127.0.0.1:${bizPort}`;
|
||
GW = `http://127.0.0.1:${gwPort}`;
|
||
const commonEnv = { ...process.env, DATABASE_URL, REDIS_URL };
|
||
|
||
biz = spawn(process.execPath, ['server/business/index.js'], {
|
||
cwd: ROOT, env: { ...commonEnv, PORT: String(bizPort) }, stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
gw = spawn(process.execPath, ['server/gateway/index.js'], {
|
||
cwd: ROOT, env: { ...commonEnv, PORT: String(gwPort) }, stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
let log = '';
|
||
for (const c of [biz, gw]) {
|
||
c.stdout.on('data', (d) => { log += d.toString(); });
|
||
c.stderr.on('data', (d) => { log += d.toString(); });
|
||
}
|
||
const [okBiz, okGw] = await Promise.all([waitHealth(BIZ), waitHealth(GW)]);
|
||
if (!okBiz || !okGw) {
|
||
console.log(`\n[skip] 临时服务起不来(biz=${okBiz} gw=${okGw}):`);
|
||
console.log(log.trim().split('\n').slice(-10).map((l) => ` ${l}`).join('\n'));
|
||
try { await redis.quit(); } catch { /* ignore */ }
|
||
return;
|
||
}
|
||
console.log(`[setup] 源码直跑:business :${bizPort} / gateway :${gwPort} · redis=${REDIS_URL}`);
|
||
|
||
// ── 一台已注册设备(hello 需要它存在;register 是公开端点,测试可用)
|
||
const reg = await post(BIZ, '/api/devices/register', {
|
||
device_name: `m4pp-${uniq}`, platform: 'test',
|
||
});
|
||
const deviceId = reg.body && reg.body.device_id;
|
||
check('前置:注册一台设备(hello 需要 device_id 存在)', !!deviceId, JSON.stringify(reg.body).slice(0, 120));
|
||
if (!deviceId) { try { await redis.quit(); } catch { /* ignore */ } return; }
|
||
const accountId = `acct_m4pp_${uniq}`;
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
section('A. 攻击者路径:零鉴权取码的端点已下线');
|
||
const a1 = await post(BIZ, '/api/pairing/code', { device_id: deviceId });
|
||
check('★ POST /api/pairing/code → 410(不是 200、不是 404)',
|
||
a1.status === 410, `status=${a1.status}`);
|
||
check('★ 且响应里没有任何 6 位码(扫整个响应体)',
|
||
!/"code"\s*:/.test(JSON.stringify(a1.body || {})), JSON.stringify(a1.body));
|
||
check('★ 错误码是 E_ENDPOINT_GONE(能读懂的失败,不是"路径拼错")',
|
||
a1.body && a1.body.error === 'E_ENDPOINT_GONE', JSON.stringify(a1.body));
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
section('B. 设备未连接时,猜到的码一律无效(码不来自服务端)');
|
||
const ghost = sixDigits(`9${uniq}`);
|
||
check('Redis 里没有该设备的码(它从没连过)',
|
||
(await redis.get(`pairing:device:${deviceId}`)) === null);
|
||
const b1 = await post(BIZ, '/api/pairing/verify',
|
||
{ code: ghost, device_id: deviceId, account_id: accountId });
|
||
check('★ 猜一个码去 verify → 被拒(不是 200)', b1.status !== 200, `status=${b1.status}`);
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
section('C. 合法链路:设备自报码 → 网关登记 → verify 成功');
|
||
const code = sixDigits(`1${uniq}`);
|
||
const h = await helloWithCode(deviceId, code);
|
||
check('hello 被接受(收到 server:config)', h.accepted === true, JSON.stringify(h.error));
|
||
// 登记是异步写 Redis,给它一点时间(网关在 hello 处理里 await 了,但仍留余量)
|
||
let registered = null;
|
||
for (let i = 0; i < 20; i++) {
|
||
registered = await redis.get(`pairing:device:${deviceId}`);
|
||
if (registered === code) break;
|
||
await sleep(100);
|
||
}
|
||
check('★ 网关把设备自报的码登记为 pairing:device:<id>', registered === code,
|
||
`期望 ${code} 实得 ${registered}`);
|
||
check('★ 同一个码也在认领码键里(两条链路同一个字符串,用户只有一个输入框的心智)',
|
||
(await redis.get(`claimcode:${deviceId}`)) === code);
|
||
|
||
const c1 = await post(BIZ, '/api/pairing/verify',
|
||
{ code, device_id: deviceId, account_id: accountId });
|
||
check('★ 出示该码 + 正确 device_id → 授权成功(200)', c1.status === 200,
|
||
`status=${c1.status} ${JSON.stringify(c1.body).slice(0, 120)}`);
|
||
check('★ 码被消费(一次性):Redis 里 pairing:<code> 已消失',
|
||
(await redis.get(`pairing:${code}`)) === null);
|
||
// ⚠️ 必须关掉这条连接再进 D 段:网关的清理判据是「这台设备还有没有别的活连接」,
|
||
// 而 D 段要验的是"断连即删"。若这里不关,E 段关的是**另一条**连接,网关判定
|
||
// "还有人在线"→ 正确行为是**不删**,测试却会报"没删干净" —— 那是我自己的夹具
|
||
// 没管好连接,不是实现错(第一版就是这么红的)。
|
||
h.close();
|
||
await sleep(400);
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
section('D. 设备绑定:码只能用在自己那台设备上');
|
||
const code2 = sixDigits(`2${uniq}`);
|
||
const h2 = await helloWithCode(deviceId, code2);
|
||
check('再次 hello 报新码被接受', h2.accepted === true, JSON.stringify(h2.error));
|
||
for (let i = 0; i < 20; i++) {
|
||
if ((await redis.get(`pairing:device:${deviceId}`)) === code2) break;
|
||
await sleep(100);
|
||
}
|
||
const other = await post(BIZ, '/api/devices/register', {
|
||
device_name: `m4pp-other-${uniq}`, platform: 'test',
|
||
});
|
||
const otherId = other.body && other.body.device_id;
|
||
const d1 = await post(BIZ, '/api/pairing/verify',
|
||
{ code: code2, device_id: otherId, account_id: accountId });
|
||
check('★ 拿 A 设备的码去认 B 设备 → 被拒(码劫持防线)', d1.status !== 200,
|
||
`status=${d1.status}`);
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
section('E. 断连即删:码的语义是"设备此刻在这条连接上活着"');
|
||
h2.close();
|
||
let gone = null;
|
||
for (let i = 0; i < 30; i++) {
|
||
gone = await redis.get(`pairing:device:${deviceId}`);
|
||
if (gone === null) break;
|
||
await sleep(100);
|
||
}
|
||
check('★ 断连后 pairing:device:<id> 被删(不是靠 TTL 慢慢过期)', gone === null,
|
||
`实得 ${gone}`);
|
||
check('★ 认领码同步被删(两条链路同一份在场状态)',
|
||
(await redis.get(`claimcode:${deviceId}`)) === null);
|
||
const e1 = await post(BIZ, '/api/pairing/verify',
|
||
{ code: code2, device_id: deviceId, account_id: accountId });
|
||
check('★ 断连后原码失效(离线设备的码不能继续换授权)', e1.status !== 200,
|
||
`status=${e1.status}`);
|
||
|
||
try { await redis.quit(); } catch { /* ignore */ }
|
||
}
|
||
|
||
(async () => {
|
||
try {
|
||
await main();
|
||
} catch (e) {
|
||
console.log(`\n ✗ 套件异常中止: ${e && e.stack ? e.stack : e}`);
|
||
fail += 1;
|
||
} finally {
|
||
stopChildren();
|
||
console.log('\n' + '='.repeat(52));
|
||
console.log(` 配对码来源(M4″):${pass} 通过 / ${fail} 失败`);
|
||
if (fail) console.log(` 失败项:\n - ${failures.join('\n - ')}`);
|
||
console.log('='.repeat(52));
|
||
process.exitCode = fail === 0 ? 0 : 1;
|
||
}
|
||
})();
|