Files
inkwatch/tests/pairing-code-origin-test.js
jkooe 7c9c2f0605 refactor(rename): pmon → inkwatch 数据层迁移(阶段 B:库名/角色/卷名/连接串)
## 做了什么(顺序有意义)

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` 未编译)。
2026-11-08 20:12:25 +08:00

276 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 配对码来源(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;
}
})();