## 缺陷(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 干净。
680 lines
27 KiB
JavaScript
680 lines
27 KiB
JavaScript
// 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;
|