From bab4b85bed44fb59eb2853be9f81991285d78c6d Mon Sep 17 00:00:00 2001 From: shenjianZ Date: Tue, 28 Jul 2026 14:48:46 +0800 Subject: [PATCH] feat: add test environment deployment workflow --- .github/workflows/build-push-acr.yml | 67 ++++++++++++++ .gitignore | 1 + README.md | 2 + deploy/README.md | 97 +++++++++++++++++++ deploy/nginx/email-unlimit.conf | 108 ++++++++++++++++++++++ deploy/server/backend/.env.example | 44 +++++++++ deploy/server/docker-compose.yml | 69 ++++++++++++++ server/src/infra/middleware/rate_limit.rs | 80 +++++++++++++--- 8 files changed, 454 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/build-push-acr.yml create mode 100644 deploy/README.md create mode 100644 deploy/nginx/email-unlimit.conf create mode 100644 deploy/server/backend/.env.example create mode 100644 deploy/server/docker-compose.yml diff --git a/.github/workflows/build-push-acr.yml b/.github/workflows/build-push-acr.yml new file mode 100644 index 0000000..708a95d --- /dev/null +++ b/.github/workflows/build-push-acr.yml @@ -0,0 +1,67 @@ +name: Build and Push Backend to Aliyun ACR + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Image version tag, for example v0.1.0-test.1" + required: true + type: string + +env: + REGISTRY: ${{ secrets.ALIYUN_REGISTRY }} + NAMESPACE: ${{ secrets.ALIYUN_NAME_SPACE }} + ALIYUN_REGISTRY_USER: ${{ secrets.ALIYUN_REGISTRY_USER }} + ALIYUN_REGISTRY_PASSWORD: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} + +jobs: + build-backend: + name: Build and push Rust backend + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Aliyun ACR + shell: bash + run: | + set -euo pipefail + echo "$ALIYUN_REGISTRY_PASSWORD" | docker login \ + --username "$ALIYUN_REGISTRY_USER" \ + --password-stdin "$REGISTRY" + + - name: Prepare image tags + id: prep + shell: bash + run: | + set -euo pipefail + VERSION="${{ inputs.version || github.ref_name }}" + IMAGE="${REGISTRY}/${NAMESPACE}/email-unlimit-server" + SHORT_SHA="${GITHUB_SHA::7}" + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + + - name: Build and push backend + shell: bash + run: | + set -euo pipefail + docker buildx build \ + --platform linux/amd64 \ + --provenance=false \ + --cache-from=type=gha,scope=email-unlimit-server \ + --cache-to=type=gha,mode=max,scope=email-unlimit-server \ + -t "${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.version }}" \ + -t "${{ steps.prep.outputs.image }}:sha-${{ steps.prep.outputs.short_sha }}" \ + -t "${{ steps.prep.outputs.image }}:latest" \ + --push \ + -f server/Dockerfile \ + server diff --git a/.gitignore b/.gitignore index ed7100e..fd7befc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ plan.md info.md # docker-compose.self.yml compose.self.env +deploy/server/backend/.env node_modules diff --git a/README.md b/README.md index 0e52f29..cd8a9a7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ VITE_API_BASE_URL=http://localhost:3000 ## Docker 部署 +测试服务器使用宿主机 Nginx 提供静态文件和 HTTPS、使用阿里云 ACR 拉取 Rust 后端镜像时,请使用 [deploy/README.md](deploy/README.md) 中的独立部署方案。该方案适用于 `test-mail.shenjianl.cn` 与 `admin-mail.shenjianl.cn`,并与本地构建、上传前端静态文件的流程对应。 + 开发/内网部署: 先修改 `compose.env` 中所有 `CHANGE_ME` 配置,然后运行: diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..a0fc02f --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,97 @@ +# 测试服务器部署 + +该目录交付给运行宿主机 Nginx 和 Docker Compose 的测试服务器。后端由阿里云 ACR 拉取,用户端和管理端均在本机构建后上传为静态文件。 + +## 一次性准备 + +1. 在服务器创建目录: + + ```bash + sudo mkdir -p /site/email-unlimit/{frontend/releases,admin/releases,backend,backups} + sudo chown -R "$USER":"$USER" /site/email-unlimit + ``` + +2. 上传 `deploy/server/docker-compose.yml` 到 `/site/email-unlimit/docker-compose.yml`,复制 `deploy/server/backend/.env.example` 为 `/site/email-unlimit/backend/.env`,填写真实值并执行: + + ```bash + chmod 600 /site/email-unlimit/backend/.env + ``` + +3. 使用 ACR 只读凭据在服务器登录私有镜像仓库: + + ```bash + docker login + ``` + +4. 将 `deploy/nginx/email-unlimit.conf` 安装到宿主机 Nginx 的 `http` 上下文包含目录。确认通配符证书路径与文件名后,执行: + + ```bash + sudo nginx -t && sudo systemctl reload nginx + ``` + +5. 在 `/site/email-unlimit` 启动数据服务和后端: + + ```bash + docker compose --env-file backend/.env up -d + docker compose --env-file backend/.env ps + curl --fail http://127.0.0.1:3000/health + ``` + + `backend/.env` 同时是 Docker Compose 的变量来源和后端容器的环境文件,因此之后所有 Compose 命令都必须带上 `--env-file backend/.env`。 + +## 本机构建与静态发布 + +构建时用户端必须使用测试收件域名,且两个前端均保持空 API 基址以使用同域反向代理: + +```powershell +$env:VITE_APP_DOMAIN = "test-mail.shenjianl.cn" +$env:VITE_API_BASE_URL = "" +Set-Location frontend +pnpm build + +Set-Location ..\admin +pnpm build +``` + +将两个 `dist` 分别上传到服务器的 `frontend/releases/<版本>` 和 `admin/releases/<版本>`。确认上传完整后,在服务器原子切换当前版本: + +```bash +cd /site/email-unlimit +ln -sfn releases/<版本> frontend/current +ln -sfn releases/<版本> admin/current +sudo nginx -t && sudo systemctl reload nginx +``` + +## 后端发布与回滚 + +GitHub 镜像仓库创建 `v*` 标签后,工作流将构建并推送 `linux/amd64` 镜像到 ACR。把 `/site/email-unlimit/backend/.env` 中的 `IMAGE_TAG` 更新为相同版本,然后执行: + +```bash +cd /site/email-unlimit +docker compose --env-file backend/.env pull backend +docker compose --env-file backend/.env up -d --no-deps backend +docker compose --env-file backend/.env logs --tail=100 backend +curl --fail http://127.0.0.1:3000/health +``` + +回滚时将 `IMAGE_TAG` 改回上一镜像版本后重复相同命令。 + +## DNS 与网络 + +- `A test-mail.shenjianl.cn` 指向服务器公网 IP。 +- `MX test-mail.shenjianl.cn` 指向 `test-mail.shenjianl.cn`,优先级为 10。 +- `A admin-mail.shenjianl.cn` 指向同一公网 IP。 +- 安全组与防火墙仅放行 TCP `80`、`443`、`25`;不公开 `3000`、`3306`、`6379`。 +- 确认云厂商允许入站 TCP 25。建议将公网 IP 的 PTR 设为 `test-mail.shenjianl.cn`。 + +## 备份 + +每日从 MySQL 容器导出一次一致性备份到 `/site/email-unlimit/backups`,保留 7 天: + +```bash +docker compose --env-file /site/email-unlimit/backend/.env exec -T mysql sh -c 'exec mysqldump --single-transaction -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \ + | gzip > "/site/email-unlimit/backups/email-unlimit-$(date +%F).sql.gz" +find /site/email-unlimit/backups -type f -name 'email-unlimit-*.sql.gz' -mtime +7 -delete +``` + +将上面的命令放入宿主机 cron 或 systemd timer。MySQL 数据卷与 Redis AOF 卷由 Docker 持久化。 diff --git a/deploy/nginx/email-unlimit.conf b/deploy/nginx/email-unlimit.conf new file mode 100644 index 0000000..690d746 --- /dev/null +++ b/deploy/nginx/email-unlimit.conf @@ -0,0 +1,108 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name test-mail.shenjianl.cn; + return 301 https://$host$request_uri; +} + +server { + listen 80; + server_name admin-mail.shenjianl.cn; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name test-mail.shenjianl.cn; + + ssl_certificate /etc/nginx/certs/shenjianl.cn.fullchain.pem; + ssl_certificate_key /etc/nginx/certs/shenjianl.cn.key; + + root /site/email-unlimit/frontend/current; + index index.html; + client_max_body_size 1m; + + location ~ ^/(auth|api)(/|$) { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + location = /health { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location = /index.html { + add_header Cache-Control "no-store" always; + try_files $uri =404; + } + + location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable" always; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} + +server { + listen 443 ssl http2; + server_name admin-mail.shenjianl.cn; + + ssl_certificate /etc/nginx/certs/shenjianl.cn.fullchain.pem; + ssl_certificate_key /etc/nginx/certs/shenjianl.cn.key; + + root /site/email-unlimit/admin/current; + index index.html; + client_max_body_size 1m; + + location ~ ^/(auth|api|admin)(/|$) { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + location ~ ^/(health|info)$ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location = /index.html { + add_header Cache-Control "no-store" always; + try_files $uri =404; + } + + location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable" always; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/server/backend/.env.example b/deploy/server/backend/.env.example new file mode 100644 index 0000000..5cd42ef --- /dev/null +++ b/deploy/server/backend/.env.example @@ -0,0 +1,44 @@ +# This file is copied to /site/email-unlimit/backend/.env and must never be committed. +ENV=production +CONFIG=config/production.toml +TZ=Asia/Shanghai +RUST_LOG=info + +REGISTRY=registry.example.com +NAMESPACE=your-acr-namespace +IMAGE_TAG=v0.1.0-test.1 + +MYSQL_DATABASE=email_unlimit +MYSQL_USER=email_unlimit +MYSQL_PASSWORD=CHANGE_ME_DATABASE_USER_PASSWORD +MYSQL_ROOT_PASSWORD=CHANGE_ME_DATABASE_ROOT_PASSWORD + +REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD + +DATABASE__DATABASE_TYPE=mysql +DATABASE__HOST=mysql +DATABASE__PORT=3306 +DATABASE__USER=email_unlimit +DATABASE__PASSWORD=CHANGE_ME_DATABASE_USER_PASSWORD +DATABASE__DATABASE=email_unlimit + +REDIS__ENABLED=true +REDIS__HOST=redis +REDIS__PORT=6379 +REDIS__PASSWORD=CHANGE_ME_REDIS_PASSWORD +REDIS__DB=0 + +AUTH__JWT_SECRET=CHANGE_ME_WITH_AT_LEAST_32_RANDOM_CHARACTERS +AUTH__INVITE_CODE=CHANGE_ME_WITH_AT_LEAST_16_CHARACTERS +AUTH__BOOTSTRAP_ADMIN_EMAIL=admin@example.com +AUTH__REGISTRATION_OPEN=true + +SERVER__CORS_ORIGINS=https://test-mail.shenjianl.cn,https://admin-mail.shenjianl.cn + +SMTP__HOSTNAME=test-mail.shenjianl.cn +SMTP__LOCAL_DOMAINS=test-mail.shenjianl.cn +SMTP__BODY_ENCRYPTION_KEY=CHANGE_ME_EXACTLY_32_ASCII_CHARACTERS +ABUSE__HONEYPOT_ADDRESSES=admin@test-mail.shenjianl.cn,noreply@test-mail.shenjianl.cn,test@test-mail.shenjianl.cn + +EMAIL__ENABLED=false +EMAIL__QUEUE_ENABLED=false diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml new file mode 100644 index 0000000..1ecf5a0 --- /dev/null +++ b/deploy/server/docker-compose.yml @@ -0,0 +1,69 @@ +services: + backend: + image: ${REGISTRY}/${NAMESPACE}/email-unlimit-server:${IMAGE_TAG} + container_name: email-unlimit-backend + restart: unless-stopped + env_file: + - ./backend/.env + ports: + - "127.0.0.1:3000:3000" + - "25:25" + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 20s + networks: + - email-unlimit + + mysql: + image: mysql:8.0 + container_name: email-unlimit-mysql + restart: unless-stopped + environment: + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + TZ: Asia/Shanghai + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - email-unlimit + + redis: + image: redis:7.4-alpine + container_name: email-unlimit-redis + restart: unless-stopped + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + command: ["sh", "-c", "redis-server --appendonly yes --requirepass $$REDIS_PASSWORD"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"] + interval: 10s + timeout: 3s + retries: 10 + networks: + - email-unlimit + +networks: + email-unlimit: + driver: bridge + +volumes: + mysql-data: {} + redis-data: {} diff --git a/server/src/infra/middleware/rate_limit.rs b/server/src/infra/middleware/rate_limit.rs index b7d5573..efabd96 100644 --- a/server/src/infra/middleware/rate_limit.rs +++ b/server/src/infra/middleware/rate_limit.rs @@ -6,7 +6,7 @@ use axum::{ }; use std::{ collections::HashMap, - net::SocketAddr, + net::{IpAddr, SocketAddr}, sync::Arc, time::{Duration, Instant}, }; @@ -38,27 +38,79 @@ impl RateLimiter { } } } + +fn client_key(req: &Request) -> String { + let peer_ip = req + .extensions() + .get::>() + .map(|value| value.0.ip()); + + // Only the host Nginx can reach the production backend through loopback. + // It overwrites X-Real-IP with the actual remote address, so untrusted + // direct clients cannot choose their own rate-limit key. + if peer_ip.is_some_and(|ip| ip.is_loopback()) { + if let Some(forwarded_ip) = req + .headers() + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + { + return forwarded_ip.to_string(); + } + } + + peer_ip + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".into()) +} + pub async fn rate_limit_middleware( State(limiter): State, req: Request, next: Next, ) -> Response { - let key = req - .extensions() - .get::>() - .map(|v| v.0.ip().to_string()) - .or_else(|| { - req.headers() - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.split(',').next()) - .map(str::trim) - .map(str::to_string) - }) - .unwrap_or_else(|| "unknown".into()); + let key = client_key(&req); if limiter.allow(key).await { next.run(req).await } else { (StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response() } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + + fn request_with_peer(peer_ip: IpAddr, real_ip: Option<&str>) -> Request { + let mut request = Request::builder().uri("/").body(Body::empty()).unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::new(peer_ip, 12345))); + if let Some(real_ip) = real_ip { + request.headers_mut().insert( + "x-real-ip", + real_ip.parse().expect("test IP header must be valid"), + ); + } + request + } + + #[test] + fn loopback_proxy_uses_valid_real_ip() { + let request = request_with_peer("127.0.0.1".parse().unwrap(), Some("203.0.113.8")); + assert_eq!(client_key(&request), "203.0.113.8"); + } + + #[test] + fn public_peer_cannot_spoof_real_ip() { + let request = request_with_peer("203.0.113.9".parse().unwrap(), Some("198.51.100.7")); + assert_eq!(client_key(&request), "203.0.113.9"); + } + + #[test] + fn loopback_proxy_with_invalid_real_ip_falls_back_to_peer() { + let request = request_with_peer("::1".parse().unwrap(), Some("not-an-ip")); + assert_eq!(client_key(&request), "::1"); + } +}