yggdrasil/docs/DEPLOYMENT.md
xfy 74992d272f docs(deployment): uncomment XFF in nginx example
Keep X-Forwarded-For as an active default so the snippet is
copy-paste safe; TRUSTED_PROXY_COUNT=1 relies on the proxy
rewriting XFF, and a commented-out line is easy to miss.
2026-06-29 15:59:54 +08:00

201 lines
7.4 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# 生产部署指南
> **前置警告:不要直接把 3000 端口暴露到公网 HTTP。**
>
> 本服务端不处理 TLS这是有意的——应交给反向代理。但安全相关逻辑
> (会话 Cookie 的 `Secure` 标志、限流按真实客户端 IP 聚合、写请求的 CSRF
> origin 校验)都假设你的部署**前置了一层反向代理并终结 TLS**。
>
> 如果你用 `docker run -p 3000:3000` 把端口直接映射到公网 HTTP会**同时**踩中
> 三个坑:会话 Cookie 无 `Secure`(明文 HTTP 下可被嗅探)、限流退化或可被伪造、
> CSRF 的 Host 头回退可被构造请求绕过。继续阅读下面的「生产环境必设变量」。
---
## 架构假设
```
客户端 ──HTTPS──▶ 反向代理(nginx/Caddy) ──HTTP──▶ 应用 :3000
```
反向代理负责:
1. **TLS 终结**——对外只暴露 HTTPS。
2. **注入真实客户端信息**——`X-Forwarded-For``X-Real-IP``X-Forwarded-Proto`
3. **清洗客户端传入的转发头**——见下方「⚠️ 反代必须覆写 XFF」。
应用进程本身只监听 `0.0.0.0:3000`(见 `Dockerfile``IP` / `PORT`),不监听 443
也不读取任何证书文件。
---
## 生产环境必设变量
`.env`(或容器的环境变量)中设置以下三项。`Dockerfile``.env.example` 默认
值都面向本地开发,**生产环境必须覆盖**
| 变量 | 生产值 | 作用 |
|------|--------|------|
| `APP_BASE_URL` | `https://your-domain.example` | 写请求 CSRF 校验的可信 origin。不设时回退到请求 `Host` 头 + `X-Forwarded-Proto`,反代后若 `Host` 头可被客户端影响,该回退路径可被 CSRF 绕过。 |
| `COOKIE_SECURE` | `true` | 给会话 Cookie 加 `Secure` 标志,浏览器仅在 HTTPS 下发送。明文 HTTP 生产环境**必开**。 |
| `TRUSTED_PROXY_COUNT` | `1`(单层反代) | 应用前方的反向代理层数,用于从 `X-Forwarded-For` 提取真实客户端 IP。直接对外服务时为 `0`;一层 nginx/Caddy 时为 `1`。 |
### `TRUSTED_PROXY_COUNT` 设错的两种后果
这个值必须**精确等于**实际的反向代理层数:
- **设得比实际大** → 应用会信任客户端伪造的 `X-Forwarded-For`,限流可被任意绕过
(攻击者每次换一个伪造 IP每个 IP 独享一个限流桶)。
- **设得比实际小** → 取到的是中间代理的 IP 而非真实客户端 IP所有真实用户共享
同一个代理 IP 的限流桶,正常用户互相挤占。
---
## 反向代理配置示例
### nginx
```nginx
# /etc/nginx/conf.d/yggdrasil.conf
# 1. HTTP 强制跳转 HTTPS
server {
listen 80;
server_name your-domain.example;
return 301 https://$host$request_uri;
}
# 2. HTTPS 主服务
server {
listen 443 ssl;
http2 on;
server_name your-domain.example;
ssl_certificate /etc/letsencrypt/live/your-domain.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.example/privkey.pem;
# ⚠️ 关键:上传路由的 body 上限。应用侧硬限制 10 MiB见 src/main.rs 的
# DefaultBodyLimit::max(10 * 1024 * 1024)nginx 默认仅 1 MiB 会先于应用
# 返回 413。设成略大于应用上限即可。
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:3000;
# ⚠️ 反代必须覆写 XFF——见下方「⚠ 反代必须覆写 XFF」说明。
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# 上传与图片处理可能较慢(图片转码最长 300s 超时),反代代理超时
# 要大于应用侧超时,否则反代先于应用返回 504。
proxy_read_timeout 360s;
proxy_send_timeout 360s;
}
# 健康检查:可不经 TLS 或单独配置探针路径。
# GET /healthz —— liveness进程存活即 200不查 DB。
# GET /readyz —— readiness执行 SELECT 1 检测 DB 连通性,不可达返回 503。
}
```
### Caddy
Caddy 会自动申请并续期 Let's Encrypt 证书,配置更短:
```caddy
your-domain.example {
# ⚠️ 上传 body 上限,理由同 nginx。应用侧硬限制 10 MiB。
request_body {
max_size 12MB
}
reverse_proxy 127.0.0.1:3000 {
# Caddy 默认会设置 X-Forwarded-For / X-Forwarded-Proto / Host
# 并正确处理 XFF 的追加(详见下方说明)。
# 上传/图片处理超时对齐应用侧300s
transport http {
read_timeout 360s
write_timeout 360s
}
}
}
```
### ⚠️ 反代必须覆写 `X-Forwarded-For`
应用的 `TRUSTED_PROXY_COUNT=1` 假设:`X-Forwarded-For` 的**最右一项**是可信的反代
自己写入的,其余左侧项才可能是客户端原始 IP。因此反代必须**覆写或正确追加** XFF
- **nginx**`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`
——`$proxy_add_x_forwarded_for` 会把客户端**已发送的** XFF 与反代看到的
`$remote_addr` 拼接。由于应用只信任右侧第 1 项(反代写入的 `$remote_addr`
客户端伪造的左侧项会被正确忽略。
- **Caddy**`reverse_proxy` 默认行为已满足,无需手动配置。
**绝不能**做的是把应用直接暴露到公网、或让客户端的 `X-Forwarded-For` 原封不动地
成为最右一项——那等于把限流的 key 交给攻击者随意伪造。
---
## 完整环境变量清单
除上述三项必设变量外,其余变量均有合理默认值,按需调整。完整说明见
[`.env.example`](../.env.example)。生产部署最少需要:
```bash
# 数据库(必填)
DATABASE_URL=postgres://user:password@db-host:5432/yggdrasil
# 日志
RUST_LOG=info
# === 生产安全三件套(必设)===
APP_BASE_URL=https://your-domain.example
COOKIE_SECURE=true
TRUSTED_PROXY_COUNT=1
```
其余限流阈值、WebP 编码参数、图片缓存上限、连接池大小等)均为可选调优项,
不设时使用 `.env.example` 中的默认值。
---
## Docker 部署
```bash
docker build -t yggdrasil .
docker run -d \
--name yggdrasil \
-p 127.0.0.1:3000:3000 \
-e DATABASE_URL=postgres://user:password@db-host:5432/yggdrasil \
-e APP_BASE_URL=https://your-domain.example \
-e COOKIE_SECURE=true \
-e TRUSTED_PROXY_COUNT=1 \
-e RUST_LOG=info \
-v yggdrasil-uploads:/app/uploads \
yggdrasil
```
注意 `-p 127.0.0.1:3000:3000`——只绑定到回环地址,让反向代理作为唯一的公网入口。
**不要**用 `-p 3000:3000`(绑定 `0.0.0.0`),那会让应用绕过反代直接暴露。
数据库迁移需在容器首次启动前执行:在宿主机用 `./migrate.sh`(需本地 PostgreSQL
客户端工具),或在初始化容器中运行。
---
## 部署后验证清单
部署完成后,逐项确认:
- [ ] `curl -I https://your-domain.example/healthz` 返回 `200`
- [ ] `curl -I https://your-domain.example/readyz` 返回 `200`DB 连通)。
- [ ] 浏览器登录后DevTools → Application → Cookies 中 `session` 项的
`Secure` 列为勾选、`HttpOnly` 为勾选、`SameSite``Lax`
- [ ] HTTP 访问(`http://your-domain.example`)被 301 跳转到 HTTPS。
- [ ] 反代访问日志中记录的是真实客户端公网 IP而非反代自身的回环地址
(确认 `TRUSTED_PROXY_COUNT` 配置正确,限流按真实 IP 生效)。