Files
jkooe 8903871919 feat(launcher): R10 客户端自愈——device_id 被降级为 null 时换新 id(免 PIN 闭环)
## 缺陷(QA 观察 2,用户可感知的那一半)
服务端已把「500 + 永久丢设备」消灭了,但客户端从不读响应里的 `device_id`:
`agent.ts` 的心跳只取 `result.data.serverTime`。后果链:
1. 两台机器共用 settings.json → 撞 `idx_devices_uuid`;
2. 服务端把后到的那台降级为 `device_id = null`(不 500,在线状态保住)✅;
3. 客户端毫无反应 → 下一轮继续上报**同一个被占用的 id** → 永远 `null`;
4. 本机证书永远进不了对端 Sunshine 目录 → **每次串流都要手输 PIN,无自愈路径**。

免 PIN 是本项目核心目标,所以这条必须补。

## 修法
- `core/config.ts` 新增 `regenerateDeviceId()`:**无条件**换一个新 id 并持久化。
  与 `ensureDeviceId()`(保证有 id、绝不覆盖)**职责严格分开** —— 给后者加
  `force` 也能 work,但等于在唯一一个「不该覆盖」的入口上开一个能覆盖的开关。
- `agent.ts`:
  - `AgentDeps` 新增 `regenerateDeviceId?()` 与 `readDeviceId?()`;
  - 内部 `deviceId` 由「构造时快照」改为可变字段,心跳请求体用它;
  - 心跳成功后调 `healDeviceIdIfSuppressed(result.data?.device_id)`;
  - 判据严格 `=== null`:`undefined` 表示老服务端根本不回该字段,与「被降级」无关,
    误触发会导致「每轮换一个 id、永远撞不完」;
  - 换完立即持久化,下一轮心跳(≤30s)生效;不做忙等重试,新 id 若又撞则下一轮自然再换;
  - 生成器未注入 / 返回同值 / 抛异常时**都不打挂心跳**(就地捕获 + warn)。
- `certs.ts`:`CertServiceDeps.deviceId` 允许传 `() => string`,每次调用现取。
  否则换 id 后 `skipped_self` 仍用旧 id 比对,会把**自己的**证书当 peer 写进本机
  Sunshine 目录,留下一个永远「受管」的自我证书文件(prune 清不掉)。
- `main.ts`:`currentDeviceId` 改为可变局部变量,向下传工厂函数而非快照。

## 测试(launcher 493 → 502)
`test/agent.test.ts` 新增 6 例:
1. `响应 device_id === null → 生成新 id 并持久化,且与旧 id 不同`
2. `下一轮心跳上报的是新 id(旧 id 不再出现在请求体里)`(断言第二次调用入参)
3. `响应是正常 id → 绝不触发重新生成(防「每轮换一个 id」的抖动)`
4. `响应没有 device_id 字段(老服务端兼容)→ 同样不触发重新生成`
5. `未注入 regenerateDeviceId → 只记 warn,不抛异常、心跳仍算成功`
6. `生成器返回与旧值相同的 id → 不切换(否则就是「假自愈」)`
`test/config.test.ts` 新增 3 例:真的写回设置文件、换完后 `ensureDeviceId` 返回新值、
原本无 id 时也能换。

## 顺带|观察 1 的口径统一(一行)
`insertDevicePresence` 第 ③ 层由无条件 `null` 改为
`racedAfterRetry.device_id ?? null`(保留原值),与 `existing` 分支
`deviceId ?? fallbackId` 的口径一致;避免将来多 worker 共享 DB 时把并发请求刚写入的
合法 id 抹掉一个 tick。
注释里补上 QA 指出的事实:**①③ 在单进程下不可达**(`node:sqlite` 是同步 API,
handler 内无 `await`,两次查询之间插不进别的请求)—— 这正好解释了旧代码为什么
**必然** 500(旧 catch 只有「重查同名」一条路,而它恒走不到)。保留为多 worker 预留。

## 文档
- `docs/troubleshooting.md` §16:新增「两台机器共用同一 `device_id` ⇒ 一个心跳周期内
  自动恢复」的首条处置(含两条 warn 的原文与判据),并把
  `本机 device_id 已被他人占用,已换新 id` 加入「现象」(注明是自愈动作不是故障)。
- `docs/api.md` R10 段:补「客户端必须读这个 `null` 并换 id,否则降级等于永久无 id」,
  含 `=== null` 与 `undefined` 的区别。
- README 数字同步。

## 记录澄清
QA 指出 `30fc269` 的 message 把 `QA-R10f/g/h/i` 记成「新增」,实际是 QA 写在工作区、
我提交时一并收录。归属记错一格,在此说明;断言与代码无误,不改历史。

## 一并入库
`server/test/qa-r10-concurrency.test.mjs`(QA 第 3 轮并发对抗用例 6 例)。

验证:server 82/82(覆盖率 90.18%,smoke 通过);launcher 502/502
(typecheck / eslint / prettier 全绿,覆盖率 91.89%);gitleaks protect --staged 干净。
2026-10-26 03:02:43 +08:00

680 lines
27 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.
// Only in One - 账户服务器(控制面)。
//
// 职责:用户认证 / 设备注册表 / 证书存储。**数据面(音视频、USB、文件)永不经过这里。**
//
// 本文件只做「组装」:
// config(强校验)→ initDb → migrate → 中间件 → 路由 → 统一错误出口。
//
// 安全红线(架构 §9.7):
// 1. 证书只存公钥;含 `PRIVATE KEY` 的 PEM 一律 400 拒绝。
// 2. 证书指纹**始终由服务端计算**,客户端提交的 `fingerprint` 无条件忽略(R-P0-04)。
// 3. 日志绝不记录口令原文、token 全文、证书全文。
// 4. 端口/主机/密钥全部来自 config.js,本文件不读 process.env。
import express from 'express';
import { pathToFileURL } from 'node:url';
import { loadConfig } from './config.js';
import { initDb } from './db.js';
import { runMigrations } from './migrate.js';
import { createLogger } from './logger.js';
import {
AppError,
sendOk,
errorHandler,
notFoundHandler,
securityHeaders,
legacyApiRedirect,
} from './errors.js';
import { createTokenService } from './tokens.js';
import { createRateLimiter } from './ratelimit.js';
import { hashPassword, safeVerify, isWellFormedHash, sha256hex, parsePublicCertPem } from './crypto.js';
import {
isValidUsername,
normalizeUsername,
describePasswordProblem,
isValidDeviceName,
isValidDeviceId,
isValidPlatform,
isRoutableIPv4,
normalizeEmail,
} from './validate.js';
/** 在线判定阈值:与客户端共用同一组常量(架构 §4.5)。 */
export const ONLINE_THRESHOLD_MS = 90_000;
/** 进程启动时间,用于 health 的 uptimeMs。 */
const STARTED_AT = Date.now();
/**
* 同步路由包装:把同步抛出转成 `next(err)`,交给统一错误中间件。
* @param {(req: import('express').Request, res: import('express').Response) => void} handler
* @returns {import('express').RequestHandler}
*/
function route(handler) {
return function wrappedRoute(req, res, next) {
try {
handler(req, res);
} catch (error) {
next(error);
}
};
}
/**
* 把设备行转成 `DeviceSummary`(架构 §4.3.3)。
* @param {any} row
* @param {number} now 当前时间(ms)
* @returns {Record<string, unknown>}
*/
function toDeviceSummary(row, now) {
const lastSeen = typeof row.last_seen === 'number' ? row.last_seen : null;
return {
id: Number(row.id),
device_id: row.device_id === undefined ? null : row.device_id,
device_name: String(row.device_name),
platform: row.platform === undefined ? null : row.platform,
lan_ip: row.lan_ip === undefined ? null : row.lan_ip,
tailscale_ip: row.tailscale_ip === undefined ? null : row.tailscale_ip,
last_seen: lastSeen,
online: lastSeen !== null && now - lastSeen <= ONLINE_THRESHOLD_MS,
};
}
/**
* 创建 Express 应用(纯组装,不做 listen,不做 IO)。
*
* @param {object} options
* @param {Readonly<object>} options.config 已校验配置
* @param {import('node:sqlite').DatabaseSync} options.db 已跑完迁移的数据库
* @param {{info: Function, warn: Function, error: Function, debug: Function}} options.logger
* @param {(app: import('express').Express) => void} [options.extraRoutes] 测试用:在 404/错误出口之前挂载额外路由
* @returns {import('express').Express} Express 应用
*/
export function createApp(options) {
const { config, db, logger, extraRoutes } = options;
if (!config) throw new Error('createApp 需要 config');
if (!db) throw new Error('createApp 需要已初始化的 db');
if (!logger) throw new Error('createApp 需要 logger');
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
const tokenService = createTokenService({
db,
secret: config.jwtSecret,
accessTtlSeconds: config.tokens.accessTtlSeconds,
refreshTtlSeconds: config.tokens.refreshTtlSeconds,
});
const rateLimiter = createRateLimiter({
db,
windowMs: config.rateLimit.windowMs,
maxFailures: config.rateLimit.maxFailures,
lockMs: config.rateLimit.lockMs,
logger,
});
// 启动即清理已自然过期的吊销记录,避免表无限增长
tokenService.cleanupExpired();
// ---- 预置中间件顺序:安全头 → 旧路径 308 → body 解析(64kb 上限)----
app.use(securityHeaders());
app.use(legacyApiRedirect());
app.use(express.json({ limit: '64kb' }));
/**
* Bearer 鉴权中间件。
* @type {import('express').RequestHandler}
*/
const auth = (req, _res, next) => {
try {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
req.user = tokenService.verifyAccess(token);
next();
} catch (error) {
next(error);
}
};
/** 静态 prepared statement 缓存(首次访问时创建)。 */
const statements = {
getUserById: () => db.prepare('SELECT id, username, email, created_at FROM users WHERE id = ?'),
getUserForLogin: () => db.prepare('SELECT id, username, password_hash FROM users WHERE LOWER(username) = ?'),
findUserByName: () => db.prepare('SELECT id FROM users WHERE LOWER(username) = ?'),
insertUser: () =>
db.prepare('INSERT INTO users (username, password_hash, email, created_at) VALUES (?,?,?,?)'),
findDeviceByName: () => db.prepare('SELECT * FROM devices WHERE user_id = ? AND device_name = ?'),
updateDevice: () =>
db.prepare(
'UPDATE devices SET platform = ?, lan_ip = ?, tailscale_ip = ?, device_id = ?, last_seen = ? WHERE id = ?'
),
insertDevice: () =>
db.prepare(
'INSERT INTO devices (user_id, device_id, device_name, platform, lan_ip, tailscale_ip, last_seen, created_at) ' +
'VALUES (?,?,?,?,?,?,?,?)'
),
insertDeviceIfMissing: () =>
db.prepare('INSERT INTO devices (user_id, device_name, created_at) VALUES (?,?,?)'),
listDevices: () => db.prepare('SELECT * FROM devices WHERE user_id = ? ORDER BY last_seen DESC, id DESC'),
upsertCert: () =>
db.prepare(
'INSERT INTO certs (user_id, device_id, cert_pem, fingerprint, created_at) VALUES (?,?,?,?,?) ' +
'ON CONFLICT(user_id, device_id) DO UPDATE SET ' +
'cert_pem = excluded.cert_pem, fingerprint = excluded.fingerprint, created_at = excluded.created_at'
),
listCerts: () =>
db.prepare(
'SELECT c.cert_pem, c.fingerprint, c.created_at, d.id AS device_pk, d.device_id, d.device_name, ' +
'd.platform, d.lan_ip FROM certs c JOIN devices d ON d.id = c.device_id ' +
'WHERE c.user_id = ? ORDER BY c.created_at DESC'
),
ping: () => db.prepare('SELECT 1 AS ok'),
};
// ================= 健康 =================
// `/api/health` 与 `/api/v1/health` 双注册,不依赖 308(容器探针不应跟随重定向)。
const healthHandler = route((_req, res) => {
let dbOk = false;
try {
dbOk = Boolean(statements.ping().get());
} catch {
dbOk = false;
}
sendOk(res, {
version: config.appVersion,
uptimeMs: Date.now() - STARTED_AT,
dbOk,
});
});
app.get('/api/health', healthHandler);
app.get('/api/v1/health', healthHandler);
// ================= 认证 =================
app.post(
'/api/v1/auth/register',
route((req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const rawUsername = typeof body.username === 'string' ? body.username.trim() : '';
if (!isValidUsername(rawUsername)) {
throw new AppError('INVALID_USERNAME', { field: 'username' });
}
const passwordProblem = describePasswordProblem(body.password);
if (passwordProblem) {
throw new AppError('WEAK_PASSWORD', { field: 'password', reason: passwordProblem });
}
const email = normalizeEmail(body.email);
if (email === undefined) {
throw new AppError('VALIDATION_FAILED', { field: 'email' });
}
const normalized = normalizeUsername(rawUsername);
const existing = statements.findUserByName().get(normalized);
if (existing) throw new AppError('USER_EXISTS', { field: 'username' });
let info;
try {
const passwordHash = hashPassword(/** @type {string} */ (body.password));
info = statements.insertUser().run(rawUsername, passwordHash, email, Date.now());
} catch (error) {
const message = String(error && error.message).toLowerCase();
if (message.includes('unique')) throw new AppError('USER_EXISTS', { field: 'username' });
throw error;
}
const userId = Number(info.lastInsertRowid);
logger.info('用户注册成功', { userId });
sendOk(res, { userId }, 201);
})
);
app.post(
'/api/v1/auth/login',
route((req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const username = normalizeUsername(typeof body.username === 'string' ? body.username : '');
const password = typeof body.password === 'string' ? body.password : '';
const ip = typeof req.ip === 'string' ? req.ip : 'unknown';
// ---- 限流前置检查(R-P0-03)----
const limitState = rateLimiter.check({ ip, username });
if (limitState.limited) {
logger.warn('登录被限流拦截', { ip, subject: limitState.subject, retryAfter: limitState.retryAfter });
throw new AppError('RATE_LIMITED', { retryAfter: limitState.retryAfter });
}
const user = username.length > 0 ? statements.getUserForLogin().get(username) : undefined;
let verified = false;
if (user) {
// 畸形哈希只记 warn(不含哈希原文),随后与「密码错误」走完全相同的 401 分支
if (!isWellFormedHash(user.password_hash)) {
logger.warn('检测到畸形 password_hash,按凭据错误处理', { userId: Number(user.id) });
}
verified = safeVerify(password, user.password_hash);
}
if (!user || !verified) {
const result = rateLimiter.recordFailure({ ip, username });
logger.warn('登录失败', {
ip,
userId: user ? Number(user.id) : null,
locked: result.locked,
});
// 401 响应体逐字节稳定:不区分「用户不存在」与「密码错误」
throw new AppError('INVALID_CREDENTIALS');
}
rateLimiter.reset({ ip, username });
tokenService.cleanupExpired();
const access = tokenService.signAccess({ id: user.id, username: user.username });
const refresh = tokenService.signRefresh({ id: user.id });
logger.info('登录成功', { userId: Number(user.id) });
sendOk(res, {
accessToken: access.token,
refreshToken: refresh.token,
expiresIn: access.expiresIn,
userId: Number(user.id),
username: String(user.username),
serverTime: Date.now(),
});
})
);
app.post(
'/api/v1/auth/refresh',
route((req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const payload = tokenService.verifyRefresh(body.refreshToken);
const user = statements.getUserById().get(payload.userId);
if (!user) throw new AppError('UNAUTHORIZED');
// refresh token 一次性:换新后旧 jti 立即吊销
tokenService.revoke(payload.jti, payload.userId, payload.exp * 1000);
const access = tokenService.signAccess({ id: user.id, username: user.username });
const refresh = tokenService.signRefresh({ id: user.id });
sendOk(res, {
accessToken: access.token,
refreshToken: refresh.token,
expiresIn: access.expiresIn,
serverTime: Date.now(),
});
})
);
app.post(
'/api/v1/auth/logout',
auth,
route((req, res) => {
const { jti, exp, userId } = /** @type {any} */ (req.user);
tokenService.revoke(jti, userId, exp * 1000);
// 可选:同时吊销请求体里带来的 refresh token
const body = req.body && typeof req.body === 'object' ? req.body : {};
if (typeof body.refreshToken === 'string' && body.refreshToken.length > 0) {
try {
const payload = tokenService.verifyRefresh(body.refreshToken);
if (payload.userId === userId) tokenService.revoke(payload.jti, userId, payload.exp * 1000);
} catch {
// refresh 已过期/已吊销都不影响登出结果
}
}
logger.info('用户登出', { userId });
sendOk(res, {});
})
);
// ================= 设备 =================
app.post(
'/api/v1/devices/heartbeat',
auth,
route((req, res) => {
const userId = /** @type {any} */ (req.user).userId;
const body = req.body && typeof req.body === 'object' ? req.body : {};
const deviceName = typeof body.device_name === 'string' ? body.device_name.trim() : '';
if (!isValidDeviceName(deviceName)) {
throw new AppError('VALIDATION_FAILED', {
field: 'device_name',
reason: '需匹配 [A-Za-z0-9][A-Za-z0-9._-]{0,63}(首字符须为字母或数字)',
});
}
// 非法值一律「忽略并保留上一次值」,避免老客户端被新校验打挂(架构 §4.3.3)
const deviceId = isValidDeviceId(body.device_id) ? String(body.device_id) : null;
const platform = isValidPlatform(body.platform) ? String(body.platform) : null;
const lanIp = isRoutableIPv4(body.lan_ip) ? String(body.lan_ip) : null;
const tailscaleIp = isRoutableIPv4(body.tailscale_ip) ? String(body.tailscale_ip) : null;
const now = Date.now();
/**
* 取 `device_id` 的日志片段:**只留前 8 位**。
*
* 理由与 `docs/security.md` 的脱敏清单同源:`device_id` 是设备身份标识,
* 完整值写进日志等于把「哪台机器」长期散播到磁盘上;前 8 位足够跨多行日志
* 关联同一次冲突,又不足以复原标识。
* @param {string|null|undefined} value device_id
* @returns {string|null} 前 8 位;空值返回 null
*/
const deviceIdHint = (value) => (typeof value === 'string' && value.length > 0 ? value.slice(0, 8) : null);
/**
* 更新一条已存在的设备行,并处理 `device_id` 的唯一约束冲突(R10)。
*
* `idx_devices_uuid` 是 `UNIQUE(user_id, device_id)`。某台客户端上报了与他人
* 重复的 `device_id`(克隆虚拟机、从旧机器复制了 settings.json)时,UPDATE 会抛
* 未捕获异常 → **500**,该设备的在线状态就永久丢失了;而 D3 之后 `device_id` 还是
* 证书文件名的主体,冲突会进一步升级为「两台设备抢同一个文件名」。
*
* 降级取舍:冲突时把 `device_id` 写成 `null` 保住「这台设备在线」这个事实
* (心跳的核心价值),代价是该设备本轮拿不到可用文件名键 —— 客户端会按
* `missing_device_id` 跳过写入(见 `certs.ts`),下一轮心跳拿到新 id 即自愈。
* @param {any} row 设备行
* @param {string|null} candidate 候选 device_id
* @returns {string|null} 实际写入的 device_id
*/
const updateDevicePresence = (row, candidate) => {
try {
statements
.updateDevice()
.run(
platform ?? row.platform,
lanIp ?? row.lan_ip,
tailscaleIp ?? row.tailscale_ip,
candidate,
now,
row.id
);
return candidate;
} catch (error) {
const message = String(error && error.message).toLowerCase();
// 只对唯一约束降级;其它 DB 错误继续上抛(不吞真实故障)
if (!message.includes('unique')) throw error;
logger.warn('设备 device_id 与既有记录冲突,已降级为 null 写回(保住在线状态)', {
userId,
deviceName,
deviceId8: deviceIdHint(candidate),
});
statements
.updateDevice()
.run(platform ?? row.platform, lanIp ?? row.lan_ip, tailscaleIp ?? row.tailscale_ip, null, now, row.id);
return null;
}
};
/**
* @typedef {{ id: number, deviceId: string|null }} DevicePresence
*/
/**
* 插入一条新的设备行,并处理**两条**唯一约束(R10,QA-R2 复验后补全)。
*
* `devices` 上有两个唯一约束,冲突后的正确处置**完全不同**:
*
* | 约束 | 含义 | 处置 |
* |---|---|---|
* | `UNIQUE(user_id, device_name)` | 并发下同名设备被同时插入 | 行其实已存在 → 重查后按 UPDATE 处理 |
* | `UNIQUE INDEX idx_devices_uuid(user_id, device_id)` | 别人的 id 被重复上报(克隆 VM、复制了旧机器的 settings.json 后改了设备名) | 降级为 `device_id = null` 再插一次 |
*
* 上一版只处理了第一种:`catch` 里只重查同名行,`raced` 为 `undefined` 时直接
* rethrow → **500**。而更严重的是「行没建成」⇒ 下一次心跳仍然走 INSERT、
* 仍然 500,**是永久性的**,不是一次性抖动。
* @param {string|null} candidate 候选 device_id
* @returns {DevicePresence} 行 id 与实际写入的 device_id
*/
const insertDevicePresence = (candidate) => {
try {
const info = statements
.insertDevice()
.run(userId, candidate, deviceName, platform, lanIp, tailscaleIp, now, now);
return { id: Number(info.lastInsertRowid), deviceId: candidate };
} catch (error) {
const message = String(error && error.message).toLowerCase();
if (!message.includes('unique')) throw error;
// ① 同名并发插入:这一行其实已存在 → 交给 UPDATE 路径(它自带 id 冲突兜底)
const raced = statements.findDeviceByName().get(userId, deviceName);
if (raced) {
return { id: Number(raced.id), deviceId: updateDevicePresence(raced, candidate) };
}
// ② 没有同名行 ⇒ 撞的是 `idx_devices_uuid`。已经要写 null 还撞,只可能是
// 别的约束(不该吞),原样上抛。
if (candidate === null) throw error;
logger.warn('新设备 device_id 与既有记录冲突,已降级为 null 插入(保住在线状态)', {
userId,
deviceName,
deviceId8: deviceIdHint(candidate),
});
try {
const info = statements
.insertDevice()
.run(userId, null, deviceName, platform, lanIp, tailscaleIp, now, now);
return { id: Number(info.lastInsertRowid), deviceId: null };
} catch (retryError) {
const retryMessage = String(retryError && retryError.message).toLowerCase();
if (!retryMessage.includes('unique')) throw retryError;
// ③ 降级插入也可能撞 `(user_id, device_name)`(并发同名)→ 再走一次 UPDATE。
//
// 这里传 `racedAfterRetry.device_id ?? null` 而**不是**无条件 `null`:
// ③ 若真跑到(多 worker 共享同一个 SQLite 才会),那行可能已被并发请求
// 写入了**合法** id,无条件写 null 会把好 id 抹掉一个 tick。这也与
// `existing` 分支的 `deviceId ?? fallbackId`(保留原值)口径一致。
//
// ⚠️ ①③ 两步在**单进程**下不可达:`node:sqlite` 是同步 API,心跳 handler
// 内没有任何 `await`,两次查询之间插不进别的请求。这也正好解释了旧代码
// 为什么**必然** 500 —— 旧 catch 只有「重查同名」这一条路,而它恒走不到。
// 保留它是为将来多 worker 共享 DB 预留。
const racedAfterRetry = statements.findDeviceByName().get(userId, deviceName);
if (!racedAfterRetry) throw retryError;
const keptId = racedAfterRetry.device_id === undefined ? null : racedAfterRetry.device_id;
return { id: Number(racedAfterRetry.id), deviceId: updateDevicePresence(racedAfterRetry, keptId) };
}
}
};
const existing = statements.findDeviceByName().get(userId, deviceName);
if (existing) {
const fallbackId = existing.device_id === undefined ? null : existing.device_id;
const effectiveId = updateDevicePresence(existing, deviceId ?? fallbackId);
sendOk(res, { deviceId: Number(existing.id), device_id: effectiveId, serverTime: now });
return;
}
const inserted = insertDevicePresence(deviceId);
sendOk(res, { deviceId: inserted.id, device_id: inserted.deviceId, serverTime: now });
})
);
app.get(
'/api/v1/devices',
auth,
route((req, res) => {
const userId = /** @type {any} */ (req.user).userId;
const now = Date.now();
const rows = statements.listDevices().all(userId);
sendOk(res, { devices: rows.map((row) => toDeviceSummary(row, now)) });
})
);
// ================= 证书 =================
app.post(
'/api/v1/certs',
auth,
route((req, res) => {
const userId = /** @type {any} */ (req.user).userId;
const body = req.body && typeof req.body === 'object' ? req.body : {};
const deviceName = typeof body.device_name === 'string' ? body.device_name.trim() : '';
if (!isValidDeviceName(deviceName)) {
throw new AppError('VALIDATION_FAILED', {
field: 'device_name',
reason: '需匹配 [A-Za-z0-9][A-Za-z0-9._-]{0,63}(首字符须为字母或数字)',
});
}
if (body.cert_pem === undefined || body.cert_pem === null || body.cert_pem === '') {
throw new AppError('VALIDATION_FAILED', { field: 'cert_pem', reason: '证书内容不能为空' });
}
// 边界 + X509 可解析 + 显式拒绝私钥块(R-P1-03)
const parsed = parsePublicCertPem(body.cert_pem);
// 指纹**无条件由服务端计算**,请求体里的 fingerprint 完全忽略(R-P0-04)
const fingerprint = sha256hex(parsed.pem);
let device = statements.findDeviceByName().get(userId, deviceName);
if (!device) {
const info = statements.insertDeviceIfMissing().run(userId, deviceName, Date.now());
device = { id: Number(info.lastInsertRowid) };
}
statements.upsertCert().run(userId, Number(device.id), parsed.pem, fingerprint, Date.now());
logger.info('证书已入库', { userId, deviceName, fingerprint });
sendOk(res, {
deviceId: Number(device.id),
fingerprint,
});
})
);
app.get(
'/api/v1/certs',
auth,
route((req, res) => {
const userId = /** @type {any} */ (req.user).userId;
const rows = statements.listCerts().all(userId);
sendOk(res, {
certs: rows.map((row) => ({
device_id: row.device_id === undefined ? null : row.device_id,
device_name: String(row.device_name),
platform: row.platform === undefined ? null : row.platform,
lan_ip: row.lan_ip === undefined ? null : row.lan_ip,
cert_pem: String(row.cert_pem),
fingerprint: row.fingerprint === undefined ? null : row.fingerprint,
updated_at: typeof row.created_at === 'number' ? row.created_at : null,
})),
});
})
);
// ---- 测试/扩展挂载点(必须在 404 与错误出口之前)----
if (typeof extraRoutes === 'function') extraRoutes(app);
app.use(notFoundHandler());
app.use(errorHandler(logger));
return app;
}
/**
* 启动服务:校验配置 → 开库 → 迁移 → 组装 → listen。
*
* 失败处理:配置不合法由 loadConfig 直接 exit(1);迁移失败记录日志后 exit(1)。
*
* @param {object} [options]
* @param {Record<string, unknown>} [options.env=process.env] 环境变量来源(smoke/测试注入)
* @param {number} [options.port] 覆盖监听端口(传 0 表示由内核分配临时端口,smoke 使用)
* @returns {Promise<{app: import('express').Express, server: import('node:http').Server, config: Readonly<object>, db: any, logger: any}|null>}
*/
export async function startServer(options = {}) {
const { env = process.env } = options;
const overridePort = Number.isInteger(options.port) ? Number(options.port) : null;
const config = loadConfig({ env });
if (!config) return null;
const logger = createLogger({
level: config.logLevel,
module: 'server',
file: `${config.logDir}/server.log`,
maxBytes: 5 * 1024 * 1024,
maxFiles: 3,
});
if (String(process.versions.node).startsWith('22.')) {
logger.info('运行在 Node 22:node:sqlite 为实验特性(会打印 ExperimentalWarning),推荐 Node >=24', {
node: process.versions.node,
});
}
const db = initDb(config);
try {
runMigrations({
db,
dbPath: config.dbPath,
migrationsDir: config.migrationsDir,
backupDir: config.backupDir,
logger,
});
} catch (error) {
logger.error('数据库迁移失败,拒绝启动', { error: error instanceof Error ? error.message : String(error) });
process.exit(1);
}
installProcessGuards(logger);
const app = createApp({ config, db, logger });
const server = await new Promise((resolve) => {
const listenPort = overridePort === null ? config.port : overridePort;
const instance = app.listen(listenPort, config.host, () => resolve(instance));
});
logger.info('控制面服务已启动', {
host: config.host,
port: /** @type {any} */ (server.address()).port,
db: config.dbPath,
env: config.nodeEnv,
trustProxy: config.trustProxy,
});
return { app, server, config, db, logger };
}
/**
* 安装进程级兜底(R-P1-05 的服务端部分)。
*
* 注意:**只在真正作为服务运行时调用**(startServer 内),不在模块 import 期注册 ——
* 否则测试进程里一个未捕获异常就会把测试跑者杀掉。
*
* @param {{error: Function}|null} logger
* @returns {void}
*/
export function installProcessGuards(logger) {
const report = (msg, detail) => {
try {
if (logger && typeof logger.error === 'function') logger.error(msg, detail);
else process.stderr.write(`${JSON.stringify({ level: 'error', msg, ...detail })}\n`);
} catch {
/* ignore */
}
};
process.on('uncaughtException', (error) => {
report('uncaughtException', { error: error instanceof Error ? error.message : String(error) });
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
report('unhandledRejection', { error: String(reason) });
});
}
/** 入口判定:仅当 `node server.js` 直接运行时才 listen,被 import 时保持静默。 */
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
startServer().catch((error) => {
try {
process.stderr.write(`启动失败:${error instanceof Error ? error.stack || error.message : String(error)}\n`);
} catch {
/* ignore */
}
process.exit(1);
});
}
export default createApp;