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
|
@@ -1,49 +1,57 @@
|
|
|
1
1
|
# fullstack-template backend — docker compose 部署
|
|
2
2
|
# 本文件与 Dockerfile 同级:开发机在 backend/ 根,buildops 拷入产物 releases/<version>-<ts>/(→ 服务器 current/),
|
|
3
3
|
# compose 与 Dockerfile 同处 current/ 根。
|
|
4
|
-
# 镜像: image:<TAG>(TAG 来自 .env,由 docker/install.sh 以 current/ 为 context 现场构建)
|
|
5
|
-
# 配置:
|
|
4
|
+
# 镜像: image:<TAG>(TAG 来自 .prod.docker.env,由 docker/install.sh 以 current/ 为 context 现场构建)
|
|
5
|
+
# 配置: 部署位 CONFIG_DIR 只读挂载 /app/config(config.base.yaml + config.yaml 环境 overlay;
|
|
6
|
+
# 业务配置一律在那,见 configuration.ts);env 文件只剩部署拓扑项(buildops 自动生成)
|
|
6
7
|
# 数据: bind mount DATA_DIR → /app/data(跨版本共享,轮转/回滚不动)
|
|
8
|
+
# 日志: bind mount LOGS_DIR → /app/logs(slowsql/apilog 落此,缺省随 DATA_DIR/logs 相邻)
|
|
7
9
|
# 运维: 直接用 docker compose 命令(up/down/logs/ps),无需额外 shell
|
|
8
10
|
|
|
9
11
|
services:
|
|
10
12
|
backend:
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
+
# 镜像名缺省(fullstack-template-backend)与 ops/docker 部署同口径:运行时 IMAGE 由
|
|
14
|
+
# ops/docker/config.sh 从包 name 派生、deploy 时覆写 env 文件对齐,此处缺省仅本地无 env 用。
|
|
15
|
+
# tag = <version>(install.sh 现场 build 时已对齐 env 文件的 TAG)。
|
|
13
16
|
image: ${IMAGE:-fullstack-template-backend}:${TAG}
|
|
14
17
|
# build context = current/(compose 文件所在目录);docker compose up --build 现场 build。
|
|
15
18
|
# 迁移机若已 docker load 同 tag 镜像,docker compose up(不带 --build)直接用已加载镜像。
|
|
16
|
-
|
|
19
|
+
# APP_LABEL 注入与 ops/docker/install.sh 同源(IMAGE):镜像 label 恒 = IMAGE,prune 定向不错位。
|
|
20
|
+
build:
|
|
21
|
+
context: .
|
|
22
|
+
args:
|
|
23
|
+
APP_LABEL: ${IMAGE:-fullstack-template-backend}
|
|
17
24
|
container_name: ${CONTAINER_NAME:-fullstack-template-backend}
|
|
18
25
|
restart: unless-stopped
|
|
19
|
-
# 端口权威 = .env 的 PORT(env 注入,app 读 PORT 监听);宿主映射 = 容器监听 = PORT。
|
|
20
|
-
# 迁移换端口只改
|
|
26
|
+
# 端口权威 = .prod.docker.env 的 PORT(env 注入,app 读 PORT 监听);宿主映射 = 容器监听 = PORT。
|
|
27
|
+
# 迁移换端口只改 env 文件的 PORT,镜像不动。
|
|
21
28
|
ports:
|
|
22
29
|
- "${PORT:-3001}:${PORT:-3001}"
|
|
23
30
|
environment:
|
|
24
31
|
NODE_ENV: ${NODE_ENV:-prod}
|
|
25
|
-
# PORT 权威在此(app 读 PORT
|
|
32
|
+
# PORT 权威在此(app 读 PORT 监听;未注入则回退配置 yaml 的 app.port)。
|
|
26
33
|
PORT: ${PORT:-3001}
|
|
27
|
-
# 容器内持久化路径写死(随 /app/data mount 目标锁定,非部署期变量):
|
|
34
|
+
# 容器内持久化路径写死(随 /app/data、/app/logs mount 目标锁定,非部署期变量):
|
|
28
35
|
# DB_DIR → sqlite 主库所在目录(文件名由程序定死为 template.prod.sqlite)
|
|
29
36
|
# UPLOAD_STORAGE_PATH → 上传文件子目录
|
|
30
|
-
#
|
|
37
|
+
# SLOW_SQL_DIR → 慢SQL探针 JSONL 落盘子目录(logs 卷)
|
|
38
|
+
# API_LOG_DIR → 运维调试日志 JSONL 落盘子目录(logs 卷)
|
|
39
|
+
# 业务配置(密钥/密码/限流阈值等)不经 env 注入 —— 一律走下方 config 挂载的部署位 yaml。
|
|
31
40
|
DB_DIR: /app/data
|
|
32
41
|
UPLOAD_STORAGE_PATH: /app/data/uploads
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
JWT_SECRET: ${JWT_SECRET:-}
|
|
36
|
-
JWT_EXPIRES: ${JWT_EXPIRES:-}
|
|
37
|
-
LOG_LEVEL: ${LOG_LEVEL:-}
|
|
38
|
-
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
|
39
|
-
APP_SIGN_KEY: ${APP_SIGN_KEY:-}
|
|
40
|
-
APP_SIGN_TS_WINDOW: ${APP_SIGN_TS_WINDOW:-}
|
|
42
|
+
SLOW_SQL_DIR: /app/logs/slowsql
|
|
43
|
+
API_LOG_DIR: /app/logs/apilog
|
|
41
44
|
volumes:
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
- ${
|
|
45
|
+
# 部署位配置(业务配置单一真相源):CONFIG_DIR 指向 APP_ROOT/config(含 config.base.yaml +
|
|
46
|
+
# config.yaml 环境 overlay 真值),只读挂载;缺省回落 compose 同级 config/(镜像兜底同款)。
|
|
47
|
+
- ${CONFIG_DIR:-./config}:/app/config:ro
|
|
48
|
+
# 宿主持久化双卷:数据 → /app/data(db/backup/uploads 全落此 volume 子目录);
|
|
49
|
+
# 进程日志 → /app/logs(slowsql/apilog JSONL)。DATA_DIR 必填,不设默认值
|
|
50
|
+
# 以免误落到错误宿主路径;LOGS_DIR 缺省随 DATA_DIR/logs 相邻(勿与数据混放需显式指定)。
|
|
51
|
+
- ${DATA_DIR:?DATA_DIR must be set in .prod.docker.env}:/app/data
|
|
52
|
+
- ${LOGS_DIR:-${DATA_DIR}/logs}:/app/logs
|
|
45
53
|
healthcheck:
|
|
46
|
-
# 无独立 /health 接口,改用 TCP 端口探测(容器内自连,端口须与
|
|
54
|
+
# 无独立 /health 接口,改用 TCP 端口探测(容器内自连,端口须与 env 文件 PORT 一致)
|
|
47
55
|
test: ["CMD", "node", "-e", "require('net').connect(${PORT:-3001},'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"]
|
|
48
56
|
interval: 30s
|
|
49
57
|
timeout: 5s
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# docker 部署共用配置 —— 运维按环境改这一处即可,各脚本都 source 它。
|
|
3
|
+
# 容器创建命令(docker run 参数)收敛在同目录 run-container.sh,修改挂载/env 参数去那里。
|
|
3
4
|
#
|
|
4
5
|
# 与 systemd/pm2 的 config.sh 对位。APP_ROOT 由顶层 install.sh / start.sh / stop.sh
|
|
5
|
-
# 经环境变量传入(
|
|
6
|
+
# 经环境变量传入(顶层脚本运行时自定位);此处只补 docker 专属变量。
|
|
6
7
|
|
|
7
8
|
# 后端根目录(current/releases/data/logs 所在)—— 顶层脚本导出
|
|
8
9
|
APP_ROOT="${APP_ROOT:-}"
|
|
@@ -11,15 +12,31 @@ APP_ROOT="${APP_ROOT:-}"
|
|
|
11
12
|
CURRENT_DIR="$APP_ROOT/current"
|
|
12
13
|
DATA_DIR="$APP_ROOT/data"
|
|
13
14
|
LOG_DIR="$APP_ROOT/logs"
|
|
14
|
-
|
|
15
|
+
# 部署位配置目录(跨版本共享:config.base.yaml + config.yaml 环境 overlay 真值)——
|
|
16
|
+
# run-container.sh 只读挂载进容器 /app/config,configuration.ts 经 cwd/config 命中挂载副本
|
|
17
|
+
# (镜像内 base+prod 仅为无挂载时的兜底)。改 $APP_ROOT/config/config.yaml + ./start.sh 即生效。
|
|
18
|
+
CONFIG_DIR_HOST="$APP_ROOT/config"
|
|
19
|
+
# 容器内挂载目标(随 /app/config 布局锁定,非部署期变量)
|
|
20
|
+
CONTAINER_CONFIG_DIR="/app/config"
|
|
21
|
+
# 容器 env 文件(docker 模式专用,纯部署件:IMAGE/TAG/CONTAINER_NAME/PORT/DATA_DIR/LOGS_DIR/
|
|
22
|
+
# NODE_ENV,buildops 自动生成、install.sh 部署时随包拷入 APP_ROOT 根并对齐;业务配置不在此,
|
|
23
|
+
# 在 $CONFIG_DIR_HOST/config.yaml):路径可外配 —— 运维想把 env 文件挪到独立目录时
|
|
24
|
+
# export ENV_FILE 覆盖(需提前自行拷贝到位)。
|
|
25
|
+
ENV_FILE="${ENV_FILE:-$APP_ROOT/.prod.docker.env}"
|
|
26
|
+
# 配置密文根密钥(与 systemd/pm2 同口径):首部署选「开启配置加密」时由 ops/lib-encrypt.sh
|
|
27
|
+
# 在本机生成(600)。run-container.sh 读取后以 docker run -e 注入(--env-file 的
|
|
28
|
+
# .prod.docker.env 不放根密钥);缺文件 = 明文模式(不注入)。
|
|
29
|
+
SECRETS_FILE="$APP_ROOT/.prod.secrets"
|
|
15
30
|
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
#
|
|
31
|
+
# 共享函数库(服务身份运行时从 current/package.json 派生;source 零动作)
|
|
32
|
+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh"
|
|
33
|
+
# 镜像名(不含 tag;tag = <version>,见 read_tag)—— 包 name 派生,export IMAGE 可覆盖
|
|
34
|
+
IMAGE="${IMAGE:-$(pkg_name)}"
|
|
35
|
+
# 容器名 —— 同上派生,export CONTAINER_NAME 可覆盖(身份与包名分叉的项目用)
|
|
36
|
+
CONTAINER_NAME="${CONTAINER_NAME:-$(pkg_name)}"
|
|
37
|
+
# 运行端口权威 = .prod.docker.env 的 PORT(docker 模式 env 注入,app 读 PORT 监听;迁移换端口只改它)。
|
|
21
38
|
# 宿主映射 = 容器内监听 = 此端口(PORT:PORT,nginx 反代到此),与 healthcheck 一致。
|
|
22
|
-
#
|
|
39
|
+
# env 文件无 PORT 回退 3001(防御,不阻断)。systemd/pm2 模式端口权威在部署位 config.yaml,与此无关。
|
|
23
40
|
read_env_port() {
|
|
24
41
|
[[ -f "$ENV_FILE" ]] || { printf '%s' "3001"; return; }
|
|
25
42
|
local p
|
|
@@ -36,14 +53,12 @@ RESTART_POLICY="${RESTART_POLICY:-unless-stopped}"
|
|
|
36
53
|
# 版本轮转保留数(docker = 本镜像仓库历史 tag 数,即回滚窗口;顶层 install.sh 轮转时读)
|
|
37
54
|
KEEP_VERSIONS="${KEEP_VERSIONS:-3}"
|
|
38
55
|
|
|
39
|
-
# 读 current 的 version
|
|
40
|
-
#
|
|
56
|
+
# 读 current 的 version 作镜像 tag(纯 version,不带时间戳)。
|
|
57
|
+
# 注意:同 version 重发 = 同名 tag 覆盖重建,回滚窗口 = 轮转保留的不同 version 数。
|
|
58
|
+
# (.build-ts 由 buildops 写入包内,仅作构建时间溯源,docker tag 不消费)
|
|
41
59
|
read_tag() {
|
|
42
|
-
local v
|
|
60
|
+
local v
|
|
43
61
|
v="$(grep -m1 '"version"' "$CURRENT_DIR/package.json" 2>/dev/null \
|
|
44
62
|
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)"
|
|
45
|
-
|
|
46
|
-
if [[ -n "$v" && -n "$ts" ]]; then printf '%s-%s' "$v" "$ts"
|
|
47
|
-
else printf '%s' "$v"
|
|
48
|
-
fi
|
|
63
|
+
printf '%s' "$v"
|
|
49
64
|
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
# Description docker 部署 apply:从 current/ context build 镜像 → 停删旧容器 → run 新容器
|
|
4
4
|
###
|
|
5
5
|
# 由顶层 install.sh / versionswitch.sh 派发(APP_ROOT/MODE 已导出)。
|
|
6
|
-
# env
|
|
7
|
-
#
|
|
6
|
+
# env(纯部署件)从 APP_ROOT/.prod.docker.env 注入(install.sh 部署时随包拷入);业务配置经
|
|
7
|
+
# 部署位 APP_ROOT/config 只读挂载 /app/config(run-container.sh);data 走 volume 挂载 /app/data。
|
|
8
|
+
# 镜像 tag = <version>(read_tag 读 current/package.json);同 version 重发覆盖同名 tag,旧 version tag 留作回滚。
|
|
8
9
|
set -euo pipefail
|
|
9
10
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
11
|
source "$DIR/config.sh"
|
|
@@ -26,56 +27,47 @@ echo "▶ docker build $IMAGE_TAG (context=$CURRENT_DIR) ..."
|
|
|
26
27
|
# 直接以 current/ 为 build context:Dockerfile 与 .dockerignore 都在 current/ 根,
|
|
27
28
|
# .dockerignore 排除 ops/ 等非镜像文件,context 仅含 package.json/dist/config/Dockerfile。
|
|
28
29
|
# Dockerfile 的 COPY package.json/dist/config 即从 current/ 根取,与目录布局对齐。
|
|
29
|
-
if [[ ! -f "$CURRENT_DIR/config/config.
|
|
30
|
-
echo "⚠ current/config/config.
|
|
30
|
+
if [[ ! -f "$CURRENT_DIR/config/config.base.yaml" ]]; then
|
|
31
|
+
echo "⚠ current/config/config.base.yaml 不存在,镜像将无兜底配置(全靠部署位挂载)"
|
|
31
32
|
fi
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
# --build-arg APP_LABEL=IMAGE:镜像 label 与运行时 IMAGE 同源烙入(Dockerfile 各阶段 ARG 接),
|
|
35
|
+
# rotate_images 的 prune 过滤(label=app=$IMAGE)恒命中本应用,不随身份派生错位。
|
|
36
|
+
docker build --build-arg APP_LABEL="$IMAGE" -t "$IMAGE_TAG" "$CURRENT_DIR"
|
|
34
37
|
echo "✓ 镜像构建: $IMAGE_TAG"
|
|
35
38
|
|
|
36
|
-
#
|
|
37
|
-
|
|
39
|
+
# 配置加密维护(每次部署,镜像构建后进行:docker 宿主常无 node,加密经一次性容器执行):
|
|
40
|
+
# 首部署选「开启加密」时在本机生成根密钥;有根密钥则把部署位 config.yaml 新明文就地加密
|
|
41
|
+
# (改明文 + 重跑 ./install.sh = 轮换闭环,见 docs/config-encryption.md)。
|
|
42
|
+
source "$DIR/../lib-encrypt.sh"
|
|
43
|
+
ensure_config_encryption "$IMAGE_TAG"
|
|
44
|
+
|
|
45
|
+
# env:APP_ROOT/.prod.docker.env 为权威(顶层 install.sh 部署时已随包拷入并前置对齐)。
|
|
46
|
+
# 现场对齐本次构建的 IMAGE/TAG/DATA_DIR/CONTAINER_NAME(PORT 在 env 文件由运维权威,不覆盖)。
|
|
47
|
+
# (IMAGE/TAG 与 config.sh 的构建一致;--env-file 把 env 文件全量灌进容器,含 PORT)
|
|
38
48
|
if [[ -f "$ENV_FILE" ]]; then
|
|
39
|
-
# 对齐本次部署的 IMAGE/TAG/DATA_DIR/CONTAINER_NAME(PORT 在 .env 由运维权威,不覆盖)
|
|
40
|
-
# (IMAGE/TAG 与 config.sh 的构建一致;--env-file 把 .env 全量灌进容器,含 PORT/密钥)
|
|
41
49
|
_sed_i "$ENV_FILE" \
|
|
42
50
|
-e "s|^IMAGE=.*|IMAGE=$IMAGE|" \
|
|
43
51
|
-e "s|^TAG=.*|TAG=$TAG|" \
|
|
44
52
|
-e "s|^DATA_DIR=.*|DATA_DIR=$DATA_DIR|" \
|
|
45
53
|
-e "s|^CONTAINER_NAME=.*|CONTAINER_NAME=$CONTAINER_NAME|"
|
|
46
|
-
ENV_FLAG="--env-file $ENV_FILE"
|
|
47
|
-
else
|
|
48
|
-
echo "⚠ 缺 $ENV_FILE,用空 env 起容器(仅限调试,密钥将为空)"
|
|
49
54
|
fi
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# 端口权威 = .env 的 PORT(config.sh::read_env_port 读);-e PORT 注入 app 监听,-p 映射同值。
|
|
56
|
-
# DB_DIR/UPLOAD_STORAGE_PATH 随 /app/data mount 目标锁定,容器内布局固定。
|
|
57
|
-
docker run -d \
|
|
58
|
-
--name "$CONTAINER_NAME" \
|
|
59
|
-
--restart "$RESTART_POLICY" \
|
|
60
|
-
-p "$HOST_PORT:$CONTAINER_PORT" \
|
|
61
|
-
$ENV_FLAG \
|
|
62
|
-
-e NODE_ENV=prod \
|
|
63
|
-
-e PORT="$PORT" \
|
|
64
|
-
-e DB_DIR="$CONTAINER_DATA_DIR" \
|
|
65
|
-
-e UPLOAD_STORAGE_PATH="$CONTAINER_DATA_DIR/uploads" \
|
|
66
|
-
-v "$DATA_DIR:$CONTAINER_DATA_DIR" \
|
|
67
|
-
"$IMAGE_TAG"
|
|
56
|
+
# 容器创建命令收敛于 run-container.sh(与 start.sh/versionswitch.sh 共用,参数勿在调用方手拷)
|
|
57
|
+
source "$DIR/run-container.sh"
|
|
58
|
+
run_container "$IMAGE_TAG"
|
|
68
59
|
|
|
69
60
|
echo "✓ 容器已起: $CONTAINER_NAME (宿主 $HOST_PORT → $CONTAINER_PORT, tag $TAG)"
|
|
70
61
|
|
|
71
62
|
# ── 镜像轮转:对齐 systemd/pm2 的 rotate_releases 口径 ──
|
|
72
63
|
# docker 模式版本靠镜像 tag、不进 releases/,故轮转对象 = 本镜像仓库的历史 tag:
|
|
73
|
-
#
|
|
64
|
+
# 按版本号降序保留 KEEP_VERSIONS 个(缺省见 config.sh),其余 rmi(回滚窗口=保留数)。
|
|
74
65
|
# KEEP_VERSIONS 优先级:顶层 --keep/env 显式 export > config.sh 持久值(运维改这处)。
|
|
75
66
|
# 不删运行中容器在用的 tag(防御);versionswitch.sh 切版本不经过本脚本,不受影响。
|
|
76
67
|
rotate_images() {
|
|
77
68
|
# 半成品兜底:经典 builder 多阶段构建的 deps 阶段成品(711MB 级 <none>)无 tag、无子层,轮转清不到;
|
|
78
|
-
#
|
|
69
|
+
# 按镜像 app label 定向 prune(仅本应用,不碰宿主其他镜像)—— label 由本脚本 --build-arg
|
|
70
|
+
# 以 IMAGE 注入,与过滤值同源恒命中(规矩:改 IMAGE 只改 config.sh/外配,label 随 build 自动跟)。
|
|
79
71
|
# 但该 <none> 同时是经典 builder 的构建缓存(删掉 = 下次全量重跑 apt+npm ci),故加 until=72h:
|
|
80
72
|
# 只清 72h 前的陈年残留,当天/周末内刚生成的缓存保住(发版即刷新存活期);放在 tag 轮转前,
|
|
81
73
|
# 不受 ≤KEEP_VERSIONS 提前 return 影响。
|
|
@@ -83,12 +75,11 @@ rotate_images() {
|
|
|
83
75
|
local running_ref
|
|
84
76
|
running_ref="$(docker inspect "$CONTAINER_NAME" --format '{{.Config.Image}}' 2>/dev/null || true)"
|
|
85
77
|
local list=() t
|
|
86
|
-
# tag
|
|
78
|
+
# tag = 纯 version;按版本号降序(sort -rV,与 versionswitch.sh switch_docker 列 tag 同口径)
|
|
87
79
|
while IFS= read -r t; do
|
|
88
80
|
[[ -n "$t" ]] || continue
|
|
89
81
|
list+=("$t")
|
|
90
|
-
done < <(docker images "$IMAGE" --format '{{.Tag}}' | grep -
|
|
91
|
-
| awk '{ts=$0; sub(/.*-/,"",ts); print ts" "$0}' | sort -r | awk '{print $2}')
|
|
82
|
+
done < <(docker images "$IMAGE" --format '{{.Tag}}' | grep -v '<none>' | sort -rV || true)
|
|
92
83
|
[[ ${#list[@]} -le $KEEP_VERSIONS ]] && return 0
|
|
93
84
|
local del=("${list[@]:$KEEP_VERSIONS}")
|
|
94
85
|
for t in "${del[@]}"; do
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
###
|
|
3
|
+
# Description: docker 容器创建命令单一真相源(start/install/versionswitch 三处共用)
|
|
4
|
+
###
|
|
5
|
+
# 本文件是被 source 的函数库(source 时零动作),调用方须先 source 同目录 config.sh
|
|
6
|
+
# (依赖其中 CONTAINER_NAME/RESTART_POLICY/HOST_PORT/CONTAINER_PORT/CONTAINER_DATA_DIR/
|
|
7
|
+
# DATA_DIR/LOG_DIR/CONFIG_DIR_HOST/CONTAINER_CONFIG_DIR/ENV_FILE/PORT)。
|
|
8
|
+
#
|
|
9
|
+
# 设计动机:env 文件只在容器"创建"那一刻经 docker run --env-file 灌入容器元数据,
|
|
10
|
+
# 之后 docker stop/start 是恢复旧容器、不重读 env —— 改 .prod.docker.env 后必须重建容器才生效。
|
|
11
|
+
# 故"启容器"统一收敛为重建:env 文件是纯部署件(buildops 自动生成,只有 IMAGE/TAG/PORT 等拓扑项);
|
|
12
|
+
# 业务配置走部署位 $CONFIG_DIR_HOST(只读挂载 /app/config,configuration.ts 命中挂载副本,
|
|
13
|
+
# 改 config.yaml + ./start.sh 即生效);根密钥经 .prod.secrets → run -e 单独注入(不落 env 文件;
|
|
14
|
+
# 首部署选「开启加密」时生成,缺文件 = 明文模式不注入);
|
|
15
|
+
# stdout 走 json-file 轮转 50MB×3(治日志撑盘)。
|
|
16
|
+
# start.sh = 用现有镜像 + 当前 env 文件/配置挂载重建(不 build;改 .prod.docker.env 或
|
|
17
|
+
# $APP_ROOT/config/config.yaml → ./start.sh 即生效)
|
|
18
|
+
# install.sh = build 新镜像后重建(随发布)
|
|
19
|
+
# versionswitch = 换 tag 重建(随版本切换)
|
|
20
|
+
# 三处的 docker run 参数必须逐字一致(端口/-p/env/volume/重启策略),历史三份手拷已收敛到本函数,
|
|
21
|
+
# 改挂载/env 参数只改这里。
|
|
22
|
+
|
|
23
|
+
# ── 兼容旧部署保留的 config.sh(存量升级)──
|
|
24
|
+
# config.sh 是运维文件,顶层 install.sh::lay_out_ops 跨版本保留不覆盖;本文件却随发布刷新。
|
|
25
|
+
# 分层改造(base+overlay,CONFIG_DIR_HOST 挂载)之前完成首部署的服务器,其保留的 config.sh
|
|
26
|
+
# 缺下面两个新变量 → set -u 下刷新后的本文件引用即炸 unbound variable。缺省回填部署位
|
|
27
|
+
# 标准值(运维在 config.sh 的显式定义/export 覆盖仍优先);新变量今后再进 config.sh 时,
|
|
28
|
+
# 若旧部署会缺,同样在此补一行兜底。
|
|
29
|
+
CONFIG_DIR_HOST="${CONFIG_DIR_HOST:-$APP_ROOT/config}"
|
|
30
|
+
CONTAINER_CONFIG_DIR="${CONTAINER_CONFIG_DIR:-/app/config}"
|
|
31
|
+
|
|
32
|
+
# run_container <镜像完整引用,如 <pkg-name>:1.0.0-20261014120000>(镜像名 = 包 name 派生)
|
|
33
|
+
# 幂等:先 stop/rm 同名旧容器再 run。数据/日志不受影响 —— 分别落 $DATA_DIR/$LOG_DIR
|
|
34
|
+
# 宿主目录(bind mount /app/data、/app/logs 双卷),容器重建不动卷。
|
|
35
|
+
# 同名容器被占用/端口被抢由 docker run 自行报错退出。
|
|
36
|
+
run_container() {
|
|
37
|
+
local image_ref="$1"
|
|
38
|
+
[[ -n "$image_ref" ]] || { echo "✗ run_container: 缺镜像引用参数" >&2; return 1; }
|
|
39
|
+
|
|
40
|
+
# env:APP_ROOT/.prod.docker.env 为权威(install.sh 部署时随包拷入;此处兜底告警不阻断)
|
|
41
|
+
local env_flag=""
|
|
42
|
+
if [[ -f "$ENV_FILE" ]]; then
|
|
43
|
+
env_flag="--env-file $ENV_FILE"
|
|
44
|
+
else
|
|
45
|
+
echo "⚠ 缺 $ENV_FILE,用空 env 起容器(仅限调试,密钥将为空)" >&2
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
echo "▶ 重建容器 $CONTAINER_NAME ..."
|
|
49
|
+
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
|
50
|
+
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
|
51
|
+
|
|
52
|
+
# 端口权威 = env 文件的 PORT(config.sh::read_env_port 读,config.sh source 时已赋 PORT/
|
|
53
|
+
# HOST_PORT/CONTAINER_PORT);-e PORT 注入 app 监听,-p 映射同值。
|
|
54
|
+
# DB_DIR/UPLOAD_STORAGE_PATH 随 /app/data、SLOW_SQL_DIR/API_LOG_DIR 随 /app/logs
|
|
55
|
+
# mount 目标锁定,容器内布局固定。
|
|
56
|
+
# 业务配置 = 部署位 $CONFIG_DIR_HOST(只读挂载 /app/config;缺目录 = 部署不完整,
|
|
57
|
+
# 告警并回落镜像内兜底 config,仅限调试)。
|
|
58
|
+
local cfg_flag=""
|
|
59
|
+
if [[ -d "$CONFIG_DIR_HOST" && -f "$CONFIG_DIR_HOST/config.yaml" ]]; then
|
|
60
|
+
cfg_flag="-v $CONFIG_DIR_HOST:$CONTAINER_CONFIG_DIR:ro"
|
|
61
|
+
else
|
|
62
|
+
echo "⚠ 缺 $CONFIG_DIR_HOST/config.yaml,容器用镜像内兜底 config(仅限调试;重跑 ./install.sh 播种部署位配置)" >&2
|
|
63
|
+
fi
|
|
64
|
+
# 根密钥(CONFIG_SECRET_KEY)从 $SECRETS_FILE(= APP_ROOT/.prod.secrets;首部署选「开启加密」
|
|
65
|
+
# 时由 lib-encrypt.sh 在本机生成、600)读取后以 run -e 注入容器。缺文件 = 明文模式
|
|
66
|
+
# (容器不注,应用按 security.encrypt=false 接受明文,仅限明文部署/调试);但部署位
|
|
67
|
+
# config.yaml 带 ENC(...) 而无密钥属部署完整性缺失,直接失败让运维回 install.sh 重新部署。
|
|
68
|
+
local enc_flags=()
|
|
69
|
+
if [[ -f "$SECRETS_FILE" ]]; then
|
|
70
|
+
local secrets_key
|
|
71
|
+
secrets_key="$(sed -nE 's/^CONFIG_SECRET_KEY=//p' "$SECRETS_FILE" | head -1 | sed -E "s/^['\"]?//; s/['\"]?$//")"
|
|
72
|
+
[[ -n "$secrets_key" ]] || { echo "✗ $SECRETS_FILE 无 CONFIG_SECRET_KEY(补齐后重跑)" >&2; return 1; }
|
|
73
|
+
enc_flags=(-e CONFIG_SECRET_KEY="$secrets_key")
|
|
74
|
+
elif grep -q 'ENC(' "$CONFIG_DIR_HOST/config.yaml" 2>/dev/null; then
|
|
75
|
+
echo "✗ 缺 $SECRETS_FILE 但部署位 config.yaml 含 ENC(...) 密文(重跑 ./install.sh <release 包> 重新部署)" >&2
|
|
76
|
+
return 1
|
|
77
|
+
else
|
|
78
|
+
echo "⚠ 明文模式:无 $SECRETS_FILE,容器不注 CONFIG_SECRET_KEY" >&2
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
docker run -d \
|
|
82
|
+
--name "$CONTAINER_NAME" \
|
|
83
|
+
--restart "$RESTART_POLICY" \
|
|
84
|
+
--log-driver json-file \
|
|
85
|
+
--log-opt max-size=50m \
|
|
86
|
+
--log-opt max-file=3 \
|
|
87
|
+
-p "$HOST_PORT:$CONTAINER_PORT" \
|
|
88
|
+
$env_flag \
|
|
89
|
+
$cfg_flag \
|
|
90
|
+
-e NODE_ENV=prod \
|
|
91
|
+
-e PORT="$PORT" \
|
|
92
|
+
${enc_flags[@]+"${enc_flags[@]}"} \
|
|
93
|
+
-e DB_DIR="$CONTAINER_DATA_DIR" \
|
|
94
|
+
-e UPLOAD_STORAGE_PATH="$CONTAINER_DATA_DIR/uploads" \
|
|
95
|
+
-e SLOW_SQL_DIR="/app/logs/slowsql" \
|
|
96
|
+
-e API_LOG_DIR="/app/logs/apilog" \
|
|
97
|
+
-v "$DATA_DIR:$CONTAINER_DATA_DIR" \
|
|
98
|
+
-v "$LOG_DIR:/app/logs" \
|
|
99
|
+
"$image_ref"
|
|
100
|
+
}
|
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# 启动 docker 容器
|
|
2
|
+
# 启动 docker 容器 —— 重建而非 docker start:
|
|
3
|
+
# env 只在容器创建时经 --env-file 灌入容器元数据,docker start 旧容器不重读 env 文件(改了不生效)。
|
|
4
|
+
# 故 start = 停删旧容器 + 按权威 tag 用现有镜像重 run(不 build,秒级)。
|
|
5
|
+
# 运维改 APP_ROOT/.prod.docker.env 或 APP_ROOT/config/config.yaml 后 ./start.sh 即生效,
|
|
6
|
+
# 与 systemd/pm2 改部署位 yaml + restart 同体验(业务配置单一真相源 = 部署位 config.yaml)。
|
|
7
|
+
# 数据安全:$APP_ROOT/data 为宿主 bind mount,容器重建不动数据。
|
|
3
8
|
set -euo pipefail
|
|
4
9
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
10
|
source "$DIR/config.sh"
|
|
11
|
+
source "$DIR/run-container.sh"
|
|
6
12
|
|
|
7
|
-
echo "
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
command -v docker >/dev/null 2>&1 || { echo "✗ docker 不存在(PATH 无 docker)" >&2; exit 1; }
|
|
14
|
+
|
|
15
|
+
# 版本权威 = env 文件的 TAG(部署/install 与版本切换/versionswitch 时机都会对齐写入;
|
|
16
|
+
# versionswitch 切换不改 current/,故不能以 current 包为权威,env TAG 才是运行版本真相)。
|
|
17
|
+
# env 文件缺 TAG 回退 read_tag(读 current/package.json,兜底)。
|
|
18
|
+
read_env_tag() {
|
|
19
|
+
[[ -f "$ENV_FILE" ]] || { printf ''; return; }
|
|
20
|
+
local t
|
|
21
|
+
t="$(sed -nE 's/^TAG=//p' "$ENV_FILE" | head -1 | sed -E "s/^['\"]?//; s/['\"]?$//; s/^[[:space:]]+//; s/[[:space:]]+$//")"
|
|
22
|
+
printf '%s' "$t"
|
|
23
|
+
}
|
|
24
|
+
TAG="$(read_env_tag)"
|
|
25
|
+
[[ -n "$TAG" ]] || TAG="$(read_tag)"
|
|
26
|
+
[[ -n "$TAG" ]] || { echo "✗ 无法定位版本 tag(env 文件无 TAG 且 current/package.json 不可读);先跑 install.sh(部署)或 versionswitch.sh(切换版本)" >&2; exit 1; }
|
|
27
|
+
|
|
28
|
+
IMAGE_REF="$IMAGE:$TAG"
|
|
29
|
+
if ! docker image inspect "$IMAGE_REF" >/dev/null 2>&1; then
|
|
30
|
+
# 回退链:当前运行容器所用镜像(docker rmi 不删被容器引用的 tag,故容器在 → 镜像必在)。
|
|
31
|
+
# 覆盖场景:env TAG 被改脏 / 旧 tag 被 KEEP_VERSIONS 轮转清掉。保启动优先,无容器可回退才 die。
|
|
32
|
+
RUNNING_REF="$(docker inspect --format '{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || true)"
|
|
33
|
+
if [[ -n "$RUNNING_REF" ]] && docker image inspect "$RUNNING_REF" >/dev/null 2>&1; then
|
|
34
|
+
echo "⚠ 镜像 $IMAGE_REF 不存在(TAG 失效),回退用当前运行容器镜像:$RUNNING_REF"
|
|
35
|
+
IMAGE_REF="$RUNNING_REF"
|
|
36
|
+
else
|
|
37
|
+
echo "✗ 镜像 $IMAGE_REF 不存在且无运行容器可回退;先跑 install.sh(部署)或 versionswitch.sh(切换到本机已有 tag)" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
14
40
|
fi
|
|
41
|
+
|
|
42
|
+
run_container "$IMAGE_REF"
|
|
43
|
+
echo "✓ started: $CONTAINER_NAME (镜像 $IMAGE_REF,env 来自 $ENV_FILE)"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* ensure-encrypt —— 部署位配置加密维护器(服务器侧执行,零依赖,每次 install.sh 部署调用)
|
|
4
|
+
*
|
|
5
|
+
* 用法(根密钥经 CONFIG_SECRET_KEY env 传入,base64 的 32 字节):
|
|
6
|
+
* CONFIG_SECRET_KEY=<key> node ensure-encrypt.cjs relock <configDir>
|
|
7
|
+
* node ensure-encrypt.cjs genkey
|
|
8
|
+
*
|
|
9
|
+
* relock(加密维护,ops/lib-encrypt.sh 调用;处理 <configDir>/config.yaml,受控字段清单读
|
|
10
|
+
* <configDir>/config.base.yaml 的 security.encryptedFields,缺省回落内置四项):
|
|
11
|
+
* 1. 校验文件内既有 ENC(...) 密文能用当前密钥解开(GCM 认证)—— 密钥错配/密文损坏
|
|
12
|
+
* fail-fast,防「换密钥后旧密文静默解不开、服务起不来才发现」。
|
|
13
|
+
* 2. 把清单内【非空明文】值就地加密为 ENC(...) 密文(行级改写,注释与其余内容逐字节
|
|
14
|
+
* 保留;空占位('')与已是 ENC(...) 的值原样保留)—— 改明文 + 重跑 ./install.sh
|
|
15
|
+
* 即完成轮换闭环,服务器上不需要手工跑 config-crypt。
|
|
16
|
+
* 3. 把 config.yaml 的 security.encrypt 写为 true(深合并覆盖 base 的 false)。应用防呆
|
|
17
|
+
* 校验随之开启:此后手改明文直接重启会被拦下,提示回 ./install.sh 自动加密。
|
|
18
|
+
*
|
|
19
|
+
* genkey:生成新根密钥(base64 32 字节)打印到 stdout(lib-encrypt.sh init 流程用)。
|
|
20
|
+
*
|
|
21
|
+
* 密文格式 ENC(<b64(iv)>.<b64(authTag)>.<b64(cipher)>),AES-256-GCM,与
|
|
22
|
+
* scripts/config-crypt.cjs 及运行时 src/common/config-crypto.ts 同源
|
|
23
|
+
* (每次加密 IV 随机,同明文密文不同)。
|
|
24
|
+
*/
|
|
25
|
+
'use strict';
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
|
|
30
|
+
const KEY_BYTES = 32;
|
|
31
|
+
// 与 configuration.ts 的 DEFAULT_ENCRYPTED_FIELDS 同步维护
|
|
32
|
+
const DEFAULT_ENCRYPTED_FIELDS = ['jwt.secret', 'admin.password', 'sso.ticketKey', 'appSign.cryptKey'];
|
|
33
|
+
|
|
34
|
+
function fail(msg) {
|
|
35
|
+
console.error(`✗ ${msg}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 零依赖读 config.base.yaml 的 security.encryptedFields(受控清单;清单形态由模板保证:
|
|
40
|
+
* security: 段内缩进的 encryptedFields: + 更深缩进的 "- dot.path" 列表项)。缺文件/缺段/空
|
|
41
|
+
* 清单回落内置默认 —— 与 configuration.ts 的深合并结果同口径。 */
|
|
42
|
+
function readFields(baseFile) {
|
|
43
|
+
try {
|
|
44
|
+
const lines = fs.readFileSync(baseFile, 'utf8').split('\n');
|
|
45
|
+
let inSecurity = false;
|
|
46
|
+
let inFields = false;
|
|
47
|
+
let fieldsIndent = -1;
|
|
48
|
+
const fields = [];
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
const top = /^([A-Za-z_][\w-]*):/.exec(line);
|
|
51
|
+
if (top) { inSecurity = top[1] === 'security'; inFields = false; continue; }
|
|
52
|
+
if (!inSecurity) continue;
|
|
53
|
+
const m = /^(\s+)([A-Za-z_][\w-]*):/.exec(line);
|
|
54
|
+
if (!m) continue;
|
|
55
|
+
if (m[2] === 'encryptedFields') { inFields = true; fieldsIndent = m[1].length; continue; }
|
|
56
|
+
if (inFields) {
|
|
57
|
+
const item = /^(\s*)-\s*(\S+)/.exec(line);
|
|
58
|
+
if (item && item[1].length > fieldsIndent) fields.push(item[2]);
|
|
59
|
+
else inFields = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return fields.length ? fields : DEFAULT_ENCRYPTED_FIELDS;
|
|
63
|
+
} catch {
|
|
64
|
+
return DEFAULT_ENCRYPTED_FIELDS;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 明文 → ENC(...) 密文(与 config-crypt.cjs encrypt 同格式) */
|
|
69
|
+
function encryptValue(plain, key) {
|
|
70
|
+
const iv = crypto.randomBytes(12);
|
|
71
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
72
|
+
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
73
|
+
const tag = cipher.getAuthTag();
|
|
74
|
+
return `ENC(${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** ENC(...) → 明文(校验既有密文用;失败抛错) */
|
|
78
|
+
function decryptValue(value, key) {
|
|
79
|
+
const inner = value.slice('ENC('.length, -1);
|
|
80
|
+
const parts = inner.split('.');
|
|
81
|
+
if (parts.length !== 3) throw new Error('密文格式非法');
|
|
82
|
+
const iv = Buffer.from(parts[0], 'base64');
|
|
83
|
+
const tag = Buffer.from(parts[1], 'base64');
|
|
84
|
+
const cipher = Buffer.from(parts[2], 'base64');
|
|
85
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
|
|
86
|
+
decipher.setAuthTag(tag);
|
|
87
|
+
return Buffer.concat([decipher.update(cipher), decipher.final()]).toString('utf8');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* yaml 行级改写:按缩进栈计算每行 key 的 dot-path,命中清单且为非空明文才替换值,
|
|
92
|
+
* 注释/空白/其余行逐字节保留(不走 load+dump,那会丢光注释)。
|
|
93
|
+
* 返回 [改写后文本, 已加密的 dot-path 列表]。
|
|
94
|
+
*/
|
|
95
|
+
function rewriteYaml(text, fields, key) {
|
|
96
|
+
const lines = text.split('\n');
|
|
97
|
+
const targets = new Set(fields);
|
|
98
|
+
const stack = []; // { indent, key }
|
|
99
|
+
const encryptedPaths = [];
|
|
100
|
+
for (let i = 0; i < lines.length; i++) {
|
|
101
|
+
// 仅捕获普通 key: 行;空行/注释/列表项(- )/flow 续行不动(受控字段均为此形态)
|
|
102
|
+
const m = /^(\s*)([A-Za-z_][\w-]*)\s*:(.*)$/.exec(lines[i]);
|
|
103
|
+
if (!m) continue;
|
|
104
|
+
const [, indentStr, key_, rest] = m;
|
|
105
|
+
const indent = indentStr.length;
|
|
106
|
+
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
107
|
+
const dotted = [...stack.map((s) => s.key), key_].join('.');
|
|
108
|
+
stack.push({ indent, key: key_ });
|
|
109
|
+
if (!targets.has(dotted)) continue;
|
|
110
|
+
|
|
111
|
+
// 取值段:引号包裹取内层(保留引号后尾注),裸值剥行尾注释(v # c 形态);
|
|
112
|
+
// 空占位 / 父节点(值空)/ 已是 ENC(...) 均原样保留
|
|
113
|
+
const v = rest.trim();
|
|
114
|
+
if (!v) continue;
|
|
115
|
+
let plain = '';
|
|
116
|
+
let tail = '';
|
|
117
|
+
const qm = /^'([^']*)'([\s\S]*)$/.exec(v) || /^"([^"]*)"([\s\S]*)$/.exec(v);
|
|
118
|
+
if (qm) {
|
|
119
|
+
plain = qm[1];
|
|
120
|
+
tail = qm[2].trim();
|
|
121
|
+
} else {
|
|
122
|
+
const hash = v.indexOf(' #');
|
|
123
|
+
if (hash >= 0) {
|
|
124
|
+
tail = v.slice(hash).trim();
|
|
125
|
+
plain = v.slice(0, hash).trim();
|
|
126
|
+
} else {
|
|
127
|
+
plain = v;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!plain || (plain.startsWith('ENC(') && plain.endsWith(')'))) continue;
|
|
131
|
+
lines[i] = `${indentStr}${key_}: ${encryptValue(plain, key)}${tail ? ` ${tail}` : ''}`;
|
|
132
|
+
encryptedPaths.push(dotted);
|
|
133
|
+
}
|
|
134
|
+
return [lines.join('\n'), encryptedPaths];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 把 config.yaml 的 security.encrypt 置 true(行级改写):
|
|
139
|
+
* 有 security: 段且带 encrypt: 键 → 原位改值;有段无键 → 段首插入;无段 → 文件末尾追加。
|
|
140
|
+
* 深合并语义:部署位 config.yaml 的 true 覆盖 base 的 false(encryptedFields 仍继承 base)。
|
|
141
|
+
*/
|
|
142
|
+
function setEncryptTrue(text) {
|
|
143
|
+
const lines = text.split('\n');
|
|
144
|
+
let secIdx = -1;
|
|
145
|
+
for (let i = 0; i < lines.length; i++) {
|
|
146
|
+
if (/^security:\s*(#.*)?$/.test(lines[i])) { secIdx = i; break; }
|
|
147
|
+
}
|
|
148
|
+
if (secIdx === -1) {
|
|
149
|
+
const block = ['security:', ' encrypt: true'];
|
|
150
|
+
// 文件尾部空行收敛:避免「末尾多空行 + 追加段」的松散形态
|
|
151
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
152
|
+
return [...lines, ...block, ''].join('\n');
|
|
153
|
+
}
|
|
154
|
+
for (let i = secIdx + 1; i < lines.length; i++) {
|
|
155
|
+
if (/^[^#\s]/.test(lines[i])) break; // 出段(下一个顶层 key)
|
|
156
|
+
const m = /^(\s*)encrypt\s*:\s*(\S+).*$/.exec(lines[i]);
|
|
157
|
+
if (m) {
|
|
158
|
+
lines[i] = `${m[1]}encrypt: true`;
|
|
159
|
+
return lines.join('\n');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// 段内无 encrypt 键:插到 security: 行之后(缩进与段内键对齐 = 2 空格)
|
|
163
|
+
lines.splice(secIdx + 1, 0, ' encrypt: true');
|
|
164
|
+
return lines.join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function main() {
|
|
168
|
+
const [, , cmd, dirArg] = process.argv;
|
|
169
|
+
if (cmd === 'genkey') {
|
|
170
|
+
console.log(crypto.randomBytes(KEY_BYTES).toString('base64'));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (cmd !== 'relock' || !dirArg) {
|
|
174
|
+
fail('用法: CONFIG_SECRET_KEY=<key> node ensure-encrypt.cjs relock <configDir> | node ensure-encrypt.cjs genkey');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rawKey = process.env.CONFIG_SECRET_KEY;
|
|
178
|
+
if (!rawKey) fail('缺少 CONFIG_SECRET_KEY 环境变量(relock 必带根密钥;genkey 可生成)');
|
|
179
|
+
let keyBuf;
|
|
180
|
+
try {
|
|
181
|
+
keyBuf = Buffer.from(rawKey, 'base64');
|
|
182
|
+
} catch {
|
|
183
|
+
fail('密钥不是合法 base64');
|
|
184
|
+
}
|
|
185
|
+
if (keyBuf.length !== KEY_BYTES) {
|
|
186
|
+
fail(`密钥解码后为 ${keyBuf.length} 字节,期望 ${KEY_BYTES} 字节(AES-256)`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const cfgFile = path.join(dirArg, 'config.yaml');
|
|
190
|
+
if (!fs.existsSync(cfgFile)) fail(`缺 ${cfgFile}(部署位配置未播种;先跑顶层 install.sh)`);
|
|
191
|
+
const fields = readFields(path.join(dirArg, 'config.base.yaml'));
|
|
192
|
+
const text = fs.readFileSync(cfgFile, 'utf8');
|
|
193
|
+
|
|
194
|
+
// 1) 既有 ENC(...) 密文全量校验(密钥错配在此暴露,而不是服务起不来才发现)
|
|
195
|
+
for (const enc of text.match(/ENC\([^)]*\)/g) || []) {
|
|
196
|
+
try {
|
|
197
|
+
decryptValue(enc, keyBuf);
|
|
198
|
+
} catch {
|
|
199
|
+
fail(`既有密文 ${enc} 用当前根密钥解不开(密钥错配或密文损坏)。` +
|
|
200
|
+
`核对 $APP_ROOT/.prod.secrets 的 CONFIG_SECRET_KEY 与加密该值的密钥是否同把(排障:scripts/config-crypt.cjs decrypt)`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 2) 清单内非空明文就地加密 → 3) security.encrypt 置 true
|
|
205
|
+
const [rewritten, encryptedPaths] = rewriteYaml(text, fields, keyBuf);
|
|
206
|
+
const finalText = setEncryptTrue(rewritten);
|
|
207
|
+
if (finalText !== text) fs.writeFileSync(cfgFile, finalText);
|
|
208
|
+
|
|
209
|
+
if (encryptedPaths.length) {
|
|
210
|
+
console.log(`✓ 已就地加密 ${encryptedPaths.length} 项:${encryptedPaths.join(', ')}(IV 随机,同明文每次密文不同)`);
|
|
211
|
+
} else {
|
|
212
|
+
console.log('✓ 无非空明文敏感值,密文无需更新');
|
|
213
|
+
}
|
|
214
|
+
console.log('✓ security.encrypt 已置 true(防呆校验开启:手改明文需重跑 ./install.sh 自动加密)');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
main();
|