首页
/ OmniRoute 的 postinstall 为何不再预填 JWT_SECRET 与 API_KEY_SECRET:服务端密钥持久化机制全解析

OmniRoute 的 postinstall 为何不再预填 JWT_SECRET 与 API_KEY_SECRET:服务端密钥持久化机制全解析

2026-09-06 23:09:13作者:邓越浪Henry

本文基于 OmniRoute 仓库中 changelog 修复记录 11436,剖析一个隐蔽的运维事故:安装脚本在安装阶段向包目录内的 .env 预写 JWT_SECRETAPI_KEY_SECRET,导致每次 npm i -g 更新都会静默轮换密钥、使 dashboard 会话与 API-key CRC 全部失效;并深入讲解修复后的真实密钥供给链路——ensureSecrets() 的“恢复 → 生成 → 持久化”协议,以及 bootstrap-env 零配置引导层的 server.env 双层兜底设计。

1. 问题定位:安装期预填密钥为什么是错的

1.1 事故链条

11436 修复记录 的记载,此前的行为是:

  1. postinstall 在安装完成时,向已安装包目录内.env 写入随机生成的 JWT_SECRETAPI_KEY_SECRET
  2. .env 文件位于 npm 全局包目录内,执行 npm i -g 升级时整个包目录会被替换,旧文件连同其中写入的密钥一起被丢弃;
  3. 新安装的 postinstall 又写入另一组不同的随机值
  4. 而服务端启动时的 ensureSecrets() 有一个关键语义——它只处理“空”的变量。当变量已被 prefill 成非空值时,它不会去持久化存储中恢复真正的密钥;
  5. 结果:两个密钥在每次更新时静默轮换JWT_SECRET 变化使所有 dashboard 会话 token 验签失败,API_KEY_SECRET 变化使数据库中所存 API key 的 CRC 校验全部对不上。

整个链条没有任何报错,属于典型的“静默数据失效”:用户只会看到“每次升级后都要重新登录、所有 API key 都要重新创建”。

1.2 设计契约:.env.example 中这两个变量为什么是空的

修复后,.env.example 明确将这两个变量作为“必填但留空”的契约项,并附了生成命令供运维手动指定:

# JWT signing key for dashboard session tokens.
# Used by: src/lib/auth — signs/verifies all authenticated session cookies.
# Generate: openssl rand -base64 48
JWT_SECRET=

# Encryption key for API keys stored in the database.
# Used by: src/lib/db/apiKeys.ts — encrypts API key values at rest in SQLite.
# Generate: openssl rand -hex 32
API_KEY_SECRET=

留空不是遗漏,而是有意为之:空值会落入 ensureSecrets() 的恢复/生成分支,从而命中持久化存储;非空值(无论是运维显式设置的,还是 postinstall 预填的)才会被原样采用。这正是后文实现的关键分支点。

2. 修复后的真实供给路径:ensureNext() —— 不对,是 ensureSecrets()

ensureSecrets() 定义在 Node 端启动钩子 src/instrumentation-node.ts 中,在服务启动早期被 await 调用(约 第 342 行),保证在后续任何用到密钥的逻辑之前完成。其完整协议如下:

async function ensureSecrets(): Promise<void> {
  let getPersistedSecret = (_key: string): string | null => null;
  let persistSecret = (_key: string, _value: string): void => {};

  try {
    ({ getPersistedSecret, persistSecret } = await import("@/lib/db/secrets"));
  } catch (err: unknown) {
    console.warn("[STARTUP] Secret persistence unavailable; falling back to process-local secrets:", ...);
  }

  if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
    const persisted = getPersistedSecret("jwtSecret");
    if (persisted) {
      process.env.JWT_SECRET = persisted;
      console.log("[STARTUP] JWT_SECRET restored from persistent store");
    } else {
      const generated = toBase64(getRandomBytes(48));
      process.env.JWT_SECRET = generated;
      persistSecret("jwtSecret", generated);
      console.log("[STARTUP] JWT_SECRET auto-generated and persisted (random 64-char secret)");
    }
  }
  // API_KEY_SECRET 同构:32 字节 hex(64 字符),持久化键名 "apiKeySecret"
}

从源码结构看,每个变量遵循严格的三态决策:

状态 行为 日志信号
process.env 中已有非空值 原样采用,不动持久化存储
为空,持久化存储中存在 从存储恢复(跨重启/跨版本更新稳定) restored from persistent store
为空,存储中也无 生成随机值并写入持久化存储(首次运行) auto-generated and persisted

几个值得注意的工程细节:

  • 持久化载体是数据库而非 .env 文件getPersistedSecret/persistSecret 来自 src/lib/db/secrets.ts。把密钥放在随 DATA_DIR 走的 SQLite 持久存储里,而不是包目录内的文件,从根本上免疫了“npm i -g 替换包目录”这一诱因。
  • 密钥强度JWT_SECRET 为 48 字节随机数的 base64 编码(64 字符),API_KEY_SECRET 为 32 字节随机数的 hex 编码(64 字符),与 .env.exampleopenssl rand -base64 48 / openssl rand -hex 32 的手动生成建议一一对应——运维手动设置的值与系统自动生成的值在熵上等价。
  • 降级路径:若 secrets 模块导入失败(例如数据库驱动尚未就绪),代码会打警告并退化为“进程本地密钥”(每次启动重新生成),保证启动不被阻断,但会话稳定性受损——这也解释了为什么 tests/unit/secrets-boot-guard.test.ts 中有专门测试守护“enforceWebRuntimeEnv() 必须在 ensureSecrets() 之后运行”这类启动顺序约束。
  • 只处理空值这一语义是整个设计的支点:它让“运维显式设置”永远优先,同时让空占位符安全地落入自动供给分支。postinstall 预填非空值,恰好把变量钉死在第一态,使恢复分支永远不可达——这就是 11436 的根因所在。

3. 第二层兜底:bootstrap-env 的零配置引导与 server.env

除了运行期 ensureSecrets(),OmniRoute 还有一条进程启动前的密钥引导链路,实现在 scripts/build/bootstrap-env.mjs。它被各部署入口(run-standalone.mjsrun-next.mjs、Electron 的 main.js)调用,负责在子进程 fork 之前把密钥合并进 process.env。其文件头注释清晰给出了四层优先级(从低到高):

1. 自动生成的默认值
2. {DATA_DIR}/server.env      (首次启动时持久化,跨重启/Docker 卷重挂/升级存活)
3. 首选 .env(DATA_DIR/.env → ~/.omniroute/.env → ./.env)
4. process.env(shell / Docker -e 参数,最高优先级)

核心生成逻辑(第 211–259 行):

if (!merged.JWT_SECRET?.trim()) {
  persisted.JWT_SECRET = randomBytes(64).toString("hex");   // 128 hex 字符
  needsPersist = true;
}
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
  if (hasEncryptedCredentials(dataDir)) {
    throw new Error(`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist ...`);
  }
  persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
  needsPersist = true;
}
if (!merged.API_KEY_SECRET?.trim()) {
  persisted.API_KEY_SECRET = randomBytes(32).toString("hex");
  needsPersist = true;
}
// needsPersist 时写入 {DATA_DIR}/server.env,文件头标注
// "Auto-generated by OmniRoute bootstrap — do not delete"

ensureSecrets() 相比,这一层的两个增量保护值得展开:

  1. STORAGE_ENCRYPTION_KEY 的“拒生成”护栏hasEncryptedCredentials(dataDir) 会只读打开 {DATA_DIR}/storage.sqlite,检查 provider_connections 表是否已存在 enc:v1: 前缀的密文字段。若已存在加密凭据却拿不到密钥,自动换一把新密钥等于让全部存储凭据永久不可解——因此这里直接抛错,要求运维通过 .envserver.envprocess.env 恢复原密钥。这是“宁可启动失败,不可静默丢数据”的防御式写法。
  2. 空值过滤:合并时把 preferredEnvprocess.env 中的空字符串剔除(第 199–209 行),确保 .env 里的 JWT_SECRET= 空占位符、或 Docker -e KEY= 这种显式空值不会覆盖 server.env 里已持久化的真实值——这与 ensureSecrets() 的“空值才处理”语义在同一条链路上相互咬合。
  3. 解钥探测(decrypt-probe)第 283–366 行 会在启动时用 AES-256-GCM 对库中一条 enc:v1: 密文做试解密(同时尝试动态盐 scrypt 与 legacy 盐两种派生),若两者都失败则打印 ⛔ STORAGE_ENCRYPTION_KEY does not match ... 并给出两条恢复路径(恢复旧密钥,或 omniroute reset-encrypted-columns --force 清空凭据列但保留 provider 配置)。这把“密钥与密文不匹配”这一最危险的静默失败变成了启动日志里的显式告警。

另外说明一下与 11436 的关系:修复记录明确指出 STORAGE_ENCRYPTION_KEY 保留在 postinstall 的处理清单中不预填(原因同上,另见 #1622),并且原注释曾指向一个已不存在的函数,现已更正为真实的供给路径——即上文这条 server.env 引导链路与 ensureSecrets()

4. 验证与实操:如何确认密钥供给正确

4.1 启动日志自检

在正常部署中,首次启动应看到(取决于走哪条链路):

  • ensureSecrets() 链路:[STARTUP] JWT_SECRET auto-generated and persisted (random 64-char secret),第二次启动变为 JWT_SECRET restored from persistent store
  • bootstrap-env 链路:[bootstrap] ✨ JWT_SECRET auto-generated (first run)📁 Secrets persisted to: <DATA_DIR>/server.env

验收标准:执行一次升级(npm i -g 或容器镜像更新)后重启,日志中应出现 restored(恢复)而非新的 auto-generated。若再次出现生成日志,说明持久化存储没有跨更新存活,需要检查 DATA_DIR 是否被放在了包目录或临时目录内。

4.2 持久化位置

  • 运行期 DB 持久化:getPersistedSecret/persistSecretsrc/lib/db/secrets.ts),随 DATA_DIR 下的 SQLite 数据库迁移;
  • 进程级持久化:{DATA_DIR}/server.envDATA_DIR 解析规则见 bootstrap-env.mjs 第 36–51 行DATA_DIR 环境变量 → Windows %APPDATA%/omniroute$XDG_CONFIG_HOME/omniroute~/.omniroute)。

运维若希望手动指定这两个密钥(例如多实例共享同一套会话体系),只需在 ~/.omniroute/.env 或部署环境中按 .env.example 给出的命令生成并设置非空值即可,ensureSecrets() 会自动让位;反之若误删 server.env 且 DB 密钥丢失,系统会重新生成——此时务必同步更新所有已签发 API key 与客户端会话。

4.3 相关测试与安装脚本边界

  • 启动顺序与导出面由 tests/unit/secrets-boot-guard.test.ts 守护;bootstrap-env 的行为另有 tests/unit/bootstrap-env.test.ts 覆盖(含空值过滤、server.env 持久化等场景)。
  • 当前仓库中的 postinstall 入口(package.json"postinstall": "node scripts/build/postinstall.mjs")已收敛为二进制副本与 better-sqlite3 预热 等非致命步骤,支持 OMNIROUTE_SKIP_POSTINSTALL=1CI=true/1 整体跳过——安装阶段与密钥供给彻底解耦,正是 11436 修复所期望的边界。

5. 小结

11436 的本质教训可以浓缩为一句话:任何会随包分发被覆盖的文件,都不应承载需要跨版本存活的秘密。OmniRoute 的修复方案是把密钥的生命周期从“安装时点”移交到“启动时点”,由 ensureSecrets()(DB 持久化,src/instrumentation-node.ts)与 bootstrapEnv()server.env 持久化,scripts/build/bootstrap-env.mjs)在启动链路上按“显式设置 > 持久化恢复 > 生成并持久化”的优先级完成供给,并用 enc:v1: 密文探测把密钥错配从静默事故变成显式告警。理解这条链路后,运维可以明确回答三个问题:密钥存在哪里(DATA_DIR 下的 DB 与 server.env)、如何手动覆盖(.env 或环境变量设非空值)、以及如何验证轮换是否发生(升级后启动日志应显示 restored 而非 auto-generated)。

登录后查看全文
热门项目推荐
相关项目推荐