新装一台实例"归谁管"以前只能靠 .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 增「首次部署:先过一遍初始化向导」。
346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
'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,
|
||
};
|