Files
inkwatch/server/shared/instance-config.js
T
PMon Dev a825fc366e feat(setup): 实例初始化配置与 Web 安装向导
新装一台实例"归谁管"以前只能靠 .env 里手敲 ADMIN_TOKEN:运维要记住随机串、
没有管理员概念、改任何业务配置都得改文件再重启。本轮把这件事收敛成
「浏览器里走一遍向导 + 配置落库 + 管理端在线可改」。

数据库(deploy/migrate.sql,两条路径共用)
- instance_config 单行表(CHECK id = 1):实例名、对外地址、自助注册、
  强制登录、设备令牌、配额、中继上限、邮件、审计保留期
- users.is_admin 列 + 部分索引
- bootstrap_state 三态 pending / adopted / ready,由「库里有没有用户」裁决 ——
  存量库升级不能断服,故不是布尔

向导(server/business/setup.js + setup/index.html)
四道护栏是前置条件而非加固项:
  1. INSTANCE_SETUP_TOKEN 未设或 <32 字符 → 404(不是 401,不对外宣布它存在)
  2. 只认 Authorization: Bearer;失败闸门在令牌校验**之前**(否则猜对就绕过限流)
  3. 只能成功一次,由数据库裁决:UPDATE ... WHERE bootstrap_state <> 'ready'
  4. 完成后永久 404,判据在库里(内存变量会让重启后复活)

鉴权(server/business/admin.js + admin-queries)
- 管理端双通道:ADMIN_TOKEN(应急/自动化)或库里 is_admin 的会话;
  审计记 operator,能区分"用管理员账号改的"和"用令牌改的"
- 新增 /api/admin/admins、账号提权/降权(不许取消最后一个可用管理员 → 409)
- 新增 /api/admin/instance-config:被 env 锁定的字段拒绝修改(409 E_ENV_LOCKED),
  不静默成功

配置层次
- 库是真相源,env 是显式覆盖层;GET 返回每个字段的来源(db/env/default)
- 密钥类配置一律不入库(库会被备份/导出/管理端读出)
- 改完同进程立刻生效(onChanged),不等 3 秒轮询

网关(server/gateway)
- **修复半个开关**:业务端的 REQUIRE_AUTH / REQUIRE_DEVICE_TOKEN 搬进库后,
  网关若仍只读 env,管理端点开"强制登录"只会关住业务端,订阅通道照样放行匿名。
  改由 index.js 每 3 秒把「库 + env 覆盖」推入 gateway.setSwitches(),
  网关自己不碰数据库(热路径只读内存字段);/health 报生效值而非 env 字面值
- 单测用假 this 直调原型方法时 this.sw 为空 → 用冻结单例兜底,避免
  TypeError 被误报成"会话索引坏了"

测试
- 新增 tests/setup-test.js(97 项):四道闸、参数矩阵、成功路径、
  并发(用行锁把两个请求压进临界区,让"输家 409"成为必然而非运气)、
  存量库升级(只提权不改口令)
- 它自带 4 个临时库并跑真实 init.sql/migrate.sql —— 顺带把"全新库首次部署"
  这条历史上出过建表顺序坑的路径也覆盖了
- dockerfile-coverage:开关扫描补上声明式 ENV_MAP(动态下标扫不到,
  覆盖曾从 5 条悄悄掉到 2 条),并按字段名判定真正的消费方

验证:bash tests/run-all.sh → 36 个套件全部通过;六组变异测试逐一证明
关键护栏"删掉即红"。README 增「首次部署:先过一遍初始化向导」。
2026-10-30 14:58:00 +08:00

346 lines
13 KiB
JavaScript
Raw 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.
'use strict';
/**
* 实例配置 —— **唯一读取入口与唯一落库点**(本仓库第 15 条纪律)
*
* ── 为什么要有这一层 ────────────────────────────────────────────────
* 在此之前,"这套部署允许谁注册、免费几台设备、中继放多大"全靠部署时敲
* 环境变量。敲漏一个就静默落到代码里的默认值,而默认值往往是最宽松那档 ——
* 且没有任何落库痕迹,半年后没人能回答"这台机器当初是怎么配的"。
*
* 现在:**库是真相源,env 是显式覆盖层**。语义只有一条:
*
* env 里设了 → 用 env(并标记 source='env',管理端显示"被环境变量锁定,界面改不动")
* env 里没设 → 用库里的值
*
* 为什么不干脆"env 优先于库"就完事:那样第一次 `cp .env.example .env` 之后
* 库里的值就永远是摆设,管理端改配置也会"看起来改了但不生效" —— 这比没有管理界面更糟。
* 反过来"库优先、env 彻底不用"也不行:测试需要用 env 把开关掰到某个状态
* (`auth-mode-test` 的严格模式实例),而测试不该去改共享库里的配置行。
* 所以 env 只承担"覆盖",不承担"配置"。
*
* ── 密钥为什么一个都不在这里 ────────────────────────────────────────
* DB 密码、SMTP 口令、ADMIN_TOKEN、INSTANCE_SETUP_TOKEN 全部只留 env。
* 这张表会被备份、被 CSV 导出、被我自己的管理端读出来 —— 把口令塞进来
* 等于把"需要 shell 权限才能拿"的东西降级成"能读库就能拿"。
*
* ── 缓存策略 ────────────────────────────────────────────────────────
* 3 秒 TTL + 显式 invalidate。代价是"改了配置最多 3 秒才全实例生效",
* 换来的是每个请求不必查一次库。单进程部署下 update() 会立刻 invalidate,
* 所以实际是即时的;只有多实例才会看到那 3 秒窗口 —— 比"永远不一致"好。
*/
/** 库表字段顺序与默认值。改这里必须同步 deploy/migrate.sql 的 DEFAULT。 */
const DEFAULTS = Object.freeze({
instance_name: '墨观',
public_url: null,
allow_open_register: false,
require_auth: false,
require_device_token: false,
enforce_device_quota: true,
free_device_limit: 3,
relay_max_bytes: 1048576,
mail_mode: 'console',
mail_from: null,
audit_retention_days: null,
bootstrap_state: 'pending',
});
/**
* env 覆盖映射:字段 → { env, type }
*
* `type` 决定怎么解析。布尔一律按 `1/0` 解析,**只认这两个字面量** ——
* `REQUIRE_AUTH=true` 不生效是刻意的:静默接受 'true'/'yes'/'on' 会让
* 文档与代码各说一套,而这个仓库已经被"开关写了但没人读"坑过。
* 无法解析的值直接当"没设",并在启动日志里点名(见 parseEnv 的返回 reasons)。
*/
const ENV_MAP = Object.freeze({
instance_name: { env: 'INSTANCE_NAME', type: 'string' },
public_url: { env: 'PUBLIC_URL', type: 'string' },
allow_open_register: { env: 'ALLOW_OPEN_REGISTER', type: 'bool' },
require_auth: { env: 'REQUIRE_AUTH', type: 'bool' },
require_device_token: { env: 'REQUIRE_DEVICE_TOKEN', type: 'bool' },
enforce_device_quota: { env: 'ENFORCE_DEVICE_QUOTA', type: 'bool' },
free_device_limit: { env: 'FREE_DEVICE_LIMIT', type: 'int' },
relay_max_bytes: { env: 'RELAY_MAX_BYTES', type: 'int' },
mail_mode: { env: 'MAIL_MODE', type: 'string' },
mail_from: { env: 'MAIL_FROM', type: 'string' },
audit_retention_days: { env: 'AUDIT_RETENTION_DAYS', type: 'int' },
});
/** 只读选出(绝不 `SELECT *`:将来加列时不该悄悄多返回字段) */
const SELECT_COLUMNS = [
'id', 'instance_name', 'public_url', 'allow_open_register', 'require_auth',
'require_device_token', 'enforce_device_quota', 'free_device_limit',
'relay_max_bytes', 'mail_mode', 'mail_from', 'audit_retention_days',
'bootstrap_state', 'initialized_at', 'initialized_by', 'revision', 'updated_at',
].join(', ');
const MAIL_MODES = ['outbox', 'console', 'smtp'];
/** 解析单个 env 覆盖值;返回 undefined 表示"没设/不可解析,交给库" */
function parseEnv(raw, type) {
if (raw === undefined || raw === null) return undefined;
const v = String(raw).trim();
if (v === '') return undefined;
if (type === 'bool') {
if (v === '1') return true;
if (v === '0') return false;
return { error: `只接受 0 或 1,收到 "${v}"` };
}
if (type === 'int') {
const n = Number(v);
// 只接受纯整数:`Number('1MB')` 是 NaN,`Number('')` 是 0,两者都不能当有效值
if (!Number.isFinite(n) || !Number.isInteger(n)) {
return { error: `不是整数:"${v}"` };
}
return n;
}
return v;
}
/**
* 把 env 覆盖层叠到库行上。
* @returns {{ config: object, sources: object, problems: string[] }}
* sources[field] = 'db' | 'env' | 'default'
*/
function applyEnvOverrides(dbRow, env = process.env) {
const config = { ...DEFAULTS, ...(dbRow || {}) };
const sources = {};
const problems = [];
for (const [field, spec] of Object.entries(ENV_MAP)) {
const parsed = parseEnv(env[spec.env], spec.type);
if (parsed === undefined) {
sources[field] = dbRow ? 'db' : 'default';
continue;
}
if (parsed && typeof parsed === 'object' && parsed.error) {
// 不静默忽略:解析失败当成"没设",但要把话说出来
problems.push(`${spec.env}=${env[spec.env]} 无效(${parsed.error}),已忽略并沿用库里的值`);
sources[field] = dbRow ? 'db' : 'default';
continue;
}
config[field] = parsed;
sources[field] = 'env';
}
return { config, sources, problems };
}
/**
* 校验一组待写入的配置。**只校验会导致病态行为的字段**,
* 不校验"看起来不规范"的 —— 校验器太严会让运维在紧急时绕过它。
*
* @returns {{ ok: boolean, errors: string[], value: object }}
*/
function validatePatch(patch = {}) {
const errors = [];
const value = {};
if ('instance_name' in patch) {
const v = String(patch.instance_name == null ? '' : patch.instance_name).trim();
if (v.length < 1) errors.push('实例名称不能为空');
else if (v.length > 64) errors.push('实例名称最长 64 个字符');
else value.instance_name = v;
}
if ('public_url' in patch) {
const raw = String(patch.public_url == null ? '' : patch.public_url).trim();
if (!raw) {
value.public_url = null; // 清空是合法操作(内网部署没有对外地址)
} else if (!/^https?:\/\/[^\s/]+/i.test(raw)) {
errors.push('对外地址必须以 http:// 或 https:// 开头');
} else if (raw.length > 200) {
errors.push('对外地址最长 200 个字符');
} else {
// 去掉尾斜杠:拼接路径时 `https://a.b//api` 这种双斜杠会被某些客户端判为非法
value.public_url = raw.replace(/\/+$/, '');
}
}
for (const [field, spec] of Object.entries(ENV_MAP)) {
if (spec.type !== 'bool' || !(field in patch)) continue;
if (typeof patch[field] !== 'boolean') {
errors.push(`${field} 必须是布尔值`);
} else {
value[field] = patch[field];
}
}
if ('free_device_limit' in patch) {
const n = Number(patch.free_device_limit);
if (!Number.isInteger(n) || n < 0 || n > 10000) errors.push('免费档设备上限须为 0~10000 的整数');
else value.free_device_limit = n;
}
if ('relay_max_bytes' in patch) {
const n = Number(patch.relay_max_bytes);
if (!Number.isInteger(n) || n < 1024 || n > 2147483648) {
errors.push('中继单次上限须为 1024 ~ 2147483648 字节');
} else value.relay_max_bytes = n;
}
if ('mail_mode' in patch) {
const v = String(patch.mail_mode || '');
if (!MAIL_MODES.includes(v)) errors.push(`邮件模式只能是 ${MAIL_MODES.join(' / ')}`);
else value.mail_mode = v;
}
if ('mail_from' in patch) {
const v = String(patch.mail_from == null ? '' : patch.mail_from).trim();
if (!v) value.mail_from = null;
else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) errors.push('发件人地址格式不正确');
else value.mail_from = v;
}
if ('audit_retention_days' in patch) {
if (patch.audit_retention_days === null || patch.audit_retention_days === '') {
value.audit_retention_days = null;
} else {
const n = Number(patch.audit_retention_days);
if (!Number.isInteger(n) || n < 1 || n > 3650) errors.push('审计保留天数须为 1~3650,或留空表示永久');
else value.audit_retention_days = n;
}
}
return { ok: errors.length === 0, errors, value };
}
/** 给前端/接口用的视图:附带"每个字段当前来自哪里",让人分得清改得动改不动 */
function toPublic(config, sources = {}) {
return {
instance_name: config.instance_name,
public_url: config.public_url,
allow_open_register: config.allow_open_register,
require_auth: config.require_auth,
require_device_token: config.require_device_token,
enforce_device_quota: config.enforce_device_quota,
free_device_limit: config.free_device_limit,
// PG 的 BIGINT 经 node-postgres 出来是**字符串**(防溢出),
// 前端做数值比较时会被 `"1048576" > 1024` 这种隐式转换坑到,这里统一转数字
relay_max_bytes: Number(config.relay_max_bytes),
mail_mode: config.mail_mode,
mail_from: config.mail_from,
audit_retention_days: config.audit_retention_days,
bootstrap_state: config.bootstrap_state,
initialized_at: config.initialized_at ? new Date(config.initialized_at).getTime() : null,
revision: config.revision,
sources,
};
}
/**
* @param {{ query: Function }} pool PostgreSQL 连接池
*/
function createInstanceConfig(pool, { ttlMs = 3000 } = {}) {
let cache = null;
let cachedAt = 0;
let inflight = null;
/** 直读库(不做 env 合并),表不存在时返回 null 而不是抛错 */
async function readRow() {
try {
const r = await pool.query(`SELECT ${SELECT_COLUMNS} FROM instance_config WHERE id = 1`);
return r.rows[0] || null;
} catch (e) {
// 迁移还没跑到(老库首次启动):如实返回 null,由调用方按"未迁移"处理。
// 绝不在这里吞掉一切 —— 只有"表不存在"这一类才降级。
if (e.code === '42P01') return null; // undefined_table
throw e;
}
}
/**
* 读当前生效配置(库 + env 覆盖)。
* @param {{fresh?: boolean}} opts fresh=true 跳过缓存
*/
async function get({ fresh = false } = {}) {
const now = Date.now();
if (!fresh && cache && now - cachedAt < ttlMs) return cache;
if (!fresh && inflight) return inflight;
inflight = (async () => {
const row = await readRow();
const merged = applyEnvOverrides(row, process.env);
// 读不到行(未迁移)时按"已采纳"处理:老库升级上来不该被 503 挡住。
// 这是唯一一处"缺数据也放行"的地方,理由写在 migrate.sql 的三态注释里。
const state = row ? row.bootstrap_state : 'adopted';
const config = { ...merged.config, bootstrap_state: state, revision: row ? row.revision : 0 };
cache = { config, sources: merged.sources, problems: merged.problems, migrated: !!row };
cachedAt = Date.now();
return cache;
})();
try {
return await inflight;
} finally {
inflight = null;
}
}
/**
* 改配置。**单条 UPDATE + revision 自增**,不做"读-改-写",
* 这样两个并发修改不会互相覆盖对方没碰的字段(各自只 SET 自己带的列)。
*/
async function update(patch, { actor = null } = {}) {
const { ok, errors, value } = validatePatch(patch);
if (!ok) return { ok: false, errors };
const fields = Object.keys(value);
if (!fields.length) return { ok: false, errors: ['没有可更新的字段'] };
const sets = fields.map((f, i) => `${f} = $${i + 1}`);
const params = fields.map((f) => value[f]);
const r = await pool.query(
`UPDATE instance_config
SET ${sets.join(', ')}, revision = revision + 1, updated_at = now()
WHERE id = 1
RETURNING ${SELECT_COLUMNS}`,
params
);
if (!r.rows[0]) return { ok: false, errors: ['实例配置行不存在(数据库未迁移?)'] };
cache = null;
cachedAt = 0;
return { ok: true, row: r.rows[0], changed: fields, actor };
}
/** 走完向导:置 ready + 记录时间与来源。用 WHERE 保证只能成功一次。 */
async function markReady({ actorIp = null } = {}) {
const r = await pool.query(
`UPDATE instance_config
SET bootstrap_state = 'ready',
initialized_at = COALESCE(initialized_at, now()),
initialized_by = COALESCE(initialized_by, $1),
revision = revision + 1,
updated_at = now()
WHERE id = 1 AND bootstrap_state <> 'ready'
RETURNING ${SELECT_COLUMNS}`,
[actorIp]
);
cache = null;
cachedAt = 0;
return r.rows[0] || null;
}
function invalidate() {
cache = null;
cachedAt = 0;
}
return { get, update, markReady, invalidate, readRow };
}
module.exports = {
DEFAULTS,
ENV_MAP,
MAIL_MODES,
applyEnvOverrides,
validatePatch,
toPublic,
createInstanceConfig,
};