fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底)
问题(2026-09-23 用户报障):
本地 `npm run dev` 启动即崩:
SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES)
→ [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。
根因:
config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把
deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号,
而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。
改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库):
仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。
合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()`
对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。
本文件只覆盖 sequelize.username / password 两个字段,
dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。
生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。
改动 B —— config/config.default.js:
① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定);
② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键
(连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。
生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据:
a) egg-scripts/lib/cmd/start.js:109 无条件 `env.NODE_ENV='production'`;
b) deploy/.env.production:17 NODE_ENV=production,且 start-backend.sh 以
`set -a; source "$ENV_FILE"` 注入 shell 后再 exec node;
c) start.js:126 注释:argv → EGG_SERVER_ENV → 兜底 egg 取 prod。
⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。
改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。
验证(全部实测):
· 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入;
合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。
· 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0;
MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。
· 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root
⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。
· 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、
verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。
不变量(本次未改,勿顺手改):
· deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库;
· sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js;
· 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,6 +23,10 @@ run/
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# 本地开发配置(Egg 仅在 serverEnv=local 时加载,生产不加载)
|
||||
# 含本地 DB 口令,故排除入库;如需团队共享请提交不含口令的 config.local.example.js
|
||||
config/config.local.js
|
||||
|
||||
# Coverage / cache
|
||||
coverage/
|
||||
.cache/
|
||||
|
||||
@@ -10,14 +10,46 @@
|
||||
// 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时
|
||||
// 生效(外部注入优先),保证幂等。
|
||||
// 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。
|
||||
//
|
||||
// 🔴 2026-09-23 修复(本地启动崩溃 1045,详见 memory/2026-09-23.md):
|
||||
// 原先本函数在【模块加载期】立即执行,且无条件把 .env.production 的全部键灌进
|
||||
// process.env —— 于是本地 `npm run dev` 也会拿到**生产库账号**(见 deploy/.env.production
|
||||
// 的 DB_USER),
|
||||
// 而本机 MySQL 并无该账号 ⇒ SequelizeAccessDeniedError(1045) ⇒ agent_worker 退出
|
||||
// ⇒ master 退出 ⇒ 7001 无监听。现改为两处收口:
|
||||
// ① 【执行时机】推迟到 module.exports(appInfo) 内部 —— 此时 appInfo.env 已确定;
|
||||
// ② 【作用范围】非生产环境跳过「连接类 / 凭据类 / 网关开关类」键(见下方常量)。
|
||||
// 生产(appInfo.env === 'prod' 或 NODE_ENV=production)**不跳过任何键**,行为逐键不变。
|
||||
// 生产必然满足判定的三重依据(2026-09-23 取证):
|
||||
// a) egg-scripts/lib/cmd/start.js:109 无条件 `env.NODE_ENV = 'production'`;
|
||||
// b) deploy/.env.production:17 `NODE_ENV=production`,且 start-backend.sh 以
|
||||
// `set -a; source "$ENV_FILE"` 注入 shell 后再 exec node;
|
||||
// c) start.js:126 注释:argv -> EGG_SERVER_ENV -> 兜底 egg 取 `prod`。
|
||||
// ---------------------------------------------------------------------------
|
||||
(function loadDeployEnv() {
|
||||
const DEPLOY_ENV_SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
||||
|
||||
// 非生产环境额外跳过的键前缀:这些键一旦注入本机进程会造成**真实副作用**,
|
||||
// 或把生产凭据带进本地进程。逐类取证依据(2026-09-23):
|
||||
// DB_ —— 实证危害:注入生产库账号后本地必然 1045 崩溃(2026-09-23 已实测);
|
||||
// REDIS_ —— 当前全库无 app.redis 使用点(grep 零命中),但一旦启用即连生产 Redis;
|
||||
// ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`),
|
||||
// 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面);
|
||||
// SMS_ —— SMS_ENABLED 全库零引用(config.sms.enabled 无使用点),跳过仅为口径一致。
|
||||
// 仍保留注入的键:JWT_SECRET / APP_KEYS / EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。
|
||||
const DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', 'REDIS_', 'ALIYUN_', 'SMS_'];
|
||||
|
||||
function loadDeployEnv(appInfo) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
||||
const targets = [];
|
||||
|
||||
// 生产判定:appInfo.env 为权威(Egg getServerEnv 的结果),NODE_ENV 为兼容兜底。
|
||||
// 两者皆不满足即视为非生产(本地/测试),采用**保守**方向跳过连接类键。
|
||||
const isProdEnv =
|
||||
process.env.NODE_ENV === 'production' ||
|
||||
(appInfo && (appInfo.env === 'prod' || appInfo.env === 'production'));
|
||||
|
||||
// 1) 显式指定(最高优先级)
|
||||
if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE);
|
||||
|
||||
@@ -39,6 +71,7 @@
|
||||
}
|
||||
|
||||
let loadedFrom = null;
|
||||
let skipped = [];
|
||||
for (const file of targets) {
|
||||
let text;
|
||||
try {
|
||||
@@ -55,9 +88,13 @@
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (key && !SKIP_KEYS.has(key) && process.env[key] === undefined) {
|
||||
process.env[key] = val;
|
||||
if (!key || DEPLOY_ENV_SKIP_KEYS.has(key) || process.env[key] !== undefined) continue;
|
||||
// 非生产环境:跳过连接类 / 凭据类 / 网关开关类键,避免「本地进程连到生产库」
|
||||
if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_PREFIXES.some((p) => key.startsWith(p))) {
|
||||
skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
process.env[key] = val;
|
||||
}
|
||||
loadedFrom = file;
|
||||
break; // 第一个存在的文件即为权威来源
|
||||
@@ -68,14 +105,30 @@
|
||||
} else {
|
||||
console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)');
|
||||
}
|
||||
// 非生产环境把「被跳过的键」显式打印出来。⚠️ 只打印键名,绝不打印值。
|
||||
// 注意:本行出现 ≠ 那些键被注入(恰恰相反,是被跳过了),排查时勿误读。
|
||||
if (skipped.length > 0) {
|
||||
console.log(
|
||||
'[deploy-env] 非生产环境(env=' +
|
||||
((appInfo && appInfo.env) || process.env.NODE_ENV || 'unknown') +
|
||||
'),已跳过 ' +
|
||||
skipped.length +
|
||||
' 个连接类/凭据类键(防止本地进程连到生产库或持有生产密钥):' +
|
||||
skipped.join(', ')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
module.exports = (appInfo) => {
|
||||
const config = (exports = {});
|
||||
|
||||
// 🔴 必须在下文任何 process.env.* 读取之前执行(原本是模块级 IIFE,见函数头注释)。
|
||||
// 放在此处而非模块顶层,是为了拿到已确定的 appInfo.env 以区分生产/本地。
|
||||
loadDeployEnv(appInfo);
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod';
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user