Files
FastapiAdmin/docker/audit-deploy.md
T
zhangtao 28bf232761 docs(audit): 全栈审计报告(7 份分报告 + 总报告)
由 7 名成员并行审计 + 队长线上实测复核产出,合计约 3,600 行:
- backend/audit-backend.md      后端架构与代码质量(22 条)
- backend/audit-security.md     认证/授权/敏感信息(23 条)
- backend/audit-testing.md      测试与可验证性(含真实命令输出)
- backend/audit-requirements.md 需求覆盖对照(多租户规格错位)
- frontend/audit-frontend.md    前端与移动端工程(32 条)
- frontend/audit-uxui.md        交互与视觉一致性
- docker/audit-deploy.md        容器化与部署链路
- AUDIT-REPORT.md               总报告:止血项、修复路线、待决策事项、对既有结论的修正

各报告均标注级别、文件:行号、影响、建议修法,并单列未验证项与抽样边界。
2026-10-04 04:26:11 +08:00

57 KiB
Raw Blame History

FastapiAdmin 容器化 / 部署链路审计报告

  • 审计任务:t6 — Docker 与部署运维审计(docker/ + deploy.sh + CI)
  • 审计对象:docker/(compose、backend/Dockerfile、nginx、mysql、redis)、deploy.sh、deploy-artifacts.sh、.github/workflows、根 .dockerignore、backend/env/*
  • 工作区:/Users/tao/workspace/FastapiAdmin,分支工作树(含未提交改动)
  • 约束遵守:只读审计。未执行 docker build / docker compose up、未启动任何容器、未安装依赖、未提交 git;本报告是唯一新增文件。
  • 证据标注规则:文件:行号 均为审计时刻工作区实际内容;实测命令输出见第 8 节。

1. 结论速览

1.1 上线前必须修(blocker / high)

ID 级别 一句话问题 关键证据
B1 blocker 生产环境从未注入 SECRET_KEY,JWT 签名 + 数据加密主密钥都是源码里的公开默认值 backend/app/config/setting.py:66;docker/docker-compose.yaml:99-119;backend/env/.env.prod(无该键)
B2 blocker 按当前工作树构建后端镜像必然失败:pip 装依赖时 requirements/base.txt 还没被 COPY 进镜像 docker/backend/Dockerfile:14,17,20;backend/requirements.txt:12-16
B3 blocker 真实 OPENAI_API_KEY 与生产库口令被 COPY ./backend/ . 烤进镜像层 docker/backend/Dockerfile:20;.dockerignore:18(只排除 .env);backend/env/.env.prod
H1 high MySQL(3306)/Redis(6379)/Backend(8001) 全部绑定 0.0.0.0 直接对公网暴露 docker/docker-compose.yaml:22-23,58-59,120-121;docker-compose config 无任何 host_ip
H2 high FORWARDED_ALLOW_IPS=* + 8001 直连 → X-Forwarded-For 可伪造,绕过 IP 登录限流 docker/docker-compose.yaml:117-119;backend/app/config/setting.py:34,74
H3 high 生产 TLS 私钥权限 0666(全局可写),pfx/jks 明文口令随目录一起上传 ls -l docker/nginx/ssl/server.key;docker/nginx/ssl/service.fastapiadmin.com/{nginx,iis,tomcat}/;docker/.env(0644)
H4 high 证书纯手动更换、无自动续期、无到期告警;HTTP 块把 ACME http-01 校验路径也 301 到 HTTPS docker/nginx/nginx.conf:73-77,86-87;证书 notAfter=Mar 6 2027
H5 high 没有任何 CI:.github/workflows/ 是空目录 ls -laR .github/ 仅一个空 workflows/
H6 high 构建上下文约 68 MiB,内含 21 个 ssl 私钥文件与两套前端产物 .dockerignore 未排除 docker/、backend/dist;实测 context≈68.4 MiB
H7 high 后端镜像以 root 运行,无 no-new-privileges / cap_drop / read_only docker/backend/Dockerfile(26 行,无 USER);docker-compose.yaml:89-159(无 security_opt)
H8 high ./deploy.sh 会在全站停机式重建后无论成败都打印"部署完成"并返回 0,且无备份、无回滚 deploy.sh:78,106-113,125-137,141

1.2 可排期优化(medium / low)

ID 级别 一句话问题 关键证据
M1 medium nginx 只等 backend "已启动"而不等健康 → 冷启动窗口 502 docker-compose.yaml:178-180(backend 自身有 healthcheck:138-147)
M2 medium deploy.sh 依赖探测允许独立版 docker-compose,其余流程却硬编码 docker compose deploy.sh:48 vs deploy.sh:72,78,81,92,98,100,106,146
M3 medium MySQL/Redis 口令以明文出现在容器 argv / healthcheck(docker inspect 可见) docker-compose.yaml:33,66,71;docker-compose config 实测展开明文
M4 medium 基础镜像全部浮动 tag,不可复现且 nginx 1.25 已非当前主线 docker-compose.yaml:13,53,164;docker/backend/Dockerfile:2
M5 medium 数据面与前端面同网络、无 stop_grace_period、container_name 固定、mysql/redis 无 CPU 限制 docker-compose.yaml:12,52,90,163,217-219
M6 medium 挂载宿主机 /etc/localtime 冗余且宿主缺文件时会导致容器启动失败 docker-compose.yaml:26(对比 TZ 环境变量 17/57/100/168)
M7 medium 缺 HSTS / CSP / Permissions-Policy,无 OCSP stapling、无 ssl_session_tickets off docker/nginx/nginx.conf:58-63,85-92(grep 全无命中)
M8 medium 全站无 Cache-Control/expires,带 hash 的 immutable 资源每次回源 docker/nginx/nginx.conf(grep 无 Cache-Control/expires);docker/nginx/web/dist/js/index.*.js
M9 medium location /web、/app、/api/v1 前缀匹配过宽;WS 头对所有 API 请求无条件设置 docker/nginx/nginx.conf:103,110,116,131-133
M10 medium nginx 允许 50m 请求体而应用只收 10MB,等于把 40MB 无效流量灌进 Python docker/nginx/nginx.conf:30;backend/app/config/setting.py:223
M11 medium nginx 健康检查只校验配置语法,证书过期/无法服务时仍是 healthy docker-compose.yaml:184
M12 medium 部署过程无落盘审计日志、无版本记录;cleanup() 无条件清空构建缓存 deploy.sh:96-101,116-121,125-137
M13 medium .env 以 source 方式 eval 执行;占位口令只在"首次生成文件"时被拦截 deploy.sh:26-38;docker/.env.example:4,9,12
M14 medium 域名硬编码在 nginx.conf 与 setting.py,README 声称存在 NGINX_SERVER_NAME 变量但实际没有 docker/nginx/nginx.conf:75,83;backend/app/config/setting.py:186;docker/README.md:63
M15 medium 真正带备份/回滚的部署脚本 deploy-artifacts.sh 未纳入版本管理,且硬编码生产 IP + root 登录 git ls-files deploy-artifacts.sh 为空;deploy-artifacts.sh:46-48
L1 low mysql/init 是空目录,但 compose 注释与 README 声称"自动执行 init SQL" docker/docker-compose.yaml:27-28;docker/README.md:17
L2 low docker/README.md 与实际实现严重漂移(含不存在的 CLI 参数、"自动拉取代码"、错误的访问地址) docker/README.md:87,93-103,132-138,219-221 vs deploy.sh:5-6,143-161
L3 low 镜像里带 22 MiB 旧前端产物,且 app.frontend("/") 注册 catch-all,未匹配路径返回 SPA 而非 JSON 404 backend/app/__init__.py:127-129;.dockerignore 未排除 backend/dist
L4 low 镜像内保留大体积 source map(redoc standalone map 2.5 MiB 等) backend/static/swagger/redoc/bundles/redoc.standalone.js.map
L5 low pip 源硬编码清华镜像,无法通过 build ARG 替换 docker/backend/Dockerfile:17
L6 low ENV TZ Asia/Shanghai 使用已废弃的空格语法;Dockerfile 无 HEALTHCHECK docker/backend/Dockerfile:8
L7 low mkdir -p 建数据目录但不修属主(MySQL 需 999:999) deploy.sh:41;docker/README.md:206

2. 上线前必须修(详细)

B1 [blocker] 生产 SECRET_KEY 使用源码内公开默认值 → JWT 可伪造 + 落库密文可解密

证据

  • backend/app/config/setting.py:66:
    SECRET_KEY: str = "fastapiadmin-dev-secret-key-do-not-use-in-production"  # JWT密钥(必须通过环境变量 SECRET_KEY 设置,无默认值)
    
    注释说"无默认值",实际有默认值。
  • 全仓库检索 SECRET_KEY 赋值来源(.venv/、node_modules/ 除外)只命中:测试用的 backend/tests/conftest.py:32 与源码默认值本身。
  • docker/docker-compose.yaml:99-119 的 backend environment 列表里没有 SECRET_KEY,也没有 env_file:。
  • docker/.env.example(26 行)无 SECRET_KEY;backend/env/.env.prod 键清单(实测 awk 抽取)无 SECRET_KEY;backend/env/.env.example:2 只在注释里提到要改 SECRET_KEY。
  • 该密钥还被复用为数据加密主密钥:backend/app/utils/crypto_util.py:47-52 在 DATA_ENCRYPTION_KEY 未配置时用 SECRET_KEY 做 HKDF 派生主密钥,crypto_util.py:52 还兼容 SHA256(SECRET_KEY) 的历史密文。DATA_ENCRYPTION_KEY 同样未在 compose 与 .env.prod 中出现(setting.py:79 默认 None)。

影响:任何读到过源码的人都能签发任意用户(含 admin)的有效 access/refresh token(backend/app/core/security.py:114,139);同时可解密数据库里用 Fernet 加密的存储源口令等敏感数据。

改法

  1. docker/docker-compose.yaml backend 服务增加密钥注入(三选一,推荐 1):
    environment:
      SECRET_KEY: "${SECRET_KEY:?错误: 请设置 SECRET_KEY 环境变量}"
      DATA_ENCRYPTION_KEY: "${DATA_ENCRYPTION_KEY:?错误: 请设置 DATA_ENCRYPTION_KEY 环境变量}"
    
    或在 docker/.env 中定义后由 compose 自动读取,并把两行加入 docker/.env.example。
  2. backend/env/.env.prod 与 backend/env/.env.example 补齐 SECRET_KEY、DATA_ENCRYPTION_KEY 键位(值留空)。
  3. 生成:openssl rand -hex 32。
  4. 加一道启动自检(后端或部署脚本均可):若 SECRET_KEY 等于默认值则拒绝启动。
  5. 轮换顺序:先配 DATA_ENCRYPTION_KEY=旧 SECRET_KEY 的派生等价物或用 DATA_ENCRYPTION_OLD_KEYS 过渡,再换 SECRET_KEY(crypto_util.py:47-62 有旧密钥解密链),避免已落库密文不可解。

B2 [blocker] 当前工作树后端镜像必然构建失败(依赖层顺序错误)

证据

docker/backend/Dockerfile:

14: COPY ./backend/requirements.txt .          # → /home/requirements.txt
17: RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
20: COPY ./backend/ .                          # → /home/requirements/ 在这一层才出现

而工作树 backend/requirements.txt:10-14 已经是拆分后的组合式清单:

-r requirements/base.txt
-r requirements/db/mysql.txt
-r requirements/db/postgres.txt
-r requirements/db/sqlite.txt
-r requirements/storage.txt

(证据时效:该文件在审计期间被其他并行审计成员修改过,行号由 12-16 变为 10-14;上述 -r 引用内容本身未变,已复核。)

backend/requirements/base.txt 等 5 个文件位于 backend/requirements/,而 git ls-files backend/requirements/ 输出为空(该目录尚未提交,git status 显示 ?? backend/requirements/)。

判定:第 17 行执行时 /home 下只有 requirements.txt。pip 对 -r 相对路径的解析基准无论是"当前工作目录 /home"还是"清单文件所在目录 /home",目标都落在 /home/requirements/base.txt —— 该路径在本层尚不存在(第 20 行才 COPY)。因此 docker compose build 与 deploy.sh 的 build_image() 都会以 Could not open requirements file: ... requirements/base.txt 失败。

改法(任选其一)

  • 推荐:把依赖清单目录先 COPY 再安装,并按数据库类型只装所需驱动:
    COPY ./backend/requirements/ /home/requirements/
    RUN pip install --no-cache-dir \
          -r /home/requirements/base.txt \
          -r /home/requirements/db/mysql.txt \
          -r /home/requirements/storage.txt
    
  • 或者把 backend/requirements/ 提交进仓库,并同步更新 requirements.txt 的引用路径。

对照:deploy-artifacts.sh 动态生成的 Dockerfile 已经写对了顺序(先 COPY requirements/ /home/requirements/ 再 pip install -r /home/requirements/base.txt),说明这是仓库内 Dockerfile 未跟上依赖拆分导致的不一致。


B3 [blocker] 真实 OPENAI_API_KEY 与生产库口令被烤进后端镜像

证据

  • docker/backend/Dockerfile:20:COPY ./backend/ . —— 把整个 backend/ 复制进镜像 /home,包含 backend/env/。
  • 根 .dockerignore:18 只有一行 .env;dockerignore 的 .env 只匹配名为 .env 的文件/目录,不匹配 backend/env/.env.prod、backend/env/.env.dev。
  • 实测 backend/env/.env.prod 键值特征(脱敏,仅长度与前 4 字符):OPENAI_API_KEY 长度 35、以 sk-d 开头;DATABASE_PASSWORD 长度 13、REDIS_PASSWORD 长度 13;DATABASE_HOST 为 172.x 内网地址、DATABASE_USER=root。这些都是真实可用凭据,非占位串。
  • 反证团队已知该风险:deploy-artifacts.sh 在构建时用 Python 脚本显式剔除 OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL(脚本内 drop_prefixes),但仓库内的 docker/backend/Dockerfile 没有任何等效处理。
  • 附带:backend/env/.env.prod 还是运行时配置的实际来源(setting.py:16-21 以 ENV_DIR/.env.{ENVIRONMENT} 为 env_file,path_conf.py:4,16 → /home/env/.env.prod),compose 只覆盖了 DB/Redis 相关键,OPENAI_API_KEY 等继续取自镜像内文件。

影响:任何拿到镜像的人(docker save、镜像仓库、构建缓存、离线交付包)都能取走生产大模型 API Key;同理 DATABASE_HOST/USER 泄露内网拓扑。

改法

  1. .dockerignore 增加排除(放在第 18 行附近):
    backend/env/.env
    backend/env/.env.*
    !backend/env/.env.example
    
  2. Dockerfile 改为白名单式 COPY(更彻底):
    COPY ./backend/app ./app
    COPY ./backend/main.py ./main.py
    COPY ./backend/alembic.ini ./alembic.ini
    COPY ./backend/static ./static
    COPY ./backend/sql ./sql
    COPY ./backend/templates ./templates
    COPY ./backend/data ./data
    COPY ./backend/banner.txt ./banner.txt
    
    运行时配置全部由 compose environment / env_file 注入。
  3. 既然密钥已进过镜像层:视为已泄露,上线前轮换 OPENAI_API_KEY、MySQL/Redis 口令,并清理本机悬空构建缓存。

H1 [high] MySQL / Redis / Backend 端口直接对公网暴露

证据

  • docker/docker-compose.yaml:22-23:- "${MYSQL_PORT:-3306}:3306"
  • docker/docker-compose.yaml:58-59:- "${REDIS_PORT:-6379}:6379"
  • docker/docker-compose.yaml:120-121:- "${BACKEND_PORT:-8001}:8001"
  • 三处都没有 127.0.0.1: 前缀;实测 cd docker && docker-compose config 输出的 ports 条目中 host_ip 字段一个都没有(grep -c host_ip = 0),即全部绑定 0.0.0.0。
  • docker/redis/conf/redis.conf(17 行)没有 bind、没有 protected-mode yes,只有 requirepass 由 compose command 注入(docker-compose.yaml:64-66)。
  • docker/README.md:195 自己写着"生产环境建议注释 MySQL/Redis 的 ports 映射",docker/README.md:214 写着"通过防火墙只开放 80/443" —— 但 compose 默认值并未落实。

影响:只要云安全组/主机防火墙放行,3306 与 6379 直接可被爆破与未授权探测;8001 直连会绕过 nginx 的限流、安全响应头、HTTPS 与上传限制,并暴露 /api/v1/docs。

改法

  mysql:
    ports:
      - "127.0.0.1:${MYSQL_PORT:-3306}:3306"   # 仅本机调试;生产建议整段删除
  redis:
    ports:
      - "127.0.0.1:${REDIS_PORT:-6379}:6379"
  backend:
    ports:
      - "127.0.0.1:${BACKEND_PORT:-8001}:8001"

更彻底:删掉 mysql/redis 的 ports:(容器间用 app_network 互通即可),backend 同样删除,只保留 nginx 的 80/443;并在云安全组层面显式拒绝 3306/6379/8001 入方向。 另外在 redis.conf 补 bind 0.0.0.0 + protected-mode yes 只是兜底,真正的收敛靠端口绑定。


H2 [high] FORWARDED_ALLOW_IPS=* 配合 8001 直连 → 可伪造客户端 IP 绕过限流

证据

  • docker/docker-compose.yaml:117-119:
    # 信任反向代理(nginx)传来的 X-Forwarded-Proto/For
    FORWARDED_ALLOW_IPS: "*"
    
  • 结合 H1:backend:8001 绑定 0.0.0.0,攻击者可直连 8001 并自带 X-Forwarded-For: 1.2.3.4。
  • 依赖客户端 IP 的安全控制:backend/app/config/setting.py:73-74(LOGIN_RATE_LIMIT_MAX_ATTEMPTS=10 按 IP 限流)、setting.py:34(TRUSTED_PROXY_HOPS=1)、以及操作日志 / 在线用户记录。backend/env/.env.example 里的 TRUSTED_PROXY_HOPS 注释明确写了"后端直接暴露公网时填 0,防止 X-Forwarded-For 伪造"——但 compose 没有设这个值,且 8001 恰恰是暴露的。

改法

  1. 先按 H1 收敛端口(首要)。
  2. FORWARDED_ALLOW_IPS 改为 nginx 所在的具体网段而非 *,例如固定网络后写 172.18.0.0/16;不要用 *。
  3. 若确实需要保留宿主机直连调试,同时在 compose 给 backend 加 TRUSTED_PROXY_HOPS: "0"。

H3 [high] TLS 私钥权限过大 + pfx/jks 明文口令随目录上传

证据(实测 ls -l)

-rw-r--r--@ docker/.env
-rw-rw-rw-@ docker/nginx/ssl/server.key            # 0666
-rw-rw-rw-@ docker/nginx/ssl/server.pem            # 0666
-rw-rw-rw-@ docker/nginx/ssl/service.fastapiadmin.com/nginx/service.fastapiadmin.com.key
-rw-rw-rw-@ docker/nginx/ssl/service.fastapiadmin.com/nginx/service.fastapiadmin.com.crt
  • docker/nginx/ssl/service.fastapiadmin.com/iis/password.txt、.../tomcat/password.txt 各 6 字节明文口令(用于 pfx/jks 导出)。
  • docker/nginx/ssl/service.fastapiadmin.com.zip 是证书打包文件。
  • docker/.env 权限 0644(全局可读),而 docker/README.md:45 明确要求 chmod 600 docker/.env;deploy.sh:29-33 用 cp .env.example .env 生成文件时也不会改权限。
  • 好消息:证书/私钥/.env 都没有被 git 跟踪(.gitignore 覆盖 docker/nginx/ssl/*、docker/.env;git ls-files docker/ 已确认只有 .gitkeep 与配置模板)。
  • 但 deploy.sh:5-6 的部署模型是"把项目目录整体 scp/FTP 上传服务器",.env、server.key、*.zip、password.txt 会原样落到服务器磁盘,权限一并带过去;同时它们也落在 Docker 构建上下文里(见 H6)。

改法

  1. 立即收紧:chmod 700 docker/nginx/ssl && chmod 600 docker/nginx/ssl/*.key docker/.env;deploy.sh 在 load_env 后加 chmod 600 "${ENV_FILE}" 与 find docker/nginx/ssl -name '*.key' -exec chmod 600 {} +。
  2. 把 service.fastapiadmin.com/(含 iis/tomcat 的 pfx/jks/password.txt)与 *.zip 移出仓库工作目录,只在服务器上保留 nginx 需要的 fullchain.pem + privkey.pem 两份。
  3. 密钥交给 Docker secret / 宿主只读挂载(docker/nginx/ssl 已是 :ro,见 docker-compose.yaml:175,这点是对的),并在 .gitignore 之外加一道 pre-commit 检查防止误传。
  4. 若这些证书曾进入过镜像或构建缓存,按"已泄露"处理。

H4 [high] 证书手动更换、无续期、无到期监控;HTTP 块阻断 ACME http-01

证据

  • docker/nginx/nginx.conf:86-87 固定读取 /etc/nginx/ssl/server.pem 与 /etc/nginx/ssl/server.key;docker-compose.yaml:175 把 ./nginx/ssl 只读挂进容器。
  • 证书实测(openssl x509 -noout -subject -issuer -dates):
    • Subject:CN=service.fastapiadmin.com
    • Issuer:C=PL/O=Asseco Data Systems S.A./CN=Certum DV TLS G2 R39 CA
    • notBefore=Aug 19 14:10:22 2026 GMT,notAfter=Mar 6 14:10:21 2027 GMT
    • SAN:DNS:service.fastapiadmin.com(单域名,无通配符)
    • server.pem 与 CA 交付的 .crt 内容一致(md5 相同),且与 server.key 公钥匹配(pubkey md5 相同)→ 配置与证书是配套的,这点没问题。
  • docker/nginx/nginx.conf:73-77 的 80 端口 server 把所有请求 return 301 https://...,其中没有 location /.well-known/acme-challenge/ 例外 → Let's Encrypt http-01 校验会跟随 301 到 HTTPS(对同名域名通常仍可完成校验,但一旦 server_name 不匹配或走 IP 校验就会失败)。仓库内也没有任何 acme.sh / certbot / systemd timer / cron 配置。

影响:证书 2027-03-06 到期,而链路上没有任何机制会提醒;历史上"到期当天才发现"的概率很高。

改法

  1. docker/nginx/nginx.conf:73-77 的 HTTP server 增加:
    location /.well-known/acme-challenge/ { root /usr/share/nginx/html; allow all; }
    
  2. 用 acme.sh(DNS-01 或 http-01)自动签发与续期,续期钩子: acme.sh --install-cert -d service.fastapiadmin.com --key-file docker/nginx/ssl/server.key --fullchain-file docker/nginx/ssl/server.pem --reloadcmd "docker compose -f docker/docker-compose.yaml exec nginx nginx -s reload"
  3. 加到期检查(cron 每天):
    openssl x509 -in docker/nginx/ssl/server.pem -noout -checkend 1209600 || echo "证书 14 天内到期" | <告警通道>
    
  4. 同步把 nginx -t 之类的健康检查升级为带 HTTPS 探测(见 M11)。

H5 [high] 没有任何 CI

证据:ls -laR .github/ 输出 workflows/ 为空目录(total 0,无任何文件);仓库内也不存在其他 CI 配置(.gitlab-ci.yml、Jenkinsfile、.woodpecker.yml 等均无;find 命中的 yaml 只有 compose 与前端配置)。

影响:没有自动门禁意味着本报告 B2 这类"依赖层顺序写错、镜像根本构建不出来"的问题只能在生产部署时暴露;也没有镜像漏洞扫描、没有配置校验、没有后端测试门禁。

改法:新增 .github/workflows/ci.yml,至少包含

  1. docker compose -f docker/docker-compose.yaml config -q(配置/变量插值校验,需提供 dummy env)
  2. docker build -f docker/backend/Dockerfile .(不 push,仅验证依赖层可解)
  3. ruff check backend + pytest backend/tests
  4. 镜像分层扫描:trivy image --severity HIGH,CRITICAL
  5. 触发条件 on: [push, pull_request],paths 覆盖 docker/**、backend/**、.dockerignore

H6 [high] 构建上下文臃肿且包含私钥(约 68 MiB,21 个密钥文件)

证据(实测)

  • 根 .dockerignore 未排除 docker/、backend/dist、backend/tests、backend/data、.agent-teams、*.zip。
  • 按 .dockerignore 规则静态估算的上下文体积:≈68.4 MiB,主要构成(实测逐个文件统计):
    • backend/data/ip2region/ip2region_v4.xdb 10.61 MiB(运行时需要,保留)
    • docker/nginx/web/dist/** ≈2.55+1.91+1.53+1.38+1.33 MiB… 合计 ≈22 MiB(死重)
    • backend/dist/** ≈22 MiB(死重,见 L3)
    • backend/static/swagger/** 数 MiB(含 .map)
    • docker/nginx/ssl/** 21 个文件(含 server.key、service.fastapiadmin.com/**/*.key、.zip、password.txt)
  • 对比:被正确排除的有 .git(151 MiB)、backend/.venv(231 MiB)、frontend(1.5 GiB)、backend/logs(6.5 MiB)——这部分是好的。

影响:每次构建把 68 MiB 上下文送给构建守护进程(远程 daemon / colima / BuildKit 场景下走网络),其中含生产私钥;COPY ./backend/ . 的改变会让 22 MiB 的 backend/dist 一起进镜像层。

改法:在根 .dockerignore 追加

# 容器资产不进后端构建上下文(含 TLS 私钥)
docker/
# 后端镜像不需要的产物
backend/dist
backend/tests
backend/data/logs
.agent-teams
docker/nginx
*.zip

注意 backend/data/ip2region 必须保留(backend/app/config/path_conf.py:31-32 依赖该库做 IP 归属地查询),所以只排除 backend/data/logs,不要整目录排除 backend/data。 若同时想减小上下文,可把 deploy.sh 改为在 docker/ 目录内构建(build.context: ../ 目前是根目录,这也是私钥进上下文的原因)。


H7 [high] 镜像以 root 运行,缺少容器安全基线

证据:docker/backend/Dockerfile 全文 26 行,没有 USER 指令;没有 PYTHONUNBUFFERED / PYTHONDONTWRITEBYTECODE;没有进程管理器(tini 等)。docker/docker-compose.yaml 四个服务(9-219 行)都没有 security_opt、cap_drop、read_only、user:。

影响:容器逃逸/依赖链漏洞时以 root 权限作业;/home 可写导致代码被覆盖;缺 no-new-privileges 让 setuid 提权路径保持开放。

改法

ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 LANG=C.UTF-8
RUN useradd -m -u 10001 app && mkdir -p /home/logs /home/static/upload && chown -R app:app /home
USER app

compose 侧:

    init: true
    security_opt: ["no-new-privileges:true"]
    cap_drop: ["ALL"]
    read_only: true
    tmpfs: ["/tmp"]

配套注意:docker-compose.yaml:130 的 ./backend/static/upload:/home/static/upload 挂载需要把宿主目录属主改成 uid 10001,否则上传会失败(这是改非 root 后最容易踩的坑)。


H8 [high] ./deploy.sh 失败也会报"部署完成"并返回 0;且无备份、无回滚、整站重建

证据(均在 deploy.sh)

  • 无条件全量重建:第 78 行 docker compose up -d --force-recreate(未指定服务名)→ MySQL、Redis、nginx 一并被销毁重建,每次部署都是一次全站停机。
  • 健康门禁形同虚设:
    • 第 80-85 行等待 MySQL healthy 循环 30 次,超时后不报错,直接继续。
    • 第 106-113 行 verify() 无论成败,最后一条语句都是 log(echo -e),必然返回 0;第 113 行 $ok && log ... || log ... 两个分支都是成功退出。
    • 第 125-137 行 full_deploy 不管 verify 结果继续执行 show_logs、cleanup,最后打印 ========== 🎉 部署完成 ==========。
    • 结论:4 个容器全部 down 时,./deploy.sh; echo $? 仍是 0。
  • 无备份无回滚:整脚本没有 mysqldump、没有静态目录备份、没有镜像版本留存;docker-compose.yaml:96 的 image: "backend:${BACKEND_IMAGE_TAG:-3.0.0}" 是固定 tag,重新构建即覆盖上一版 → 没有可回退的镜像。
  • Ctrl-C 变成全站下线:第 141 行 trap 'stop_service; exit 130' INT TERM,而 stop_service(90-94)执行 docker compose down。
  • 清理激进:第 118-119 行 docker image prune -f + docker builder prune -f 会清空构建缓存,下一次构建从零开始(且 builder prune -f 在 BuildKit 下会丢弃所有缓存)。

改法(可直接复用 deploy-artifacts.sh 的成熟做法)

  1. 部署前备份(必做):
    mkdir -p backups
    docker compose exec -T mysql sh -c 'exec mysqldump --single-transaction --routines --triggers -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' | gzip > "backups/db-$(date +%F-%H%M%S).sql.gz"
    tar -czf "backups/nginx-$(date +%F-%H%M%S).tgz" docker/nginx/web/dist docker/nginx/app docker/nginx/docs
    cp docker/.env "backups/env-$(date +%F-%H%M%S)"
    
  2. 镜像版本化:构建后 docker tag backend:3.0.0 "backend:$(date +%Y%m%d-%H%M%S)",并把当前在跑的 tag 固定为 backend:prev(deploy-artifacts.sh 的 _rollback_image 已实现)。
  3. 只重建变化的服务:docker compose up -d --no-deps --force-recreate backend,不要动 mysql/redis/nginx。
  4. 健康门禁:轮询 docker inspect --format '{{.State.Health.Status}}' backend 直到 healthy,超时即 exit 1 并自动回滚到 backend:prev。
  5. verify() 必须能失败:改为累积状态并在末尾 [ "$ok" = true ] || exit 1,且增加真实 HTTP 探测(curl -fsS https://域名/api/v1/monitor/health/check)。
  6. 改掉 trap:trap 'log "中断,未做任何回滚" WARN; exit 130' INT TERM,不要在中断时 down。
  7. 清理改为显式命令:cleanup 只在 ./deploy.sh clean 时调用,且去掉 docker builder prune。

3. 分项审计明细

3.1 docker-compose(docker/docker-compose.yaml,219 行)

审计项 现状 结论
语法与变量校验 docker-compose config 退出码 0(实测,见第 8 节) ✅ 通过;${VAR:?} 必填校验生效(18,21,66 行,缺变量会直接报错并给出中文提示)
端口暴露 mysql 3306 / redis 6379 / backend 8001 全部 0.0.0.0(22-23,58-59,120-121) ❌ H1
健康检查 四个服务都有 healthcheck(31-37,69-75,138-147,183-187) ⚠️ 覆盖到位,但 mysql/redis 口令进 argv(M3),nginx 只 nginx -t(M11)
依赖顺序 nginx → backend 用 service_started(178-180);backend → mysql/redis 用 service_healthy(131-135) ⚠️ backend 的两条依赖正确,nginx 这条应改 service_healthy(M1)
restart 策略 四服务均 unless-stopped(14,54,97,165) ✅
资源限制 backend 1 CPU/1 GB,nginx 0.5 CPU/256 MB,mysql 1 GB,redis 512 MB(43-48,81-86,153-159,193-199) ⚠️ mysql/redis 无 CPU 上限;compose v2 会尊重 deploy.resources.limits,但 reservations 在非 swarm 下被忽略
日志滚动 四服务均 json-file + max-size: 10m + max-file: 3(38-42,76-80,148-152,188-192) ✅ 与 docker/README.md:164 一致
数据卷与权限 mysql_data/redis_data 用 driver_opts bind 到 ./mysql/data、./redis/data(202-214);实测 config 解析为绝对路径 /Users/tao/.../docker/mysql/data ✅ 结构正确;⚠️ 属主未初始化(README:206 需手工 chown 999:999,L7);.gitkeep 占位说明目录已在库中(docker/mysql/data/.gitkeep)
时区挂载 /etc/localtime:/etc/localtime:ro(26) ⚠️ 与 TZ 环境变量重复,宿主缺文件会创建目录导致启动失败(M6)
网络隔离 四个服务同一 app_network bridge(217-219) ⚠️ mysql/redis 与 nginx 同网,缺前后端分层(M5)
生产/开发分离 仅靠 DEPLOY_ENV 注入 backend 的 ENVIRONMENT(95,102);开发热更新挂载已注释(123-127);无 profile、无独立 override 文件 ⚠️ 分离靠一个变量,无 docker-compose.override.yaml/profiles 机制(M4/M5 一并改造)
镜像 tag mysql:8.0、redis:7-alpine、nginx:1.25-alpine、python:3.12-slim 均浮动(13,53,164;Dockerfile:2) ❌ M4
平台 四服务均强制 platform: linux/amd64(15,55,98,166) ℹ️ 生产若为 x86_64 无影响;在 ARM 宿主上会走 qemu 模拟(性能差、mysql 可能起不来),换 ARM 机器时需删掉
上传目录 ./backend/static/upload:/home/static/upload(130) ✅ 必要(setting.py:211 UPLOAD_FILE_PATH=Path("static/upload"),容器 CWD 为 /home,路径对得上);注意这是 docker/backend/static/upload,由 deploy.sh:41 创建

3.2 Dockerfile 与 .dockerignore

docker/backend/Dockerfile(26 行)逐项:

审计项 结论
多阶段构建 ❌ 单阶段。Python 场景收益有限,不是必须修项;但见下"体积"
基础镜像固定版本 ❌ python:3.12-slim(第 2 行)浮动 tag,无 digest(M4)
root 运行 ❌ 无 USER(H7)
依赖安装与缓存层 ⚠️ 思路正确(先 COPY 清单再装,第 13-17 行),但层顺序错误导致构建失败(B2);且 COPY ./backend/ . 会把 22 MiB 的 backend/dist 一起进层(L3)
pip 源可替换性 ❌ 硬编码 -i https://pypi.tuna.tsinghua.edu.cn/simple(第 17 行),无 ARG(L5)
镜像体积 ⚠️ --no-cache-dir 已用;但包含 backend/dist(22 MiB)、backend/static/swagger/**/*.map、backend/tests、backend/sql;且 pip install -r requirements.txt 装全部三种数据库驱动 + 全部三种对象存储 SDK(backend/requirements.txt),而生产只用 MySQL(crypto/paramiko/boto3/esdk-obs/aliyun/cos 体量可观)
安全基线 ❌ 无 HEALTHCHECK(第 25 行只有 CMD),无 --no-new-privileges 等(配合 compose,H7)
时区/环境 ⚠️ 第 8 行 ENV TZ Asia/Shanghai 用空格分隔(旧式语法,可用但不推荐);缺 PYTHONUNBUFFERED
端口/启动 ✅ EXPOSE 8001、CMD ["python","main.py","run","--env=prod"] 为 exec 形式(正确)

.dockerignore(58 行)逐项:已正确排除 .venv(17)、.git(31)、logs(36)、*.log(37)、frontend(44)、backend/static/upload(58)、各类缓存(11-13,7-9)。 应排除而未排除:backend/env/.env.prod/.env.dev(B3)、docker/(含私钥,H6)、backend/dist(L3/L4)、backend/tests、.agent-teams、docker/nginx/ssl/**、*.zip。 注意不要排除:backend/data/ip2region/**(path_conf.py:31-32 运行时依赖)、backend/static/swagger/** 的 js/css(setting.py:228-230 的 Swagger/ReDoc 资源)、backend/sql/** 与 backend/templates/**(初始化种子与代码生成模板)。

3.3 nginx(docker/nginx/nginx.conf,142 行)

审计项 结论
反代路径 ✅ 正确。location /api/v1 { proxy_pass http://backend:8001; }(116-128)不带 URI 部分,原样透传 /api/v1/...;FastAPI 侧 root_path="/api/v1"(setting.py:311)会把 scope["root_path"] 剥掉再匹配路由(fastapi/applications.py:1160-1163 → starlette/_utils.py:90-98 → starlette/routing.py:255),所以后端内部按 /system/...、/monitor/... 注册也能命中。compose 健康检查用的 /api/v1/monitor/health/check(docker-compose.yaml:142)与此一致。
上传大小限制 ⚠️ 仅 http 级 client_max_body_size 50m(30 行),与应用 MAX_FILE_SIZE=10MB(setting.py:223)不匹配(M10)
gzip ⚠️ 已开启(42-56),级别 6;缺 gzip_static、缺 application/wasm,字体已压缩无妨(M8 一并优化)
SSL 证书处理 ⚠️ 证书与私钥匹配、未过期(实测),但无 OCSP stapling、无 ssl_session_tickets off、无续期机制(H4/M7)
安全响应头 ⚠️ 有 X-Frame-Options/X-Content-Type-Options/X-XSS-Protection/Referrer-Policy(60-63);缺 HSTS、CSP、Permissions-Policy,且 X-XSS-Protection 已废弃(M7)。因 location 块内未再定义 add_header,http 级头可正常继承到各 location。
SPA history 回退 ✅ /(99)、/web(105)、/app(112)都有 try_files ... /index.html;deploy-artifacts.sh 的注释也记录了"少一层就 500 内部重定向死循环"的坑
限流 ✅ api_limit 30r/s burst=60 nodelay + conn_limit 100(67-69,117-118)只作用于 /api/v1
日志 ⚠️ access_log ... buffer=32k flush=5s(39);第 8 行注释称"错误日志输出到 stdout",第 9 行实际写 /var/log/nginx/error.log——官方镜像里该路径是 /dev/stderr 的软链,因此效果上仍进容器日志,但注释与代码不一致,且 buffer 会丢失崩溃前最后 5 秒
匹配过宽 ❌ location /web(103)会匹配 /webxxx;/app(110)、/api/v1(116)同理(M9)
WebSocket ⚠️ 对所有 /api/v1 请求无条件设置 Connection "upgrade"(131-133),普通 HTTP 请求也带升级头(M9)
文档暴露 ⚠️ FASTAPI_CONFIG 关闭了内置 docs(setting.py:309-310 docs_url/redoc_url=None),但 app/__init__.py:118-119 的 register_docs(app) 无条件注册自定义 /docs、/redoc。经上面的 root_path 剥离规则,外部可通过 https://域名/api/v1/docs 访问 Swagger UI,而 nginx.conf:116 对 /api/v1 不做任何限制。建议:nginx 层显式 `location ~ ^/api/v1/(docs
静态资源缓存 ❌ 无任何 Cache-Control/expires(全文件 grep 无命中),而 docker/nginx/web/dist/js/ 下是带内容 hash 的 immutable 资源(M8)
error_page ✅ 500 502 503 504 → /50x.html(137-140),路径落在镜像自带的 /usr/share/nginx/html/50x.html(未被挂载覆盖,因为只挂了子目录)

3.4 deploy.sh(161 行)

见 H8(成功误报/无备份/全站重建/trap)、M2(compose 命令不一致)、M12(无可观测性)、M13(.env eval 与占位口令)、L7(属主)。补充:

  • ✅ 优点:set -euo pipefail(第 2 行);彩色分级日志(12-22);check_deps 建目录 + 检查命令(40-55);check_code 校验关键文件(57-67);cleanup 注释里明确写了"-a 会删掉服务器上其他项目的未使用镜像,慎用"(117)——这条自我约束是对的。
  • ⚠️ --build-frontend / --skip-frontend 等 README 承诺的参数在 case 里没有分支(143-161),会落到 *) full_deploy "$@"(160),被静默忽略并执行完整部署。
  • ⚠️ 幂等性:可重复执行,但每次都会重建全部容器(78 行),且不校验当前是否已经有更新版本在跑。
  • ⚠️ 无失败回滚:build_image(69-74)失败会 exit 1(好);但 start_service(76-88)失败后的处理路径缺失健康门禁(坏)。

3.5 deploy-artifacts.sh(未纳入版本管理,但是更成熟的路径)

  • ✅ 设计明显优于 deploy.sh:本地构建成品 → 服务器不跑构建;push_dir 先解包到 /tmp/fa-stage 校验 index.html 与非空 → 备份旧目录 → 原子替换 → chown(push_dir 函数体);后端镜像 docker save | gzip | ssh docker load;切换前 docker tag backend:3.0.0 backend:prev 保留回滚点;rollback web|app|backend 与 prune 轮转(备份每标签 5 份、镜像每类 2 份);verify 探测公网四端 HTTP 200;构建时剔除 OPENAI_* 并把数据库/Redis 地址强制回 localhost(避免把开发凭据烤进镜像——与 B3 正好相反,做得对)。
  • ❌ 问题:
    1. 未提交(git ls-files deploy-artifacts.sh 为空,git status 显示 ??)→ 真正的生产部署脚本会随工作区清理而丢失,团队其他人无法复现部署。
    2. 硬编码生产 IP 与 root 登录:SERVER="${FA_SERVER:-8.137.99.5}"、ssh_do ... "root@${SERVER}"、固定 KEY="$HOME/.ssh/id_ed25519"(脚本 46-53 行附近)→ 应改为必填环境变量 + 专用部署用户(sudo 只放开 docker)。
    3. 只有一个 backend:prev 回滚点,连续两次部署后回滚能力退化。
    4. 无数据库备份/回滚(只有静态目录与镜像),schema 变更仍不可逆。
    5. 备份是 cp -a 全量复制,无校验和校验(tar | ssh 中断可能得到不完整内容,虽 set -e 能兜住大部分)。

3.6 CI

\.github/workflows/ 为空 → 无 CI(H5)。这直接影响本轮审计的可信度:没有任何自动步骤会在合并前发现 B2 这种"镜像构建必然失败"的问题。

3.7 生产环境变量清单(compose 未注入 → 落到默认值)

变量 compose 是否注入 默认值 / 实际来源 风险
SECRET_KEY ❌ setting.py:66 公开默认值 blocker(B1)
DATA_ENCRYPTION_KEY ❌ None → 由 SECRET_KEY HKDF 派生(crypto_util.py:49-52) blocker(B1)
OPENAI_API_KEY ❌ 镜像内 backend/env/.env.prod(真实 key) blocker(B3)
PROD_CORS_ORIGINS ❌ 空 → ALLOW_ORIGINS 返回 ["*"](setting.py:243-248),且 ALLOW_CREDENTIALS=True(第 60 行) high(同源部署下影响有限,但属于错误的生产配置)
SCHEDULER_ALLOW_CODE_EXEC ❌ True(setting.py:127),注释写着"生产环境强烈建议设为 False" high(定时任务可执行用户提交的代码块)
ALLOWED_HOSTS ❌ 硬编码 service.fastapiadmin.com 等(setting.py:186),换域名即全站 400 medium(M14)
TRUSTED_PROXY_HOPS ❌ 1(setting.py:34) medium(H2)
DEMO_ENABLE ❌ .env.prod 设 True medium
WORKERS ❌ 1(setting.py:33) medium(单 worker,注意注释提示 >1 时需确认 Redis jobstore 去重)
DATABASE_* / REDIS_* ✅ compose environment(104-114)优先于 env_file ✅ 正确

建议:新增 docker/.env.example 中的 SECRET_KEY、DATA_ENCRYPTION_KEY、PROD_CORS_ORIGINS、SCHEDULER_ALLOW_CODE_EXEC、TRUSTED_PROXY_HOPS 键位,并在 compose 中用 ${VAR:?} 强制必填,让"忘记配置"变成启动即失败而不是静默降级。

3.8 文档一致性(docker/README.md)

README 声明 实际 影响
:93-103 支持 --build-frontend/--skip-frontend、BUILD_WEB=true deploy.sh:143-161 无对应分支,未知参数被 *) 吞掉执行完整部署 ⚠️ 误导,运维会以为跳过了前端构建
:63 NGINX_SERVER_NAME 变量(默认 service.fastapiadmin.com) nginx.conf 与 compose 中都不存在该变量(grep 无命中),域名在 nginx.conf:75,83 硬编码 ⚠️ 换域名时按文档改 .env 无效
:87 "停止旧容器 → 更新代码 → 构建镜像" deploy.sh:5-6 明确不再拉取代码 ⚠️
:219-221 "./deploy.sh 自动拉取最新代码" 同上是错的 ⚠️ 会造成"部署了但代码没更新"的误判
:132-135 Swagger 在 https://域名/docs、健康检查在 /health / 被 nginx.conf:96-100 指向 VitePress 官网;Swagger 实际在 /api/v1/docs;健康检查实际是 /api/v1/monitor/health/check ⚠️ 验收地址全错
:138 "docs 和 app 默认不参与部署,需取消注释启用" docker-compose.yaml:176-177 与 nginx.conf:110 都是启用状态 ⚠️ 与实际镜像挂载不一致
:136 默认账号 admin/123456 需与种子数据核对(backend/sql/sys_user.json),属后端范围 ℹ️ 生产必须改
:211-215 安全建议(改默认密码、限制端口) 仅文档,compose 默认值未落实(H1) ⚠️ 建议改成默认安全

3.9 值得肯定的部分(避免只报问题)

  1. docker-compose config 通过,必填变量用 ${VAR:?错误: ...} 表达,缺变量即报错并给中文提示(18,21,66 行)。
  2. 四个服务都有 healthcheck,backend→mysql/redis 使用 service_healthy,比多数项目的 service_started 更严谨(131-135)。
  3. 日志滚动、内存/CPU 限制、时区、上传目录持久化(130 行注释点明了"不挂载会丢用户上传文件")都想到了。
  4. .gitignore 正确排除了 docker/.env、docker/nginx/ssl/*、三个前端 dist,实测 git ls-files docker/ 无任何密钥入库。
  5. nginx proxy_pass 与后端 root_path 的配合是对的(3.3 已证明)。
  6. deploy-artifacts.sh 的备份→原子替换→回滚→轮转链路设计完整,且主动剥离 OPENAI_*,说明团队已识别部分风险——只是没回流到 docker/backend/Dockerfile。
  7. 证书与私钥配套、未过期(实测),ssl/ 以 :ro 挂载(175 行)。

4. 从零到上线部署步骤草案(含证书与备份)

前提:先修完第 2 节的 B1/B2/B3 + H1/H8。以下命令在服务器上以部署用户执行,$DOMAIN = 实际域名。

阶段 0:服务器与网络前置

  1. 服务器:≥4 vCPU / 8 GB 内存 / 100 GB 磁盘(compose 限制合计约 2.8 GB,留出 MySQL 数据增长与备份空间)。
  2. 安装 Docker Engine ≥ 24 与 Compose v2 插件(docker compose version 必须成功;不要只装独立版 docker-compose)。
  3. 云安全组 / 主机防火墙:入方向只放行 80、443 与 SSH;3306、6379、8001 一律拒绝。
  4. 域名 A 记录指向服务器公网 IP,确认备案/HTTPS 可用。

阶段 1:代码与权限

sudo mkdir -p /home/FastapiAdmin && sudo chown "$USER" /home/FastapiAdmin
# 上传代码(scp / git)
cd /home/FastapiAdmin
chmod 700 docker/nginx/ssl
find docker/nginx/ssl -name '*.key' -exec chmod 600 {} +

阶段 2:生成密钥与 .env

cp docker/.env.example docker/.env
SECRET_KEY=$(openssl rand -hex 32)
DATA_ENCRYPTION_KEY=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' )
MYSQL_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' )
REDIS_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' )
# 写入 docker/.env(含 SECRET_KEY / DATA_ENCRYPTION_KEY / PROD_CORS_ORIGINS / SCHEDULER_ALLOW_CODE_EXEC=false)
# 用编辑器填写,避免命令历史留痕:
${EDITOR:-vi} docker/.env
chmod 600 docker/.env

必须显式确认的键(对照 3.7):SECRET_KEY、DATA_ENCRYPTION_KEY、MYSQL_ROOT_PASSWORD、MYSQL_PASSWORD、REDIS_PASSWORD、DEPLOY_ENV=prod、SCHEDULER_ALLOW_CODE_EXEC=false、PROD_CORS_ORIGINS=https://$DOMAIN。

阶段 3:配置校验(不启动容器)

cd docker
docker compose config -q && echo "compose OK"
# 显式确认没有以占位口令通过
grep -qE 'your_(mysql|redis)' .env && { echo "❌ 仍是占位口令"; exit 1; } || echo ".env OK"
docker compose config | grep -E 'host_ip|published'   # 人工确认没有 0.0.0.0 的 3306/6379/8001

阶段 4:构建(带版本 tag 与回滚点)

cd docker
export DOCKER_BUILDKIT=1
TAG="$(date +%Y%m%d-%H%M%S)"
docker compose build backend                 # 修完 B2 后必须成功
docker tag backend:3.0.0 "backend:${TAG}"
docker tag backend:"${TAG}" backend:prev     # 建立回滚点
docker images 'backend:*'

阶段 5:证书

# 方案 A:CA 签发
cp /path/to/fullchain.pem docker/nginx/ssl/server.pem
cp /path/to/privkey.pem   docker/nginx/ssl/server.key
chmod 600 docker/nginx/ssl/server.{pem,key}

# 方案 B:acme.sh 自动签发 + 自动续期(推荐)
curl https://get.acme.sh | sh -s email=ops@example.com
~/.acme.sh/acme.sh --issue -d "$DOMAIN" --standalone        # 需 80 端口空闲
~/.acme.sh/acme.sh --install-cert -d "$DOMAIN" \
  --key-file       /home/FastapiAdmin/docker/nginx/ssl/server.key \
  --fullchain-file /home/FastapiAdmin/docker/nginx/ssl/server.pem \
  --reloadcmd      "cd /home/FastapiAdmin/docker && docker compose exec nginx nginx -s reload"

# 到期巡检(cron 每天 09:00)
# 0 9 * * * openssl x509 -in /home/FastapiAdmin/docker/nginx/ssl/server.pem -noout -checkend 1209600 \
#   || curl -fsS "https://<告警 webhook>" -d "证书 14 天内到期"

(同时按 H4 在 nginx.conf 的 80 端口 server 里加 location /.well-known/acme-challenge/ 例外。)

阶段 6:分层启动(不要一把梭)

cd /home/FastapiAdmin/docker
docker compose up -d mysql redis
until [ "$(docker inspect -f '{{.State.Health.Status}}' mysql)" = healthy ] \
   && [ "$(docker inspect -f '{{.State.Health.Status}}' redis)" = healthy ]; do sleep 3; done

docker compose up -d backend
until [ "$(docker inspect -f '{{.State.Health.Status}}' backend)" = healthy ]; do sleep 5; done

docker compose up -d nginx
docker compose ps

首次启动时后端会自动完成建表与种子数据(backend/app/__init__.py:30 → backend/app/scripts/initialize.py:50-86:空库 create_all + stamp head,非空库 alembic upgrade head)。 docker/mysql/init 目录为空(L1),不要期待 MySQL 容器初始化任何 SQL;若需手工导入 backend/sql/*.json 之外的 SQL,放在这里或走应用初始化。

阶段 7:备份(上线前先建好,别等出事)

mkdir -p /home/FastapiAdmin/backups
cat > /home/FastapiAdmin/backup.sh <<'EOF'
#!/bin/bash
set -euo pipefail
cd /home/FastapiAdmin/docker
set -a; source .env; set +a
STAMP=$(date +%Y%m%d-%H%M%S)
OUT=/home/FastapiAdmin/backups

docker compose exec -T mysql sh -c \
  'exec mysqldump --single-transaction --routines --triggers --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' \
  | gzip -1 > "$OUT/db-$STAMP.sql.gz"

tar -czf "$OUT/nginx-$STAMP.tgz" nginx/web/dist nginx/app nginx/docs nginx/nginx.conf
cp .env "$OUT/env-$STAMP"
cp -a redis/data "$OUT/redis-$STAMP" 2>/dev/null || true

# 保留最近 14 天
find "$OUT" -maxdepth 1 -name 'db-*'   -mtime +14 -delete
find "$OUT" -maxdepth 1 -name 'nginx-*' -mtime +14 -delete
find "$OUT" -maxdepth 1 -name 'redis-*' -mtime +14 -delete
echo "backup done: $STAMP"
EOF
chmod +x /home/FastapiAdmin/backup.sh
/home/FastapiAdmin/backup.sh                # 立即跑一次验证可执行

# cron:每天 02:30
# 30 2 * * * /home/FastapiAdmin/backup.sh >> /home/FastapiAdmin/backups/cron.log 2>&1

恢复演练(每季度做一次,否则备份等于没有):

# 1) 停后端(避免写入竞争),保留 MySQL
docker compose stop backend
# 2) 恢复
gunzip -c /home/FastapiAdmin/backups/db-<STAMP>.sql.gz | \
  docker compose exec -T mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"'
# 3) 起后端并验证
docker compose start backend
docker compose exec -T backend python -c "import urllib.request;urllib.request.urlopen('http://localhost:8001/api/v1/monitor/health/check',timeout=5);print('health OK')"

阶段 8:上线验收(每条都要有明确通过标准)

D=https://$DOMAIN
curl -sS -o /dev/null -w 'root      %{http_code}\n' "$D/"
curl -sS -o /dev/null -w 'web       %{http_code}\n' "$D/web/"
curl -sS -o /dev/null -w 'app       %{http_code}\n' "$D/app/"
curl -sS -o /dev/null -w 'apihealth %{http_code}\n' "$D/api/v1/monitor/health/check"
curl -sS -o /dev/null -w 'swagger   %{http_code}\n' "$D/api/v1/docs"   # 期望 404/403,若 200 则按 3.3 在 nginx 封掉
curl -sSI "$D/" | grep -iE 'strict-transport-security|content-security-policy'  # 期望有命中(修完 M7 后)
echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -dates
docker compose ps          # 期望四服务 Up (healthy)
# 端口暴露复核(应从外部超时/拒绝)
nc -z -w3 <公网IP> 3306; nc -z -w3 <公网IP> 6379; nc -z -w3 <公网IP> 8001

阶段 9:日常发布与回滚

# 发布(推荐用 deploy-artifacts.sh,先把它提交进仓库并按 M15 去掉硬编码 IP)
./deploy-artifacts.sh web
./deploy-artifacts.sh app
./deploy-artifacts.sh backend      # 内部已含备份点/镜像 tag/健康轮询

# 回滚
./deploy-artifacts.sh backups
./deploy-artifacts.sh rollback web
./deploy-artifacts.sh rollback app
./deploy-artifacts.sh rollback backend

⚠️ 回滚边界:镜像回滚不会反向执行数据库迁移。若某次发布包含 schema 变更(alembic upgrade),回滚必须同时恢复阶段 7 的数据库备份,并把后端一并停掉避免双写;上线含 schema 变更的版本前务必手工跑一次 backup.sh。

阶段 10:可观测性最小集

  • 容器健康:docker compose ps + docker events 接告警(restart 事件)。
  • 磁盘:df -h 对 / 与数据卷目录做阈值告警(MySQL 数据 + 日志 + 备份三处)。
  • 证书:阶段 5 的 -checkend 巡检。
  • 应用:nginx access/error 日志(docker compose logs nginx)5xx 比例;后端日志在 backend/logs(已挂载?当前 compose 未挂载 backend/logs,容器内日志文件随容器销毁而丢失,只有 stdout 被 json-file 驱动轮转保留 —— 如需长期留存建议加挂载或接日志采集)。

5. 建议的处理顺序(给排期用)

  1. 当天(不改业务代码即可完成):B1 注入并轮换密钥 → B3 排除/轮换泄露凭据 → H3 收紧权限 → H1 收敛端口。
  2. 本周:B2 修 Dockerfile 层顺序(否则任何人都无法构建镜像)→ H8 给 deploy.sh 加备份/健康门禁/回滚 → H5 建最小 CI(compose config + docker build + pytest)。
  3. 两周内:H4 证书自动化与到期告警 → H6 精简构建上下文 → H7 非 root + 容器安全基线 → M1/M3/M4/M11。
  4. 可排期:M7/M8/M9/M10 的 nginx 加固与性能项 → M2/M12/M13/M15 部署脚本工程化 → L1/L2 文档与注释纠偏。

6. 与其它审计角色的边界

本报告只覆盖容器与部署链路。以下发现虽在部署过程中被确认,但根因属后端代码/配置域,建议由对应负责人并入主清单:

  • setting.py:127 SCHEDULER_ALLOW_CODE_EXEC=True(远程代码执行面)与 setting.py:243-248 生产 CORS 回落 ["*"];
  • setting.py:66 注释与实现不符(注释称必填、实现有默认值),以及 backend/tests/conftest.py:32 用环境变量兜底证明"必填"是设计意图;
  • app/__init__.py:118-119 无条件注册 /docs、/redoc(生产是否应暴露需产品决策);
  • backend/app/__init__.py:127-129 app.frontend("/") 的 catch-all 与 JSON 404 语义冲突。

7. 复现本审计的方法

cd /Users/tao/workspace/FastapiAdmin

# 组件版本
docker version            # Client 29.8.0 (context: colima) / Server 29.5.2
docker compose version    # ❗ 本机报 "unknown command: docker compose"
docker-compose --version  # Docker Compose version 5.5.1(homebrew 独立版)
docker buildx version     # ❗ 本机报 "unknown command: docker buildx"

# compose 静态校验(未启动容器)
cd docker && docker-compose config ; echo "exit=$?"    # exit=0
docker-compose config | grep -c host_ip                # 0 —— 无任何端口限定到 127.0.0.1

# Dockerfile 依赖层顺序(静态判定,未构建)
sed -n '13,21p' ../docker/backend/Dockerfile

# 证书有效期 / 配套性
openssl x509 -in nginx/ssl/server.pem -noout -subject -issuer -dates
openssl x509 -in nginx/ssl/server.pem -noout -pubkey | openssl md5
openssl rsa  -in nginx/ssl/server.key -pubout 2>/dev/null | openssl md5

# 权限
ls -l nginx/ssl/server.key .env

# 入库情况(确认密钥未提交)
git ls-files docker/ | grep -v -E '^docker/nginx/(web|docs|app)/'
git ls-files backend/requirements/     # 空 → 依赖拆分未提交(B2 的成因之一)

# CI
ls -laR ../.github/

# 构建上下文体积(按 .dockerignore 规则静态统计)
python3 - <<'PY'
import os
root='/Users/tao/workspace/FastapiAdmin'
ig={'.git','frontend','.venv','node_modules','logs','.ruff_cache','.mypy_cache','.pytest_cache','__pycache__','.vscode','.idea','.codebuddy','.workbuddy','.agent-teams'}
t=0
for dp,dns,fns in os.walk(root):
    dns[:]=[d for d in dns if d not in ig]
    if 'static' in dp.split(os.sep) and 'upload' in dp.split(os.sep): dns[:]=[]; continue
    for f in fns:
        if f.endswith(('.pyc','.pyo','.pyd','.log','.db','.sqlite3')) or f in ('.DS_Store','.gitignore','.gitattributes'): continue
        try: t+=os.path.getsize(os.path.join(dp,f))
        except OSError: pass
print(f"approx context = {t/1024/1024:.1f} MiB")
PY
# → approx context = 68.4 MiB

未执行(受本轮只读约束):docker build、docker compose up/down、nginx -t、任何容器启动、依赖安装、git 提交。因此 nginx.conf 的语法正确性只做了静态审查(docker-compose config 校验的是 compose 而非 nginx.conf)。

证据时效说明:审计期间工作区被多个并行审计成员同时读写,backend/uv.lock、docker/.env.example、backend/requirements.txt 在本报告定稿前发生过变化(非本任务所为)。所有关键结论(B1/B2/B3 的引用内容、compose/Dockerfile/nginx.conf 的原始行号、证书与权限实测值)已在定稿前逐条复核,仍然成立;若后续再修改这些文件,请以“文件内容”而非行号为准。

8. 实测输出存档

命令 关键输出
docker version Client Docker Engine - Community 29.8.0(darwin/arm64,Context: colima);Server 29.5.2(linux/arm64,overlayfs,Cgroup v2)
docker compose version docker: unknown command: docker compose(~/.docker/cli-plugins/docker-compose 软链指向 /Applications/Docker.app/...,目标不存在)
docker-compose version Docker Compose version 5.5.1(/opt/homebrew/bin/docker-compose)
docker buildx version docker: unknown command: docker buildx
docker info Containers 0 / Images 23 / Logging Driver json-file / Cgroup Version 2
cd docker && docker-compose config 退出码 0;输出含 name: docker、backend platform: linux/amd64、image: backend:3.0.0、FORWARDED_ALLOW_IPS: '*'、mysql healthcheck 中口令被展开为明文;grep -c host_ip = 0
openssl x509 -in docker/nginx/ssl/server.pem -noout -subject -issuer -dates subject= /CN=service.fastapiadmin.com;issuer= /C=PL/O=Asseco Data Systems S.A./CN=Certum DV TLS G2 R39 CA;notBefore=Aug 19 14:10:22 2026 GMT;notAfter=Mar 6 14:10:21 2027 GMT;SAN DNS:service.fastapiadmin.com;-checkend 0 → not expired
证书/私钥配套性 server.pem、service.fastapiadmin.com/nginx/*.crt、.../pem/*.pem md5 均为 f308d6601a5521014d9a4b205c89efeb;server.pem 公钥 md5 与 server.key 公钥 md5 一致(4d0165b5c7a158d519cd29fd6a6f699a)
ls -l docker/.env 0644;docker/nginx/ssl/server.key 0666;.../nginx/*.key 0666;iis/password.txt、tomcat/password.txt 各 6 字节
git ls-files docker/(滤除前端产物) 仅 .env.example、README.md、Dockerfile、docker-compose.yaml、mysql/.gitkeep、mysql/data/.gitkeep、mysql/init/.gitkeep、nginx.conf、nginx/ssl/.gitkeep、redis/.gitkeep、redis/conf/redis.conf、redis/data/.gitkeep —— 无 .env、无证书
git ls-files backend/requirements/ 空(git status:?? backend/requirements/)
ls -laR .github/ workflows/ 空目录,total 0
上下文体积静态统计 68.4 MiB;Top 项:backend/data/ip2region/ip2region_v4.xdb 10.61 MiB、docker/nginx/web/dist/js/iconify-icons.*.js 2.55 MiB、backend/dist/js/iconify-icons.*.js 2.55 MiB、backend/static/swagger/redoc/bundles/redoc.standalone.js.map 2.55 MiB;docker/nginx/ssl/** 命中 21 个文件
目录体积 仓库总计 2.0 G(.git 151 M、backend/.venv 231 M、frontend 1.5 G、docker/nginx 28 M、backend/dist 22 M、backend/data 11 M、backend/logs 6.5 M)
源码判定 backend/app/config/setting.py:311 root_path=/api/v1 → backend/.venv/.../fastapi/applications.py:1160-1163 设置 scope["root_path"] → starlette/_utils.py:90-98 + starlette/routing.py:255 在匹配前剥离 → nginx 的 proxy_pass http://backend:8001;(不重写 URI)正确

报告结束。本轮为只读审计,除本文件外未修改、未删除任何仓库文件,未构建、未启动任何容器,未提交 git。