Compare commits
3 Commits
8ec4ecd310
...
b923851284
| Author | SHA1 | Date | |
|---|---|---|---|
| b923851284 | |||
| 74f8c212f6 | |||
| c237007111 |
@ -38,3 +38,8 @@ make dev
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
## 生产部署
|
||||
|
||||
生产环境必须前置反向代理(nginx/Caddy)做 TLS 终结,并设置 `APP_BASE_URL`、
|
||||
`COOKIE_SECURE=true`、`TRUSTED_PROXY_COUNT=1`。详见 [部署指南](docs/DEPLOYMENT.md)。
|
||||
|
||||
200
docs/DEPLOYMENT.md
Normal file
200
docs/DEPLOYMENT.md
Normal file
@ -0,0 +1,200 @@
|
||||
# 生产部署指南
|
||||
|
||||
> **前置警告:不要直接把 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 生效)。
|
||||
@ -19,6 +19,36 @@ fn is_write_method(method: &Method) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// 启动时检查 `APP_BASE_URL` 是否已设置,未设置则打一条 WARN。
|
||||
///
|
||||
/// [`trusted_origin`] 在拿不到该变量时会回退到请求 `Host` 头推导本站 origin,
|
||||
/// 反向代理后若 `Host` 头可被客户端影响,该回退路径可被 CSRF 绕过。
|
||||
/// 生产环境应显式设置该变量为站点完整 origin(如 `https://your-domain.example`)。
|
||||
///
|
||||
/// 本地开发同样会触发(默认不设 `APP_BASE_URL`),代价仅是启动时一条 WARN,
|
||||
/// 远小于误判 localhost 的复杂度。与 `image.rs` 的启动告警同范式:一次性 WARN,
|
||||
/// 不污染每请求路径。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn warn_if_app_base_url_unset() {
|
||||
if app_base_url_is_set() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"APP_BASE_URL 未设置。CSRF 校验将回退到请求 Host 头推导本站 origin,\
|
||||
反向代理后若 Host 头可被客户端影响存在绕过风险。\
|
||||
生产环境应显式设置为站点完整 origin,如 https://your-domain.example。"
|
||||
);
|
||||
}
|
||||
|
||||
/// `APP_BASE_URL` 是否已设置为非空值。纯函数,便于测试。
|
||||
#[cfg(feature = "server")]
|
||||
fn app_base_url_is_set() -> bool {
|
||||
std::env::var("APP_BASE_URL")
|
||||
.ok()
|
||||
.map(|v| !v.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 从 `scheme://host[:port][/path][?query]` 提取标准化的 `scheme://host[:port]`,
|
||||
/// 端口为默认值(http=80, https=443)时省略。
|
||||
///
|
||||
@ -197,4 +227,61 @@ mod tests {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_origin(&headers), None);
|
||||
}
|
||||
|
||||
// ── APP_BASE_URL 启动告警 ──────────────────────────────────────
|
||||
// 这些测试读/写 APP_BASE_URL 全局环境变量,用 serial 串行隔离,
|
||||
// 与 rate_limit.rs 的 env 测试同模式(保存 → 设值 → 恢复)。
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn app_base_url_is_set_false_when_unset() {
|
||||
let original = std::env::var("APP_BASE_URL").ok();
|
||||
std::env::remove_var("APP_BASE_URL");
|
||||
assert!(!app_base_url_is_set());
|
||||
restore_env("APP_BASE_URL", original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn app_base_url_is_set_false_when_empty() {
|
||||
let original = std::env::var("APP_BASE_URL").ok();
|
||||
std::env::set_var("APP_BASE_URL", "");
|
||||
assert!(!app_base_url_is_set());
|
||||
restore_env("APP_BASE_URL", original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn app_base_url_is_set_false_when_whitespace_only() {
|
||||
let original = std::env::var("APP_BASE_URL").ok();
|
||||
std::env::set_var("APP_BASE_URL", " \t ");
|
||||
assert!(!app_base_url_is_set());
|
||||
restore_env("APP_BASE_URL", original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn app_base_url_is_set_true_when_set() {
|
||||
let original = std::env::var("APP_BASE_URL").ok();
|
||||
std::env::set_var("APP_BASE_URL", "https://example.com");
|
||||
assert!(app_base_url_is_set());
|
||||
restore_env("APP_BASE_URL", original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn app_base_url_is_set_trims_surrounding_whitespace() {
|
||||
let original = std::env::var("APP_BASE_URL").ok();
|
||||
std::env::set_var("APP_BASE_URL", " https://example.com ");
|
||||
assert!(app_base_url_is_set());
|
||||
restore_env("APP_BASE_URL", original);
|
||||
}
|
||||
|
||||
/// 恢复环境变量到测试前的状态,避免污染其他测试。
|
||||
fn restore_env(key: &str, original: Option<String>) {
|
||||
match original {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@ -108,6 +108,15 @@ fn parse_compression_algorithms(env: &str) -> Option<CompressionAlgorithms> {
|
||||
|
||||
/// 根据 COMPRESSION_ALGORITHMS 环境变量构造 CompressionLayer。
|
||||
/// 未设置或设置为 "all" 时启用全部算法;设置为 ""、"none" 或 "off" 时禁用。
|
||||
///
|
||||
/// CompressionLayer 使用 tower-http 的 `DefaultPredicate`,开箱即用即:
|
||||
/// - 跳过 `image/*` content-type(WebP/PNG/JPEG/GIF 等已是压缩格式,再压浪费 CPU,
|
||||
/// 唯一例外是 `image/svg+xml`,作为 XML 文本可被压缩);
|
||||
/// - 跳过 gRPC 与 `text/event-stream`(SSE);
|
||||
/// - 跳过小于 32 字节的响应。
|
||||
///
|
||||
/// 因此无需在此处对图片响应做额外的 content-type 过滤。另:图片实际挂在
|
||||
/// `static_routes`(无中间件),根本不经此层,详见下方路由 merge 处。
|
||||
#[cfg(feature = "server")]
|
||||
fn compression_layer_from_env() -> Option<tower_http::compression::CompressionLayer> {
|
||||
use tower_http::compression::CompressionLayer;
|
||||
@ -224,6 +233,10 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// 提醒部署者显式设置 APP_BASE_URL:未设置时 CSRF 会回退到 Host 头,
|
||||
// 反向代理后存在绕过风险(本地 localhost 开发会静默,不打日志)。
|
||||
api::csrf::warn_if_app_base_url_unset();
|
||||
|
||||
// 启动前执行数据库迁移。阻塞:完成前不监听端口。
|
||||
// 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。
|
||||
// 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user