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

800 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```python
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):
```yaml
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 再安装,并按数据库类型只装所需驱动:
```dockerfile
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**(更彻底):
```dockerfile
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`。
**改法**
```yaml
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`:
```yaml
# 信任反向代理(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 增加:
```nginx
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 每天):
```bash
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 提权路径保持开放。
**改法**
```dockerfile
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 侧:
```yaml
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. **部署前备份**(必做):
```bash
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|redoc|openapi\.json)$ { return 404; }`(或加 IP 白名单),或让后端按 `DEBUG` 决定是否注册 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:代码与权限
```bash
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`
```bash
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:配置校验(不启动容器)
```bash
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 与回滚点)
```bash
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:证书
```bash
# 方案 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:分层启动(不要一把梭)
```bash
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:备份(上线前先建好,别等出事)
```bash
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
```
**恢复演练(每季度做一次,否则备份等于没有)**:
```bash
# 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:上线验收(每条都要有明确通过标准)
```bash
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:日常发布与回滚
```bash
# 发布(推荐用 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. 复现本审计的方法
```bash
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。*