create-ssnode-cli 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.js +17 -21
- package/package.json +1 -1
- package/secrets.js +32 -34
- package/templates/backend/Dockerfile +18 -8
- package/templates/backend/config/config.base.yaml +130 -0
- package/templates/backend/config/config.develop.yaml +8 -75
- package/templates/backend/config/config.prod.yaml +12 -67
- package/templates/backend/docker-compose.yml +30 -22
- package/templates/backend/ops/docker/config.sh +30 -15
- package/templates/backend/ops/docker/install.sh +25 -34
- package/templates/backend/ops/docker/run-container.sh +100 -0
- package/templates/backend/ops/docker/start.sh +37 -8
- package/templates/backend/ops/docker/stop.sh +1 -1
- package/templates/backend/ops/ensure-encrypt.cjs +217 -0
- package/templates/backend/ops/install.sh +249 -148
- package/templates/backend/ops/lib-encrypt.sh +98 -0
- package/templates/backend/ops/lib.sh +22 -0
- package/templates/backend/ops/pm2/config.sh +11 -6
- package/templates/backend/ops/pm2/install.sh +27 -3
- package/templates/backend/ops/pm2/start.sh +1 -1
- package/templates/backend/ops/start.sh +7 -5
- package/templates/backend/ops/status.sh +7 -5
- package/templates/backend/ops/stop.sh +7 -5
- package/templates/backend/ops/systemd/config.sh +13 -6
- package/templates/backend/ops/systemd/install.sh +28 -1
- package/templates/backend/ops/systemd/start.sh +1 -1
- package/templates/backend/ops/versionswitch.sh +34 -122
- package/templates/backend/package-lock.json +7561 -0
- package/templates/backend/package.json +3 -1
- package/templates/backend/scripts/build.cjs +104 -10
- package/templates/backend/scripts/config-crypt.cjs +108 -0
- package/templates/backend/scripts/lib/ssh.cjs +72 -3
- package/templates/backend/scripts/publish.cjs +17 -9
- package/templates/backend/scripts/pulldocker.cjs +208 -0
- package/templates/backend/scripts/pushdocker.cjs +323 -0
- package/templates/backend/scripts/rollback.cjs +6 -3
- package/templates/backend/scripts/server.config.example.cjs +43 -15
- package/templates/backend/scripts/slowsql-report.mjs +2 -2
- package/templates/backend/src/common/api-log/api-log.service.ts +1 -1
- package/templates/backend/src/common/config-crypto.ts +133 -0
- package/templates/backend/src/config/config.interface.ts +22 -3
- package/templates/backend/src/config/configuration.ts +173 -86
- package/templates/backend/src/database/migrations/0001-init.migration.ts +1 -1
- package/templates/backend/src/database/slow-sql.logger.ts +1 -1
- package/templates/backend/src/database/sqlite.config.ts +2 -2
- package/templates/backend/src/main.ts +3 -3
- package/templates/backend/src/modules/ops/ops-probe.interface.ts +3 -3
- package/templates/backend/src/modules/ops/ops.module.ts +2 -11
- package/templates/backend/src/modules/user/user.service.ts +2 -2
- package/templates/docs/config-encryption.md +164 -0
- package/templates/root/AGENTS.md +5 -4
- package/templates/root/CLAUDE.md +1 -1
- package/templates/root/README.md +25 -15
- package/templates/root/_gitignore +5 -0
- package/templates/root/scripts/publish.cjs +20 -2
- package/templates/root/scripts/rollback.cjs +20 -2
- package/templates/web/config/router.config.ts +8 -0
- package/templates/web/package.json +5 -1
- package/templates/web/scripts/compress-src-images.cjs +332 -0
- package/templates/web/scripts/lib/interactive.cjs +134 -0
- package/templates/web/scripts/publish.cjs +299 -200
- package/templates/web/scripts/rollback.cjs +110 -41
- package/templates/web/scripts/server.config.example.json +17 -0
- package/templates/web/src/layouts/Background.module.less +0 -4
- package/templates/web/src/layouts/Background.tsx +48 -74
- package/templates/web/src/layouts/components/PageTabs/SortableTab.tsx +133 -0
- package/templates/web/src/layouts/components/PageTabs/index.module.less +105 -0
- package/templates/web/src/layouts/components/PageTabs/index.tsx +110 -0
- package/templates/web/src/layouts/components/PageTabs/usePageTabs.ts +224 -0
- package/templates/web/src/pages/Backend/Image/index.tsx +4 -4
- package/templates/web/src/pages/Maintenance/index.module.less +177 -0
- package/templates/web/src/pages/Maintenance/index.tsx +142 -0
- package/templates/web/src/services/api.ts +4 -0
- package/templates/web/src/services/app-request.ts +8 -1
- package/templates/web/src/services/maintenance.ts +67 -0
- package/templates/web/src/services/request.ts +5 -0
- package/transforms.js +21 -28
- package/templates/backend/ops/docker/.env.example +0 -40
- package/templates/backend/ops/sqlite/README.md +0 -57
- package/templates/backend/ops/sqlite/backup.sh +0 -65
- package/templates/backend/ops/sqlite/config.sh +0 -91
- package/templates/backend/ops/sqlite/healthcheck.sh +0 -25
- package/templates/backend/ops/sqlite/restore.sh +0 -105
- package/templates/backend/src/modules/ops/sqlite-backup-list.probe.ts +0 -100
- package/templates/backend/src/modules/ops/sqlite-backup-log.probe.ts +0 -63
- package/templates/backend/src/modules/ops/sqlite-backup.probe.ts +0 -144
|
@@ -7,7 +7,7 @@ import type { AppConfig } from '../../config/config.interface';
|
|
|
7
7
|
/**
|
|
8
8
|
* 运维调试日志(《后端日志体系整改方案》§2)
|
|
9
9
|
*
|
|
10
|
-
* API 失败登记(≥400)与慢请求登记(>slowMs)落
|
|
10
|
+
* API 失败登记(≥400)与慢请求登记(>slowMs)落 logs/apilog/apilog-YYYY-MM-DD.jsonl,
|
|
11
11
|
* 2xx 成功请求不记(内网单体下全量访问日志是纯噪音)。traceId 由 main.ts 中间件
|
|
12
12
|
* 挂到 req 并回传 X-Trace-Id 响应头——联调时用户报一个 traceId,grep 一次定位全链路。
|
|
13
13
|
*
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { aesGcmDecrypt } from './app-crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 配置密文(jasypt 式)加载期解密
|
|
5
|
+
*
|
|
6
|
+
* 设计/运维手册见 docs/config-encryption.md。yaml/env 中敏感值以 `ENC(...)` 包裹存储,
|
|
7
|
+
* 进程启动时由 configuration.ts 在「base+overlay 合并之后」对最终配置树统一调用 decryptConfigValues
|
|
8
|
+
* 解开为明文,下游 config.get(...) 拿到的就是明文,业务零改动。
|
|
9
|
+
*
|
|
10
|
+
* 密文格式(三段,以 `.` 分隔,各段 base64):
|
|
11
|
+
* ENC(<b64(iv)>.<b64(authTag)>.<b64(ciphertext)>)
|
|
12
|
+
* - iv : 12 字节 GCM IV(每次加密随机)
|
|
13
|
+
* - authTag : 16 字节 GCM 认证标签
|
|
14
|
+
* - ciphertext : AES-256-GCM 密文
|
|
15
|
+
*
|
|
16
|
+
* 密钥:CONFIG_SECRET_KEY env(base64 的 32 字节)。根密钥在服务器首部署选「开启配置加密」
|
|
17
|
+
* 时生成(ops/lib-encrypt.sh),只存服务器 APP_ROOT/.prod.secrets(600,三模式统一注入:
|
|
18
|
+
* systemd EnvironmentFile / pm2 env / docker run -e),不入 git、开发机零密钥;
|
|
19
|
+
* dev 明文运行(security.encrypt=false),不涉及密钥。
|
|
20
|
+
*
|
|
21
|
+
* 安全边界(诚实声明):本机制防「仓库 / release 包 / 镜像 / 配置外发」泄露,
|
|
22
|
+
* 不防「已能登服务器读 env / 内存」的攻击者(root 可读 env 与解密后内存)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const ENC_PREFIX = 'ENC(';
|
|
26
|
+
const ENC_SUFFIX = ')';
|
|
27
|
+
/** base64 的 32 字节密钥长度(AES-256) */
|
|
28
|
+
const KEY_BYTES = 32;
|
|
29
|
+
|
|
30
|
+
/** 判断是否为 ENC(...) 包裹的密文 */
|
|
31
|
+
export function isEncryptedValue(v: unknown): v is string {
|
|
32
|
+
return typeof v === 'string' && v.startsWith(ENC_PREFIX) && v.endsWith(ENC_SUFFIX);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 从 env 取 CONFIG_SECRET_KEY 并校验为 base64 的 32 字节密钥。
|
|
37
|
+
* 返回 base64 原串(app-crypto 的 aesGcmDecrypt 内部 Buffer.from(key,'base64'))。
|
|
38
|
+
* 缺失/非法 → 抛错(由调用方决定 fail-fast 还是 dev 回退)。
|
|
39
|
+
*/
|
|
40
|
+
export function loadConfigSecretKey(envKey = 'CONFIG_SECRET_KEY'): string {
|
|
41
|
+
const raw = process.env[envKey];
|
|
42
|
+
if (!raw) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[config-crypto] 缺少 ${envKey} 环境变量,无法解密配置中的 ENC(...) 值。` +
|
|
45
|
+
`加密模式的根密钥在服务器 APP_ROOT/.prod.secrets(首部署生成),` +
|
|
46
|
+
`经部署通道注入:systemd EnvironmentFile / pm2 env / docker run -e。`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
let buf: Buffer;
|
|
50
|
+
try {
|
|
51
|
+
buf = Buffer.from(raw, 'base64');
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error(`[config-crypto] ${envKey} 不是合法 base64`);
|
|
54
|
+
}
|
|
55
|
+
if (buf.length !== KEY_BYTES) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`[config-crypto] ${envKey} 解密后为 ${buf.length} 字节,期望 ${KEY_BYTES} 字节(AES-256)。请用 genkey 重新生成。`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return raw;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 解密单个 ENC(...) 值为明文。
|
|
65
|
+
* @param value 形如 ENC(b64(iv).b64(authTag).b64(cipher)) 的字符串
|
|
66
|
+
* @param secretKeyB64 base64 的 32 字节密钥
|
|
67
|
+
*/
|
|
68
|
+
export function decryptValue(value: string, secretKeyB64: string): string {
|
|
69
|
+
const inner = value.slice(ENC_PREFIX.length, -ENC_SUFFIX.length);
|
|
70
|
+
const parts = inner.split('.');
|
|
71
|
+
if (parts.length !== 3) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[config-crypto] 密文格式非法:期望 ENC(iv.authTag.cipher) 三段,实得 ${parts.length} 段`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const [ivB64, tagB64, cipherB64] = parts;
|
|
77
|
+
let iv: Buffer;
|
|
78
|
+
let authTag: Buffer;
|
|
79
|
+
let ciphertext: Buffer;
|
|
80
|
+
try {
|
|
81
|
+
iv = Buffer.from(ivB64, 'base64');
|
|
82
|
+
authTag = Buffer.from(tagB64, 'base64');
|
|
83
|
+
ciphertext = Buffer.from(cipherB64, 'base64');
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error('[config-crypto] 密文各段不是合法 base64');
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return aesGcmDecrypt(ciphertext, secretKeyB64, iv, authTag).toString('utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
// GCM 认证失败:密钥错 / 密文被篡改 / 三段错序,统一不泄露细节
|
|
91
|
+
throw new Error('[config-crypto] 解密失败(密钥不匹配或密文已损坏)');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 递归遍历配置对象,把所有 ENC(...) 字符串值就地替换为明文。
|
|
97
|
+
* 仅处理 string;number/boolean/数组/嵌套对象照常递归,数组内字符串一并解密。
|
|
98
|
+
* 遇非法密文 / 解密失败即抛错(fail-fast,由 configuration.ts 决定是否启动)。
|
|
99
|
+
*/
|
|
100
|
+
export function decryptConfigValues(node: unknown, secretKeyB64: string): void {
|
|
101
|
+
if (node === null || node === undefined) return;
|
|
102
|
+
if (Array.isArray(node)) {
|
|
103
|
+
for (let i = 0; i < node.length; i++) {
|
|
104
|
+
const item = node[i];
|
|
105
|
+
if (isEncryptedValue(item)) {
|
|
106
|
+
node[i] = decryptValue(item, secretKeyB64);
|
|
107
|
+
} else if (typeof item === 'object') {
|
|
108
|
+
decryptConfigValues(item, secretKeyB64);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (typeof node === 'object') {
|
|
114
|
+
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
|
|
115
|
+
if (isEncryptedValue(v)) {
|
|
116
|
+
(node as Record<string, unknown>)[k] = decryptValue(v, secretKeyB64);
|
|
117
|
+
} else if (typeof v === 'object') {
|
|
118
|
+
decryptConfigValues(v, secretKeyB64);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 检测配置树中是否含有 ENC(...) 值(用于决定是否要求密钥) */
|
|
125
|
+
export function hasEncryptedValues(node: unknown): boolean {
|
|
126
|
+
if (node === null || node === undefined) return false;
|
|
127
|
+
if (isEncryptedValue(node)) return true;
|
|
128
|
+
if (Array.isArray(node)) return node.some(hasEncryptedValues);
|
|
129
|
+
if (typeof node === 'object') {
|
|
130
|
+
return Object.values(node as Record<string, unknown>).some(hasEncryptedValues);
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
@@ -46,7 +46,7 @@ export interface AppConfig {
|
|
|
46
46
|
/** 单文件最大字节 */
|
|
47
47
|
maxSize: number;
|
|
48
48
|
};
|
|
49
|
-
/** 超管账号(yaml 为权威,启动 reconcile 到 DB;prod
|
|
49
|
+
/** 超管账号(yaml 为权威,启动 reconcile 到 DB;prod 真值在环境 overlay / 部署位 config.yaml) */
|
|
50
50
|
admin: {
|
|
51
51
|
username: string;
|
|
52
52
|
password: string;
|
|
@@ -74,7 +74,7 @@ export interface AppConfig {
|
|
|
74
74
|
* (better-sqlite3/mysql 同钩子 maxQueryExecutionTime → logQuerySlow)
|
|
75
75
|
*/
|
|
76
76
|
slowSql: {
|
|
77
|
-
/** 落盘目录(cwd
|
|
77
|
+
/** 落盘目录(cwd 相对,默认 logs/slowsql;docker 下随 /app/logs volume 单独挂载) */
|
|
78
78
|
dir: string;
|
|
79
79
|
/** 慢查询阈值 ms,同时作为 TypeORM maxQueryExecutionTime */
|
|
80
80
|
thresholdMs: number;
|
|
@@ -87,9 +87,28 @@ export interface AppConfig {
|
|
|
87
87
|
/** 单日文件上限 MB(超限滚动瘦身留新去旧,丢条计数随 stats 留痕) */
|
|
88
88
|
maxFileMb: number;
|
|
89
89
|
};
|
|
90
|
+
/** 配置加密总开关 */
|
|
91
|
+
security: {
|
|
92
|
+
/**
|
|
93
|
+
* 是否强制配置加密。true:敏感字段(默认 jwt.secret / admin.password / sso.ticketKey /
|
|
94
|
+
* appSign.cryptKey,清单见 encryptedFields)非空必须是 ENC(...) 密文,启动校验、
|
|
95
|
+
* 漏填明文 fail-fast;false:全部接受明文(已存在的 ENC(...) 仍照常解密,向后兼容)。
|
|
96
|
+
* 模板 base 恒 false(仓库层明文维护);服务器首部署选「开启加密」时 install.sh
|
|
97
|
+
* (ops/ensure-encrypt.cjs)把部署位 config.yaml 的此键置 true 并就地加密敏感值。
|
|
98
|
+
* 老配置缺 security 段时按 true(强制)对齐历史口径。
|
|
99
|
+
*/
|
|
100
|
+
encrypt: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* 受控加密字段清单(dot-path,相对配置树根部)。security.encrypt=true 时清单内字段
|
|
103
|
+
* 非空且非 ENC(...) → 拒启(仅 string 值参与校验,数组/数字字段不适用)。
|
|
104
|
+
* 打包产物是混淆后的 dist,改清单只改 yaml 即可、无需重编译;
|
|
105
|
+
* 老配置缺该项回落内置默认四项(向后兼容)。
|
|
106
|
+
*/
|
|
107
|
+
encryptedFields?: string[];
|
|
108
|
+
};
|
|
90
109
|
/** 运维调试日志(《后端日志体系整改方案》§2):API 失败(≥400)/慢请求登记,JSONL 落盘 */
|
|
91
110
|
apiLog: {
|
|
92
|
-
/** 落盘目录(cwd
|
|
111
|
+
/** 落盘目录(cwd 相对,默认 logs/apilog;docker 下随 /app/logs volume 单独挂载) */
|
|
93
112
|
dir: string;
|
|
94
113
|
/** 慢请求阈值 ms(超时记 slow;2xx 成功不记) */
|
|
95
114
|
slowMs: number;
|
|
@@ -2,19 +2,35 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
4
|
import type { AppConfig } from './config.interface';
|
|
5
|
+
import {
|
|
6
|
+
decryptConfigValues,
|
|
7
|
+
hasEncryptedValues,
|
|
8
|
+
isEncryptedValue,
|
|
9
|
+
loadConfigSecretKey
|
|
10
|
+
} from '../common/config-crypto';
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
|
-
*
|
|
8
|
-
* 文件位于 backend/config/config.{env}.yaml;buildops 产物保留 config/config.<env>.yaml 原名。
|
|
13
|
+
* 配置加载:config.base.yaml(全量默认)+ 环境 overlay 深合并,单一真相源。
|
|
9
14
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* 文件形态(backend/config/,新发布环境只需增一份 overlay):
|
|
16
|
+
* config.base.yaml 全量默认值 + 注释权威(所有环境共享)
|
|
17
|
+
* config.develop.yaml 开发 overlay:只写与 base 不同的键 + 本环境凭证(明文)
|
|
18
|
+
* config.prod.yaml 生产 overlay:同上,仓库/传输层明文维护 —— 服务器首部署选「开启
|
|
19
|
+
* 配置加密」时,install.sh 把部署位副本的敏感值就地加密为 ENC(...)
|
|
20
|
+
* (新环境 = 复制一份改值,如 config.demo.yaml,加密同此路径)
|
|
21
|
+
* config.yaml 部署位 overlay 固定名:install.sh 首部署从所选 overlay 播种到
|
|
22
|
+
* APP_ROOT/config/(此后发布不覆盖,运维/开发在其上维护该环境真值)
|
|
15
23
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
24
|
+
* 目录定位(优先级):CONFIG_DIR env(systemd unit / pm2 ecosystem 注入 = APP_ROOT/config)
|
|
25
|
+
* > cwd/config(dev 与 docker 容器:cwd=/app,挂载或镜像内 config)
|
|
26
|
+
* > __dirname/../../config(tsc 多文件布局兜底)。
|
|
27
|
+
* overlay 选择(同目录内):config.yaml(部署位固定名)> config.<NODE_ENV>.yaml(dev 侧按环境名)。
|
|
28
|
+
*
|
|
29
|
+
* env 覆盖只剩部署拓扑项(容器内路径/端口,由部署脚本注入,与业务配置无关):
|
|
30
|
+
* PORT / DB_DIR / UPLOAD_STORAGE_PATH / SLOW_SQL_DIR / API_LOG_DIR / CONFIG_SECRET_KEY(根密钥)
|
|
31
|
+
* 业务配置一律走 yaml:新增键 = base 加键 + config.interface.ts 加类型 + 消费处读取,
|
|
32
|
+
* 不再需要动任何 env 模板/部署文件(这是收敛「一处新增多处改」的根因修复)。
|
|
33
|
+
* 改部署位 APP_ROOT/config/config.yaml + 重启 即生效(改密轮换同路径,三模式行为一致)。
|
|
18
34
|
*/
|
|
19
35
|
|
|
20
36
|
const envStr = (k: string, fallback?: string): string | undefined => {
|
|
@@ -27,111 +43,182 @@ const envInt = (k: string, fallback?: number): number | undefined => {
|
|
|
27
43
|
const n = Number(v);
|
|
28
44
|
return Number.isFinite(n) ? n : fallback;
|
|
29
45
|
};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
46
|
+
|
|
47
|
+
/** 深合并:overlay 的键覆盖 base 同位键,对象递归、数组/标量整体替换 */
|
|
48
|
+
function deepMerge<T>(base: T, overlay: T): T {
|
|
49
|
+
if (
|
|
50
|
+
Array.isArray(base) || Array.isArray(overlay) ||
|
|
51
|
+
typeof base !== 'object' || typeof overlay !== 'object' ||
|
|
52
|
+
base === null || overlay === null
|
|
53
|
+
) {
|
|
54
|
+
return overlay;
|
|
55
|
+
}
|
|
56
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
57
|
+
for (const k of Object.keys(overlay as Record<string, unknown>)) {
|
|
58
|
+
out[k] = deepMerge((base as Record<string, unknown>)[k], (overlay as Record<string, unknown>)[k]);
|
|
59
|
+
}
|
|
60
|
+
return out as T;
|
|
61
|
+
}
|
|
40
62
|
|
|
41
63
|
export default (): AppConfig => {
|
|
42
64
|
const env = process.env.NODE_ENV || 'develop';
|
|
43
|
-
const file = `config.${env}.yaml`;
|
|
44
65
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (fs.existsSync(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
// 配置目录定位:CONFIG_DIR(部署脚本注入,存在 config.base.yaml 才算数)
|
|
67
|
+
// > cwd/config(dev 与 docker 容器) > __dirname/../../config(tsc 布局兜底)。
|
|
68
|
+
// 候选链保证:首选目录无 base 时回落下一候选(兼容无 base 的老布局 —— env
|
|
69
|
+
// overlay 单独成完整配置);全部无 base 时用首个存在目录。
|
|
70
|
+
const candidates: string[] = [];
|
|
71
|
+
if (process.env.CONFIG_DIR) candidates.push(process.env.CONFIG_DIR);
|
|
72
|
+
candidates.push(path.join(process.cwd(), 'config'));
|
|
73
|
+
candidates.push(path.join(__dirname, '../../config'));
|
|
74
|
+
let configDir = candidates[0];
|
|
75
|
+
for (const c of candidates) {
|
|
76
|
+
if (fs.existsSync(path.join(c, 'config.base.yaml'))) {
|
|
77
|
+
configDir = c;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!fs.existsSync(path.join(configDir, 'config.base.yaml'))) {
|
|
82
|
+
for (const c of candidates) {
|
|
83
|
+
if (fs.existsSync(c)) { configDir = c; break; }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
59
86
|
|
|
60
|
-
|
|
87
|
+
// base:全量默认(缺文件按空对象 —— 老布局无 base 时 overlay 单独成完整配置)
|
|
88
|
+
let base: Partial<AppConfig> = {};
|
|
61
89
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// 找不到对应环境文件时回退到 develop
|
|
66
|
-
config = yaml.load(fs.readFileSync(resolveConfig('config.develop.yaml'), 'utf8')) as AppConfig;
|
|
90
|
+
base = yaml.load(fs.readFileSync(path.join(configDir, 'config.base.yaml'), 'utf8')) as Partial<AppConfig>;
|
|
91
|
+
} catch {
|
|
92
|
+
// base 不存在或不可解析:按空对象继续
|
|
67
93
|
}
|
|
68
94
|
|
|
69
|
-
|
|
70
|
-
|
|
95
|
+
// overlay:部署位固定名 config.yaml > 环境 overlay config.<env>.yaml(都不在则仅 base)
|
|
96
|
+
const overlayName = fs.existsSync(path.join(configDir, 'config.yaml'))
|
|
97
|
+
? 'config.yaml'
|
|
98
|
+
: `config.${env}.yaml`;
|
|
99
|
+
let overlay: Partial<AppConfig> = {};
|
|
100
|
+
try {
|
|
101
|
+
overlay = yaml.load(fs.readFileSync(path.join(configDir, overlayName), 'utf8')) as Partial<AppConfig>;
|
|
102
|
+
} catch {
|
|
103
|
+
// overlay 不存在:纯 base 也能起(占位值直跑)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 断言同旧实现(yaml.load as AppConfig):base 全量键由模板保证,类型层不重复推导
|
|
107
|
+
const merged = deepMerge(base, overlay) as AppConfig;
|
|
108
|
+
|
|
109
|
+
const config: AppConfig = {
|
|
110
|
+
...merged,
|
|
111
|
+
// security 老配置缺段按 true(强制)对齐文档「密码类一律密文」口径
|
|
112
|
+
security: {
|
|
113
|
+
encrypt: merged.security?.encrypt ?? true,
|
|
114
|
+
encryptedFields: merged.security?.encryptedFields
|
|
115
|
+
},
|
|
71
116
|
app: {
|
|
72
|
-
...
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
117
|
+
...merged.app,
|
|
118
|
+
// PORT = 部署拓扑项(docker 权威 = .prod.docker.env 的 PORT,run-container.sh -e 注入;
|
|
119
|
+
// 未注入则回退 yaml app.port)
|
|
120
|
+
port: envInt('PORT', merged.app?.port) as number,
|
|
121
|
+
corsOrigins: merged.app?.corsOrigins ?? [],
|
|
122
|
+
trustProxy: merged.app?.trustProxy ?? false
|
|
76
123
|
},
|
|
77
124
|
database: {
|
|
78
|
-
...
|
|
79
|
-
// DB_DIR:仅目录(容器内如 /app/data、宿主绝对路径)
|
|
80
|
-
|
|
81
|
-
// dev=template.dev.sqlite),不外配;未设 DB_DIR 则用 yaml 的 dir(相对 data/ → WORKDIR /app)。
|
|
82
|
-
dir: envStr('DB_DIR', config.database.dir) as string
|
|
125
|
+
...merged.database,
|
|
126
|
+
// DB_DIR:仅目录(容器内如 /app/data、宿主绝对路径),部署脚本注入
|
|
127
|
+
dir: envStr('DB_DIR', merged.database?.dir) as string
|
|
83
128
|
},
|
|
84
129
|
jwt: {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expiresIn: envStr('JWT_EXPIRES', config.jwt.expiresIn) as string
|
|
130
|
+
secret: merged.jwt?.secret ?? '',
|
|
131
|
+
expiresIn: merged.jwt?.expiresIn ?? '8h'
|
|
88
132
|
},
|
|
89
133
|
log: {
|
|
90
|
-
|
|
91
|
-
level: envStr('LOG_LEVEL', config.log.level) as string
|
|
134
|
+
level: merged.log?.level ?? 'info'
|
|
92
135
|
},
|
|
93
136
|
upload: {
|
|
94
|
-
...
|
|
95
|
-
//
|
|
96
|
-
storagePath: envStr('UPLOAD_STORAGE_PATH',
|
|
137
|
+
...merged.upload,
|
|
138
|
+
// 上传目录:部署脚本注入(容器内随 data volume,如 /app/data/uploads)
|
|
139
|
+
storagePath: envStr('UPLOAD_STORAGE_PATH', merged.upload?.storagePath) as string
|
|
97
140
|
},
|
|
98
141
|
// 超管账号:yaml 为权威,启动时 user.service 把 DB reconcile 到此值
|
|
99
|
-
// (
|
|
100
|
-
//
|
|
101
|
-
// 占位值(change-me/空)时 user.service 跳过、不写弱默认,作防御兜底。
|
|
142
|
+
// (支持「改部署位 config.yaml + 重启」轮换)。占位值(change-me/空)时
|
|
143
|
+
// user.service 跳过 reconcile、不写弱默认,作防御兜底。
|
|
102
144
|
admin: {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
password: envStr('ADMIN_PASSWORD', config.admin.password) as string
|
|
145
|
+
username: merged.admin?.username ?? 'admin',
|
|
146
|
+
password: merged.admin?.password ?? ''
|
|
106
147
|
},
|
|
107
148
|
appSign: {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
tsWindow:
|
|
149
|
+
signKey: merged.appSign?.signKey ?? '',
|
|
150
|
+
cryptKey: merged.appSign?.cryptKey ?? '',
|
|
151
|
+
tsWindow: merged.appSign?.tsWindow ?? 300
|
|
111
152
|
},
|
|
112
153
|
sso: {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
tsWindow: envInt('SSO_TS_WINDOW', config.sso.tsWindow) as number
|
|
154
|
+
enabled: merged.sso?.enabled ?? false,
|
|
155
|
+
ticketKey: merged.sso?.ticketKey ?? '',
|
|
156
|
+
tsWindow: merged.sso?.tsWindow ?? 300
|
|
117
157
|
},
|
|
118
|
-
// 慢SQL探针 +
|
|
119
|
-
//
|
|
158
|
+
// 慢SQL探针 + 运维调试日志:落盘目录为部署拓扑项(部署脚本指到 APP_ROOT/logs),
|
|
159
|
+
// 其余阈值走 yaml;base/overlay 不带段也能起(内置默认)
|
|
120
160
|
slowSql: {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
maxFileMb: envInt('SLOW_SQL_MAX_FILE_MB', config.slowSql?.maxFileMb ?? 10) as number
|
|
161
|
+
dir: envStr('SLOW_SQL_DIR', merged.slowSql?.dir ?? 'logs/slowsql') as string,
|
|
162
|
+
thresholdMs: merged.slowSql?.thresholdMs ?? 50,
|
|
163
|
+
lagMs: merged.slowSql?.lagMs ?? 200,
|
|
164
|
+
hostSampleSec: merged.slowSql?.hostSampleSec ?? 60,
|
|
165
|
+
retainDays: merged.slowSql?.retainDays ?? 14,
|
|
166
|
+
maxFileMb: merged.slowSql?.maxFileMb ?? 10
|
|
128
167
|
},
|
|
129
|
-
// 运维调试日志:只记 ≥400 失败与 >slowMs 慢请求,2xx 成功不记
|
|
130
168
|
apiLog: {
|
|
131
|
-
dir: envStr('API_LOG_DIR',
|
|
132
|
-
slowMs:
|
|
133
|
-
retainDays:
|
|
134
|
-
maxFileMb:
|
|
169
|
+
dir: envStr('API_LOG_DIR', merged.apiLog?.dir ?? 'logs/apilog') as string,
|
|
170
|
+
slowMs: merged.apiLog?.slowMs ?? 1000,
|
|
171
|
+
retainDays: merged.apiLog?.retainDays ?? 14,
|
|
172
|
+
maxFileMb: merged.apiLog?.maxFileMb ?? 10
|
|
135
173
|
}
|
|
136
174
|
};
|
|
175
|
+
|
|
176
|
+
// 防呆校验(security.encrypt,缺 security 段按 true 对齐历史口径):敏感字段非空却不是
|
|
177
|
+
// ENC(...) 密文 → fail-fast,防「以为加密了实际裸奔」。模板 base 恒 false(仓库层明文),
|
|
178
|
+
// 服务器首部署选「开启加密」时由 ops/ensure-encrypt.cjs 把部署位 config.yaml 的此键置
|
|
179
|
+
// true —— 此后手改明文直接重启会被本校验拦下,提示回 ./install.sh 自动重新加密(轮换闭环)。
|
|
180
|
+
// 受控字段清单来自 security.encryptedFields(base 可配,缺省回落内置默认),打包产物 dist
|
|
181
|
+
// 是混淆源码,改清单只改 yaml、无需重编译。校验在解密之前(只看 ENC 前后缀)。
|
|
182
|
+
// 空占位不拦(与「占位可直跑」一致)。置 false 完全放开(不建议 prod)。
|
|
183
|
+
if (config.security.encrypt) {
|
|
184
|
+
const DEFAULT_ENCRYPTED_FIELDS = [
|
|
185
|
+
'jwt.secret',
|
|
186
|
+
'admin.password',
|
|
187
|
+
'sso.ticketKey',
|
|
188
|
+
'appSign.cryptKey'
|
|
189
|
+
];
|
|
190
|
+
// dot-path 解析配置树(label 形如 'jwt.secret');解析不到 = 清单写错,报出而非静默跳过
|
|
191
|
+
const getByPath = (obj: unknown, dotted: string): unknown =>
|
|
192
|
+
dotted.split('.').reduce<unknown>((o, k) => {
|
|
193
|
+
return typeof o === 'object' && o !== null ? (o as Record<string, unknown>)[k] : undefined;
|
|
194
|
+
}, obj);
|
|
195
|
+
const fields = config.security.encryptedFields?.length
|
|
196
|
+
? config.security.encryptedFields
|
|
197
|
+
: DEFAULT_ENCRYPTED_FIELDS;
|
|
198
|
+
const plainSensitive: string[] = [];
|
|
199
|
+
for (const label of fields) {
|
|
200
|
+
const value = getByPath(config, label);
|
|
201
|
+
if (typeof value === 'string' && value && !isEncryptedValue(value)) plainSensitive.push(label);
|
|
202
|
+
}
|
|
203
|
+
if (plainSensitive.length) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`[configuration] security.encrypt=true:以下敏感字段为明文,必须写 ENC(...) 密文` +
|
|
206
|
+
`(base 与环境 overlay 同强制):${plainSensitive.join(' / ')}。` +
|
|
207
|
+
`加密:重跑 ./install.sh(自动用服务器根密钥就地把明文加密);` +
|
|
208
|
+
`或手动 \`node scripts/config-crypt.cjs encrypt "<明文>" $CONFIG_SECRET_KEY\`;` +
|
|
209
|
+
`或把 config.yaml 的 security.encrypt 置 false 以明文运行(不建议 prod)。`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// jasypt 式解密:ENC(...) 密文值统一解开为明文,下游 config.get(...) 零改动。
|
|
215
|
+
// ENC 只存在于服务器侧(首部署「开启配置加密」后由 install.sh 就地加密部署位 config.yaml,
|
|
216
|
+
// 见 ops/ensure-encrypt.cjs);根密钥 CONFIG_SECRET_KEY 在服务器生成并只存服务器
|
|
217
|
+
// (APP_ROOT/.prod.secrets,600;systemd EnvironmentFile / pm2 env / docker run -e 注入),
|
|
218
|
+
// 开发机零密钥。密钥缺失/解密失败 → 抛错中断启动(fail-fast,不带病运行);
|
|
219
|
+
// 无 ENC 值时不要求密钥(明文/占位直跑)。
|
|
220
|
+
if (hasEncryptedValues(config)) {
|
|
221
|
+
decryptConfigValues(config, loadConfigSecretKey());
|
|
222
|
+
}
|
|
223
|
+
return config;
|
|
137
224
|
};
|
|
@@ -10,7 +10,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
|
10
10
|
* string → TEXT,number → INTEGER,boolean → INTEGER(0/1),Date → TEXT/DATETIME
|
|
11
11
|
*
|
|
12
12
|
* 五张框架表:user / screen_config / page_data / audit_log / image。
|
|
13
|
-
* 业务扩展:新增实体后 `pnpm --filter backend migration:generate src/database/migrations/xxxx`
|
|
13
|
+
* 业务扩展:新增实体后 `pnpm --filter ./backend migration:generate src/database/migrations/xxxx`
|
|
14
14
|
* 生成新迁移文件,并在 sqlite.config.ts 的 migrations 数组显式 import 注册(必须,勿漏)。
|
|
15
15
|
*/
|
|
16
16
|
export class Init1740000000000 implements MigrationInterface {
|
|
@@ -12,7 +12,7 @@ import { sqlFingerprint, hostTension } from './sql-fingerprint';
|
|
|
12
12
|
* 切 MySQL 时本文件零改动,只在 MySQL options 侧严禁顺手开 enableQueryTimeout
|
|
13
13
|
* (那会把 maxQueryExecutionTime 变成 server 端杀查询的超时)。
|
|
14
14
|
*
|
|
15
|
-
* 记录落独立 JSONL(
|
|
15
|
+
* 记录落独立 JSONL(logs/slowsql/slowsql-YYYY-MM-DD.jsonl),非应用日志——是调试
|
|
16
16
|
* 期间的证据链,供 slowsql-report.mjs 后期分析。三类记录:
|
|
17
17
|
* sql 慢查询样本(内联最近宿主快照 host 字段;《后端日志体系整改方案》§4.2 起
|
|
18
18
|
* 同指纹每日仅前 5 条落全量,其余只进 stats 计数——影响大的就是那几条,
|
|
@@ -52,8 +52,8 @@ export const buildTypeOrmOptions = (
|
|
|
52
52
|
const db = config.get<AppConfig['database']>('database');
|
|
53
53
|
const env = process.env.NODE_ENV || 'develop';
|
|
54
54
|
const isProd = env === 'prod';
|
|
55
|
-
// 文件名按环境定死(不外配):prod=template.prod.sqlite / dev=template.dev.sqlite
|
|
56
|
-
//
|
|
55
|
+
// 文件名按环境定死(不外配):prod=template.prod.sqlite / dev=template.dev.sqlite。
|
|
56
|
+
// 仅目录(dir/DB_DIR)可外配。
|
|
57
57
|
const dbFilename = isProd ? 'template.prod.sqlite' : 'template.dev.sqlite';
|
|
58
58
|
// 慢SQL探针(《慢SQL探针与SQL优化路径方案》):挂 TypeORM Logger 层,驱动无关——
|
|
59
59
|
// better-sqlite3 与 mysql 两驱动的 QueryRunner 都内置同一钩子(maxQueryExecutionTime
|
|
@@ -27,7 +27,7 @@ async function bootstrap() {
|
|
|
27
27
|
if (!jwtSecret || jwtSecret === DEV_DEFAULT_SECRET) {
|
|
28
28
|
throw new Error(
|
|
29
29
|
`[security] NODE_ENV=${nodeEnv} 但 jwt.secret 为空/开发默认值,拒绝启动。` +
|
|
30
|
-
'
|
|
30
|
+
'请在环境 overlay yaml(config.prod.yaml / 部署位 config.yaml)注入强密钥(CLI 生成的项目已自动注入)。'
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
} else if (!nodeEnv) {
|
|
@@ -101,7 +101,7 @@ async function bootstrap() {
|
|
|
101
101
|
const origins = cfg.get<AppConfig['app']>('app').corsOrigins;
|
|
102
102
|
if (!origins.length) {
|
|
103
103
|
throw new Error(
|
|
104
|
-
'[security] app.corsOrigins 为空——请在 yaml
|
|
104
|
+
'[security] app.corsOrigins 为空——请在 yaml 配置前端来源后重启。'
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
app.enableCors({
|
|
@@ -111,7 +111,7 @@ async function bootstrap() {
|
|
|
111
111
|
|
|
112
112
|
// 可信反代:默认关闭——直连部署(compose 直暴端口)下 x-forwarded-for 可伪造,
|
|
113
113
|
// 限流/审计 IP 一律取 socket 对端。前置 nginx 时设 yaml app.trustProxy=true
|
|
114
|
-
// (
|
|
114
|
+
// (yaml 配置),req.ip 即按 XFF 正确解析客户端 IP。
|
|
115
115
|
if (cfg.get<AppConfig['app']>('app').trustProxy) {
|
|
116
116
|
app.set('trust proxy', 1);
|
|
117
117
|
}
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
export const OPS_PROBE = Symbol('OPS_PROBE');
|
|
20
20
|
|
|
21
21
|
export interface OpsProbe {
|
|
22
|
-
/** 探针唯一标识,前端按此聚合,如 '
|
|
22
|
+
/** 探针唯一标识,前端按此聚合,如 'backend-version' */
|
|
23
23
|
readonly key: string;
|
|
24
|
-
/**
|
|
24
|
+
/** 展示名,如「后台版本」 */
|
|
25
25
|
readonly name: string;
|
|
26
26
|
/** 卡片渲染形态 */
|
|
27
27
|
readonly kind: OpsProbeKind;
|
|
@@ -38,7 +38,7 @@ export interface OpsProbeResult {
|
|
|
38
38
|
kind: OpsProbeKind;
|
|
39
39
|
/** healthy: true=正常 / false=异常 / unknown=探针自身无法判定(如目录不存在) */
|
|
40
40
|
healthy: boolean | 'unknown';
|
|
41
|
-
/**
|
|
41
|
+
/** 一句话摘要,状态卡用,如「v1.2.3」 */
|
|
42
42
|
summary: string;
|
|
43
43
|
/** 详情,结构随 kind 不同(见下方 *Detail)。前端按 kind 解析。 */
|
|
44
44
|
detail: OpsStatusDetail | OpsTableDetail | OpsLogDetail;
|
|
@@ -3,9 +3,6 @@ import { OpsService } from './ops.service';
|
|
|
3
3
|
import { OpsController } from './ops.controller';
|
|
4
4
|
import { VersionController } from './version.controller';
|
|
5
5
|
import { OPS_PROBE, type OpsProbe } from './ops-probe.interface';
|
|
6
|
-
import { SqliteBackupProbe } from './sqlite-backup.probe';
|
|
7
|
-
import { SqliteBackupListProbe } from './sqlite-backup-list.probe';
|
|
8
|
-
import { SqliteBackupLogProbe } from './sqlite-backup-log.probe';
|
|
9
6
|
import { BackendVersionProbe } from './backend-version.probe';
|
|
10
7
|
|
|
11
8
|
/**
|
|
@@ -16,10 +13,7 @@ import { BackendVersionProbe } from './backend-version.probe';
|
|
|
16
13
|
* 加到下方 providers 数组即可。OpsService 通过多 provider 注入自动发现,
|
|
17
14
|
* controller / 前端均无需改动。
|
|
18
15
|
*
|
|
19
|
-
*
|
|
20
|
-
* - SqliteBackupProbe status 状态卡(最近备份时间 / 健康灯 / 份数)
|
|
21
|
-
* - SqliteBackupListProbe table 备份历史表
|
|
22
|
-
* - SqliteBackupLogProbe log 备份日志尾
|
|
16
|
+
* 当前探针:
|
|
23
17
|
* - BackendVersionProbe status 后台版本号(package.json version)
|
|
24
18
|
*
|
|
25
19
|
* 未来 B 阶段可挂(各自新文件 + 加到 providers):
|
|
@@ -29,9 +23,6 @@ import { BackendVersionProbe } from './backend-version.probe';
|
|
|
29
23
|
providers: [
|
|
30
24
|
OpsService,
|
|
31
25
|
// 各探针以自身类 token 注册(可被下方工厂注入)
|
|
32
|
-
SqliteBackupProbe,
|
|
33
|
-
SqliteBackupListProbe,
|
|
34
|
-
SqliteBackupLogProbe,
|
|
35
26
|
BackendVersionProbe,
|
|
36
27
|
// NestJS 不支持 Angular 的 multi:true,同 token 的 { provide, useClass } 会互相覆盖
|
|
37
28
|
// (最后一个胜出)。故用工厂把所有探针实例聚合成数组,注入到 OPS_PROBE token,
|
|
@@ -41,7 +32,7 @@ import { BackendVersionProbe } from './backend-version.probe';
|
|
|
41
32
|
{
|
|
42
33
|
provide: OPS_PROBE,
|
|
43
34
|
useFactory: (...probes: OpsProbe[]) => probes,
|
|
44
|
-
inject: [
|
|
35
|
+
inject: [BackendVersionProbe]
|
|
45
36
|
}
|
|
46
37
|
],
|
|
47
38
|
controllers: [OpsController, VersionController],
|
|
@@ -32,7 +32,7 @@ export class UserService implements OnModuleInit {
|
|
|
32
32
|
* 超管(superadmin)仅此一个,随配置注入,后台 UI 不可创建/不可见/不可改。
|
|
33
33
|
* admin/普通用户由后台 UI 管理。
|
|
34
34
|
* 「改配置 + 重启」即完成轮换(如三月一换)。
|
|
35
|
-
* prod
|
|
35
|
+
* prod 真值在环境 overlay / 部署位 config.yaml(直读)。占位值
|
|
36
36
|
* (change-me/空)时跳过 reconcile、不写弱默认,作防御兜底。
|
|
37
37
|
*/
|
|
38
38
|
async onModuleInit() {
|
|
@@ -43,7 +43,7 @@ export class UserService implements OnModuleInit {
|
|
|
43
43
|
// 占位/空:防御性跳过,不写库(yaml 已带真值时正常走 reconcile;此处仅兜底异常配置)。
|
|
44
44
|
if (!password || /^change-?me$/i.test(password)) {
|
|
45
45
|
this.logger.warn(
|
|
46
|
-
`admin.password 为占位值,跳过超管 reconcile(检查 ${isProd ? '
|
|
46
|
+
`admin.password 为占位值,跳过超管 reconcile(检查 ${isProd ? '环境 overlay / 部署位 config.yaml 的 admin.password' : 'config.develop.yaml'})`
|
|
47
47
|
);
|
|
48
48
|
return;
|
|
49
49
|
}
|