From f7dd13db072ad8cf7bbcad6e9a5c1a7695335db0 Mon Sep 17 00:00:00 2001 From: RobbsLuo Date: Wed, 28 Oct 2026 08:31:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(deploy):=20=E5=85=AC=E7=BD=91=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=EF=BC=88Docker=20+=20Caddy=20=E8=87=AA=E5=8A=A8=20HTT?= =?UTF-8?q?PS=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docker-compose.public.yml:叠加 Caddy 入口,应用端口改绑回环, TRUST_PROXY/HOST/ALLOWED_HOSTS/PUBLIC_BASE_URL 一并按公网口径修正 - 新增 deploy/caddy/Caddyfile:自动签发续期、HTTP→HTTPS、SSE 逐帧不缓冲、 XFF 覆盖式写入、/metrics 公网摘除 - README(中/英)新增「公网部署(Docker + Caddy)」一节 + 快速开始互链 - .env.example 补 PUBLIC_DOMAIN / ACME_EMAIL;TECHNICAL 目录表同步 - CI compose job 校验覆盖文件与 Caddyfile 语法 Refs #26 --- .env.example | 6 ++++ .github/workflows/ci.yml | 15 ++++++++++ README.en.md | 45 ++++++++++++++++++++++++++++ README.md | 45 ++++++++++++++++++++++++++++ TECHNICAL.md | 4 +-- deploy/caddy/Caddyfile | 43 +++++++++++++++++++++++++++ docker-compose.public.yml | 62 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 deploy/caddy/Caddyfile create mode 100644 docker-compose.public.yml diff --git a/.env.example b/.env.example index ce089f23..2a0d3b08 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,12 @@ ENABLE_DOCS=false # 是否采信 X-Forwarded-For(仅当本服务前面恰好一层受信反代时开启)。 TRUST_PROXY=false +# ---- 公网部署(Docker + Caddy,见 README「公网部署(Docker)」)---- +# 有公网 IP 的 VPS:填域名即自动申请 / 续期证书;本地与内网部署保持留空 +# (留空时 base compose 不引用这两项,不影响常规启动)。 +PUBLIC_DOMAIN= +ACME_EMAIL= + # 按渠道出站代理,格式 `渠道=代理URL;渠道2=代理URL2`;留空 = 全部直连。 # 渠道取八个渠道名,协议支持 http/https/socks5/socks5h。环境变量 HTTP_PROXY 等一律不采信。 # ⚠️ 容器里 127.0.0.1 指容器自身,不是宿主:代理跑在宿主时须写 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eaf4c66..3285e755 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,21 @@ jobs: run: docker compose config --quiet env: APP_SECRET: ci-placeholder-secret + - name: 校验 compose 公网覆盖文件语法(Caddy 入口) + run: docker compose -f docker-compose.yml -f docker-compose.public.yml config --quiet + env: + APP_SECRET: ci-placeholder-secret + PUBLIC_DOMAIN: ai-api.example.com + ACME_EMAIL: ops@example.com + - name: 校验 Caddyfile 语法 + run: | + # 只 validate、不起服务:占位域名不申请证书(validate 不联网签发) + docker run --rm \ + -e PUBLIC_DOMAIN=ai-api.example.com \ + -e ACME_EMAIL=ops@example.com \ + -e PORT=8000 \ + -v "$PWD/deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ + caddy:2.11.7-alpine caddy validate --config /etc/caddy/Caddyfile - name: 构建镜像并启动(验证 compose 可真实运行) run: | mkdir -p data secrets diff --git a/README.en.md b/README.en.md index 2ca85365..845ec29d 100644 --- a/README.en.md +++ b/README.en.md @@ -76,6 +76,8 @@ docker compose exec coding2api python scripts/create_user.py admin --role admin Pull image: `docker compose pull` (or `docker pull ghcr.io/robbsluo/coding2api:v0.10.1`). Pushing `v*` tag to main triggers `.github/workflows/publish.yml`, publishing `` + `:latest`. +Running the container on a **VPS with a public IP** and wanting an https edge → see [Public deployment (Docker + Caddy)](#public-deployment-docker--caddy-automatic-https). + ## Users and roles Accounts live in SQLite's `users` table; the *Users* page (admins only) handles management. @@ -786,6 +788,49 @@ Backend serves built frontend at `/` (reads disk per request), so **frontend-onl > **Deployment notes for this machine**: process management, paths, tool versions in `docs/local-environment.md` (gitignored). +## Public deployment (Docker + Caddy automatic HTTPS) + +For a VPS with a public IP: one compose command starts "Caddy edge + app", certificates are issued / renewed automatically, and the app port is not exposed. + +``` +client ──443/TLS──> Caddy (TLS termination) ──compose network──> coding2api:8000 +``` + +Prerequisites: Docker Compose ≥ 2.24 (the override uses `!override`), DNS A / AAAA record pointing at this host, ports 80 and 443 (tcp + udp) free (80 is needed for the ACME HTTP-01 challenge). + +Two more `.env` entries (everything else as in *Quick start*): + +```bash +PUBLIC_DOMAIN=ai-api.example.com # required: the certificate is issued for it +ACME_EMAIL=ops@example.com # required: Let's Encrypt expiry notices +# Leave ALLOWED_HOSTS empty: empty = loopback + the PUBLIC_BASE_URL hostname +``` + +Start it and watch certificate issuance: + +```bash +docker compose -f docker-compose.yml -f docker-compose.public.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.public.yml logs -f caddy +curl -fsS https://ai-api.example.com/health +``` + +What the override (`docker-compose.public.yml`) changes: + +| Change | Why | +|---|---| +| App port bound to `127.0.0.1:${PORT}` | No plaintext-http bypass left; the only way in is Caddy | +| `TRUST_PROXY=true` | Exactly one Caddy in front (and the app port is loopback-only, so it cannot be reached directly), so API keys' `allowed_ips` see the real client IP | +| `ALLOWED_HOSTS` extended with the domain + `127.0.0.1` | Non-empty means *full replacement*: a value already in `.env` would drop the domain (site-wide 400); missing loopback makes the container healthcheck fail → permanently unhealthy | +| `HOST=0.0.0.0` | `.env.example` ships `HOST=127.0.0.1`, which would make the container listen on loopback only and Caddy's upstream unreachable | +| New Caddy service (`deploy/caddy/Caddyfile`) | Automatic HTTPS + HTTP→HTTPS 308; `flush_interval -1` keeps streaming frame-by-frame; `X-Forwarded-For` is overwritten so client spoofing is ignored; no proxy-level timeouts (those stay with the app's `STREAM_*` / `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`) | +| `/metrics` returns 404 | That endpoint has no auth; grab it from the host with `curl 127.0.0.1:${PORT}/metrics` when needed | + +Notes: + +- **Certificates live in the named volume `caddy_data`**: never `docker compose ... down -v` — re-issuing hits the Let's Encrypt limit of 5 certificates per domain per week. +- **After changing `src/`, rebuild the app container**: `... up -d --build` (or at least `docker compose ... restart coding2api`); restarting Caddy alone does nothing. +- Behind CGNAT there is no inbound address, so this path is unavailable → use the frp tunnel below. + ## Public entry point (VPS + frp reverse tunnel) Behind CGNAT there is no inbound address, so public entry must be outbound tunnel. Cloudflare Tunnel free tier routes mainland traffic via Los Angeles (measured 1.1–2.4s, ~60% loss); "offshore VPS entry + frp back to origin" gets 80–150ms. Inbound only — outbound still uses `PROVIDER_PROXIES`. diff --git a/README.md b/README.md index 2db0537a..495c47b4 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ docker compose exec coding2api python scripts/create_user.py admin --role admin 拉镜像:`docker compose pull`(或 `docker pull ghcr.io/robbsluo/coding2api:v0.10.1`)。tag `v*` 推 main → `.github/workflows/publish.yml` → 发 `` + `:latest`。 +容器跑在**有公网 IP 的 VPS** 上要 https 入口 → 见「[公网部署(Docker + Caddy 自动 HTTPS)](#公网部署docker--caddy-自动-https)」。 + ## 用户与角色 账号存 SQLite `users` 表;「用户管理」页(仅管理员)增删改。 @@ -805,6 +807,49 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users # 期 > **部署操作见本地文档**:进程管理、路径、工具版本 → `docs/local-environment.md`(已 gitignore)。 +## 公网部署(Docker + Caddy 自动 HTTPS) + +有公网 IP 的 VPS 用这条:一条 compose 起「Caddy 入口 + 应用」,证书自动申请 / 续期,应用端口不外露。 + +``` +客户端 ──443/TLS──> Caddy(终止 TLS)──compose 内网──> coding2api:8000 +``` + +前提:Docker Compose ≥ 2.24(覆盖文件用了 `!override`)、域名 A / AAAA 记录已指向本机、80 与 443(tcp + udp)空闲(80 要留给 ACME HTTP-01)。 + +`.env` 补两项(其余同「快速开始」): + +```bash +PUBLIC_DOMAIN=ai-api.example.com # 必填:证书按它签发 +ACME_EMAIL=ops@example.com # 必填:Let's Encrypt 到期通知 +# ALLOWED_HOSTS 留空即可:空 = 放行本地回环 + PUBLIC_BASE_URL 的主机名 +``` + +启动并看证书签发: + +```bash +docker compose -f docker-compose.yml -f docker-compose.public.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.public.yml logs -f caddy +curl -fsS https://ai-api.example.com/health +``` + +覆盖文件(`docker-compose.public.yml`)改了什么: + +| 改动 | 为什么 | +|---|---| +| 应用端口改绑 `127.0.0.1:${PORT}` | 不给明文 http 留旁路:入口只能走 Caddy | +| `TRUST_PROXY=true` | 前面恰好一层 Caddy(且应用端口只在回环,无法直连绕过),Key 的 `allowed_ips` 才看得到真实来源 IP | +| `ALLOWED_HOSTS` 补域名 + `127.0.0.1` | 非空即「完全替换」语义:.env 里已有的值会顶掉域名(全站 400);漏掉回环会让容器 healthcheck 判 400 → 永远 unhealthy | +| `HOST=0.0.0.0` | `.env.example` 里的 `HOST=127.0.0.1` 会让容器只监听回环,Caddy 连不上 | +| 新增 Caddy 服务(`deploy/caddy/Caddyfile`) | 自动 HTTPS + HTTP→HTTPS 308;`flush_interval -1` 保流式逐帧;`X-Forwarded-For` 覆盖式写入,客户端自带伪造无效;反代不设超时上限(耗时口径交给应用自己的 `STREAM_*` / `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`) | +| `/metrics` 返 404 | 该端点无鉴权,公网入口直接摘掉;需要时在宿主 `curl 127.0.0.1:${PORT}/metrics` | + +注意: + +- **证书落具名卷 `caddy_data`**:别 `docker compose ... down -v` —— 会重新签发,撞 Let's Encrypt 每域名每周 5 次的下发限额。 +- **改 `src/` 后必须重建应用容器**:`... up -d --build`(或至少 `docker compose ... restart coding2api`);只重启 Caddy 无效。 +- 家宽 CGNAT 无入站地址走不了这条 → 用下一节的 frp 隧道方案。 + ## 公网入口(VPS + frp 反向隧道) 家宽 CGNAT 后无入站地址 → 公网入口只能内网侧主动连出。Cloudflare Tunnel 免费版把大陆流量绕洛杉矶(实测 1.1–2.4s、丢包约 60%);「境外 VPS 入口 + frp 隧道回源」降到 80–150ms。只改入站;出站仍走 `PROVIDER_PROXIES`。 diff --git a/TECHNICAL.md b/TECHNICAL.md index 074fcbf8..6d082896 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -23,7 +23,7 @@ PROPOSAL.md 定方向,本文定实现。只写**当前代码现状**。 | 路径 | 文件 | 职责 | |---|---|---| | `pyproject.toml` | — | uv 项目;`[tool.pytest.ini_options]` 设 coverage 目标 | -| 仓库根 | `Dockerfile`、`docker-compose.yml` | compose build context 依赖根目录 | +| 仓库根 | `Dockerfile`、`docker-compose.yml`、`docker-compose.public.yml` | compose build context 依赖根目录/公网部署覆盖文件(叠 Caddy 入口) | | `src/` | `main.py` `config.py` `runtime_settings.py` `version.py`(其余顶层模块见下「`src/` 顶层」) | 组装·lifespan·路由挂载/pydantic-settings 全部 env + `live()` 归一化/运行时配置覆盖层(DB > env + 白名单 + snapshot)/版本号(`pyproject.toml` 真源) | | `src/webapp/` | `limits.py` `security.py` `request_id.py` `access_log.py` `log_buffer.py` `handlers.py` `logging.py` `static.py` | 请求体上限 ASGI(登录 8KB / 其余 16MB)/Host 白名单 + CSP·nosniff/请求 ID(最外层,含 413/400)+ 会话 contextvar/请求级 logfmt/日志环形缓冲 + `redact_or_drop`/异常 → 稳定错误码/root logger/SPA catch-all(排除 `/api`、`/v1`) | | `src/db/` | `schema.sql` `conn.py` `migrate.py` `repo.py` `limits.py` `key_usage.py` `crypto.py` `backup.py` | DDL(只加不改)/线程本地连接 + WAL + busy_timeout + `transaction()`/迁移(**补列之后**建索引)/持久化手写 SQL/限额 CRUD + 当日聚合/按 `api_key_id` 聚合明细(Key 配额与 `/v1/usage` 唯一数据源)/Fernet/备份恢复(`VACUUM INTO` + integrity_check + 0600) | @@ -44,7 +44,7 @@ PROPOSAL.md 定方向,本文定实现。只写**当前代码现状**。 | `src/api/` | `deps.py` `endpoints.py` `chat.py` `responses.py` `messages.py` `context.py` `models.py` `model_merge.py` `model_catalog.py` `balance.py` `authorize.py` `usage.py` `admin_credentials.py` `admin_keys.py` `admin_settings.py` `admin_users.py` `admin_audit.py` `admin_alerts.py` `admin_badges.py` `admin_upstream.py` `admin_model_catalog.py` `admin_model_availability.py` `admin_backups.py` `admin_billing.py` `logs.py` `activate.py` `admin_stats.py` `admin_auth.py` `playground.py` `streaming.py` `metrics.py` | Services 容器 + 鉴权依赖/`dispatch_compat` 骨架/三个 /v1 出口/压缩接线/`GET /v1/models`(SWR + 落盘 + 白名单)/归一·合并·别名(纯函数)/目录快照/余额/TRAE 回调/`GET /v1/usage` 自助用量/凭证运维/Key CRUD/配置与任务/用户/审计/告警/角标/上游状态/模型目录 + 探活/备份与恢复/月度账单/日志页/激活/统计/登录 + 上游登录/Playground/SSE 包装/`/metrics` | | `src/audit/actions.py` | — | 审计动作常量 + 中文标签 | | `web/` | — | React 前端。**`.npmrc` 的 `auto-install-peers=false` 与 lock 的 `settings.autoInstallPeers` 强耦合**(避免拉 `@lobehub/ui`/`antd` 子树);`Dockerfile` 必须 `COPY web/.npmrc`,否则 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` | -| `deploy/` | — | launchd / logrotate / systemd 模板 | +| `deploy/` | — | launchd / logrotate / systemd 模板;`caddy/` 为公网 Docker 部署的入口配置 | | `diagrams/` | — | 架构图 / 时序图 / 状态机(HTML + 源 JSON) | | `scripts/` | — | 建号、回填、运维脚本 | | `tests/` | — | 按 `src/` 子包镜像,外加 `test_key_quota.py` / `test_model_alias.py` / `test_upstream_status.py` 这类顶层模块对应用例 | diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 00000000..bc4ffbb9 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,43 @@ +# 公网入口(Docker):Caddy 终止 TLS,反代到同网络的 coding2api:PORT。 +# +# 由 `docker compose -f docker-compose.yml -f docker-compose.public.yml` 启动, +# 环境变量来自 compose(PUBLIC_DOMAIN / ACME_EMAIL / PORT,见 .env)。 +# 证书与 ACME 账号落 /data(compose 具名卷 caddy_data)——**别 `down -v`**, +# 清掉会重新申请,撞 Let's Encrypt 每周同域名 5 次的下发限额。 +# +# 本地校验语法(不起服务): +# docker run --rm -e PUBLIC_DOMAIN=ai-api.example.com -e ACME_EMAIL=ops@example.com \ +# -e PORT=8000 -v "$PWD/deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ +# caddy:2.11.7-alpine caddy validate --config /etc/caddy/Caddyfile +{ + # Let's Encrypt 到期通知收件人;compose 侧已要求必填(留空时 email 指令会少参数报错) + email {$ACME_EMAIL} +} + +{$PUBLIC_DOMAIN} { + # /metrics 无鉴权,公网入口直接摘掉,需要时从宿主 127.0.0.1:${PORT} 直取 + @metrics path /metrics + respond @metrics 404 + + # 压缩:SSE 响应不落这条(Caddy 跳过 event-stream) + encode zstd gzip + + # 上游超时一律交给应用自己的口径(STREAM_*_TIMEOUT_SECONDS / + # UPSTREAM_COMPLETE_TIMEOUT_SECONDS)——反代再加一层会先掐断长请求 + reverse_proxy coding2api:{$PORT} { + # 关掉响应缓冲:逐帧下发,否则流式首字要等到缓冲区满 + flush_interval -1 + # 客户端可自带 XFF 伪造来源 IP(API Key 的 allowed_ips):只认直连对端, + # 覆盖式写入单值。TRUST_PROXY=true 才让应用采信它。 + header_up X-Forwarded-For {http.request.remote.host} + header_up X-Forwarded-Proto {scheme} + transport http { + dial_timeout 10s + } + } + + log { + output stdout + format console + } +} diff --git a/docker-compose.public.yml b/docker-compose.public.yml new file mode 100644 index 00000000..2fba78c4 --- /dev/null +++ b/docker-compose.public.yml @@ -0,0 +1,62 @@ +# 公网部署覆盖文件(Docker):在基础 compose 上叠一个 Caddy 入口。 +# +# docker compose -f docker-compose.yml -f docker-compose.public.yml up -d --build +# +# .env 新增两项:PUBLIC_DOMAIN(必填,域名,A/AAAA 记录已指向本机)、 +# ACME_EMAIL(必填,Let's Encrypt 到期通知)。详见 README「公网部署(Docker)」。 +# +# 需要 Docker Compose ≥ 2.24(下方 `!override` 标签;老版本读不了会报错)。 + +services: + coding2api: + # 应用端口只绑宿主机回环:入口只能走 Caddy。绑 0.0.0.0 会留一条明文 http + # 旁路(TRUST_PROXY 已开,直连还能伪造 XFF 绕过 Key 的 allowed_ips)。 + ports: !override + - "127.0.0.1:${PORT:-8000}:${PORT:-8000}" + environment: + # PUBLIC_BASE_URL 决定 TRAE 登录回调基址与 HSTS 下发;https 才发 HSTS。 + # 它的主机名也自动进入 Host 白名单(ALLOWED_HOSTS 留空时的口径)。 + PUBLIC_BASE_URL: https://${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env (e.g. ai-api.example.com)} + # 前恰好一层 Caddy 且应用端口只在回环 → 采信 XFF 是安全的 + TRUST_PROXY: "true" + # Caddy 反代走 compose 网络,容器内必须监听 0.0.0.0;.env 里沿用 + # .env.example 的 HOST=127.0.0.1 会让容器只监听回环、Caddy 连不上。 + HOST: 0.0.0.0 + # 显式列全:非空即「完全替换」语义——若 .env 里已有 ALLOWED_HOSTS, + # 不补这一条会漏掉域名(全站 400 invalid host header);漏掉回环地址 + # 会让容器 healthcheck 打的 127.0.0.1 判 400 → 永远 unhealthy + # (Caddy 依赖应用 healthy,也就永远不启动) + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-},${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env},127.0.0.1,localhost + + caddy: + image: caddy:2.11.7-alpine + container_name: coding2api-caddy + restart: unless-stopped + # 应用 healthy 后再起入口:否则 Caddy 会在应用预热期持续 502 并刷日志 + depends_on: + coding2api: + condition: service_healthy + environment: + PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env} + ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in .env (Let's Encrypt expiry notices)} + PORT: ${PORT:-8000} + ports: + # 80:ACME HTTP-01 挑战 + 之后 308 跳 https;443/udp 是 HTTP/3 + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + # 证书、ACME 账号与 Caddy 状态:必须持久化,否则每次重建都重新签发 + - caddy_data:/data + - caddy_config:/config + # 与 coding2api 同口径的日志轮转(json-file 默认无上限) + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + +volumes: + caddy_data: + caddy_config: \ No newline at end of file From 7a9ecb70d3249e126493583fe4326f20e6c67da0 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 1 Nov 2026 18:57:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(deploy):=20=E5=85=AC=E7=BD=91=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=94=AF=E6=8C=81=E3=80=8C=E5=8F=AA=E6=9C=89=E5=85=AC?= =?UTF-8?q?=E7=BD=91=20IP=E3=80=81=E6=B2=A1=E6=9C=89=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #27 原本要求 PUBLIC_DOMAIN 是域名:Caddy 自动申请(ACME)不给裸 IP 签发。 补上两条 IP-only 路径,并顺手修掉自签场景的两个坑: - TLS_MODE 选证书来源:acme(默认,域名,Let's Encrypt)/ internal(公网 IP, Caddy 自带 CA 自签)。Caddyfile 站点块按 TLS_MODE import deploy/caddy/tls_{acme,internal}.caddyfile,两种模式互不越界。 - ACME_EMAIL 改为「域名模式必填、自签模式可留空」:邮箱搬进 tls_acme 片段 的 issuer 块,自签模式不 import 该片段也就不会解析失败(原全局 email 指令 会把没有域名的部署也卡在一个用不到的邮箱上)。 - Caddyfile 加 default_sni {$PUBLIC_DOMAIN}:浏览器与 curl 对 IP 字面量 一律不发 SNI(RFC 6066 只允许主机名),没有它自签模式选不出证书,客户端 只看到 tlsv1 alert internal error——握手直接失败。本地起真实容器验证过: 加 default_sni 后 no-SNI 请求 200、80 端口 308、/metrics 404。 - README 中英 + .env.example 补「只有公网 IP、没有域名」一节:优先 sslip.io / nip.io(共享注册域限额,历史上被打满过),保底自签 + 装根证书。 - CI 两种 TLS 模式都 validate:compose config(acme / internal / 缺 PUBLIC_DOMAIN 必须报错)+ caddy validate 循环。 - tests/deploy:补公网部署静态断言(回环绑定、ALLOWED_HOSTS 补回环、 XFF 覆盖、default_sni、TLS 片段互不越界、.env.example 与 CI 同源)。 --- .env.example | 8 +- .github/workflows/ci.yml | 37 ++++--- README.en.md | 21 +++- README.md | 21 +++- TECHNICAL.md | 2 +- deploy/caddy/Caddyfile | 29 ++++-- deploy/caddy/tls_acme.caddyfile | 13 +++ deploy/caddy/tls_internal.caddyfile | 11 +++ docker-compose.public.yml | 19 ++-- tests/deploy/test_assets.py | 143 ++++++++++++++++++++++++++++ 10 files changed, 267 insertions(+), 37 deletions(-) create mode 100644 deploy/caddy/tls_acme.caddyfile create mode 100644 deploy/caddy/tls_internal.caddyfile diff --git a/.env.example b/.env.example index 2a0d3b08..3c1dec1d 100644 --- a/.env.example +++ b/.env.example @@ -48,9 +48,15 @@ TRUST_PROXY=false # ---- 公网部署(Docker + Caddy,见 README「公网部署(Docker)」)---- # 有公网 IP 的 VPS:填域名即自动申请 / 续期证书;本地与内网部署保持留空 -# (留空时 base compose 不引用这两项,不影响常规启动)。 +# (留空时 base compose 不引用这三项,不影响常规启动)。 +# 也可以填**公网 IP**(没有域名时):此时必须配 TLS_MODE=internal 走自签。 PUBLIC_DOMAIN= +# 域名模式(TLS_MODE 留空 / acme)必填:Let's Encrypt 到期通知收件人,也是 +# ACME 账号标识;自签模式(internal)用不到,留空即可。 ACME_EMAIL= +# TLS 模式:留空 / acme = 自动申请证书(站点须是域名);internal = Caddy 自带 +# CA 自签(站点是公网 IP,证书浏览器不认、需装根证书或客户端跳过校验)。 +TLS_MODE= # 按渠道出站代理,格式 `渠道=代理URL;渠道2=代理URL2`;留空 = 全部直连。 # 渠道取八个渠道名,协议支持 http/https/socks5/socks5h。环境变量 HTTP_PROXY 等一律不采信。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3285e755..0b16f5ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,21 +93,36 @@ jobs: run: docker compose config --quiet env: APP_SECRET: ci-placeholder-secret - - name: 校验 compose 公网覆盖文件语法(Caddy 入口) - run: docker compose -f docker-compose.yml -f docker-compose.public.yml config --quiet + - name: 校验 compose 公网覆盖文件语法(Caddy 入口,两种 TLS 模式) env: APP_SECRET: ci-placeholder-secret - PUBLIC_DOMAIN: ai-api.example.com - ACME_EMAIL: ops@example.com - - name: 校验 Caddyfile 语法 + run: | + # acme(域名):PUBLIC_DOMAIN + ACME_EMAIL + PUBLIC_DOMAIN=ai-api.example.com ACME_EMAIL=ops@example.com \ + docker compose -f docker-compose.yml -f docker-compose.public.yml config --quiet + # internal(公网 IP、自签):不设 ACME_EMAIL——自签模式用不到,必须能拼出来 + PUBLIC_DOMAIN=203.0.113.10 TLS_MODE=internal \ + docker compose -f docker-compose.yml -f docker-compose.public.yml config --quiet + # 缺 PUBLIC_DOMAIN 必须报错(覆盖文件里 :? 断言的回归保护) + if docker compose -f docker-compose.yml -f docker-compose.public.yml config --quiet 2>/dev/null; then + echo "::error::缺 PUBLIC_DOMAIN 时 compose 本应报错,却通过了" + exit 1 + fi + - name: 校验 Caddyfile 语法(域名 / 自签两种模式) run: | # 只 validate、不起服务:占位域名不申请证书(validate 不联网签发) - docker run --rm \ - -e PUBLIC_DOMAIN=ai-api.example.com \ - -e ACME_EMAIL=ops@example.com \ - -e PORT=8000 \ - -v "$PWD/deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ - caddy:2.11.7-alpine caddy validate --config /etc/caddy/Caddyfile + for mode in acme internal; do + case "$mode" in + acme) domain=ai-api.example.com; email=ops@example.com ;; + # 自签模式站址是公网 IP,且刻意不传 ACME_EMAIL(tls_internal 片段不引用它) + internal) domain=203.0.113.10; email= ;; + esac + docker run --rm \ + -e PUBLIC_DOMAIN="$domain" -e ACME_EMAIL="$email" \ + -e PORT=8000 -e TLS_MODE="$mode" \ + -v "$PWD/deploy/caddy:/etc/caddy:ro" \ + caddy:2.11.7-alpine caddy validate --config /etc/caddy/Caddyfile + done - name: 构建镜像并启动(验证 compose 可真实运行) run: | mkdir -p data secrets diff --git a/README.en.md b/README.en.md index 845ec29d..21720bc9 100644 --- a/README.en.md +++ b/README.en.md @@ -796,13 +796,14 @@ For a VPS with a public IP: one compose command starts "Caddy edge + app", certi client ──443/TLS──> Caddy (TLS termination) ──compose network──> coding2api:8000 ``` -Prerequisites: Docker Compose ≥ 2.24 (the override uses `!override`), DNS A / AAAA record pointing at this host, ports 80 and 443 (tcp + udp) free (80 is needed for the ACME HTTP-01 challenge). +Prerequisites: Docker Compose ≥ 2.24 (the override uses `!override`), a DNS A / AAAA record pointing at this host for the site address (a domain, or see *Public IP only, no domain* below), ports 80 and 443 (tcp + udp) free (80 is needed for the ACME HTTP-01 challenge). Two more `.env` entries (everything else as in *Quick start*): ```bash -PUBLIC_DOMAIN=ai-api.example.com # required: the certificate is issued for it -ACME_EMAIL=ops@example.com # required: Let's Encrypt expiry notices +PUBLIC_DOMAIN=ai-api.example.com # required: the certificate is issued for it (public IP when you have no domain) +ACME_EMAIL=ops@example.com # required with a domain: Let's Encrypt expiry notices +# TLS_MODE= # empty = automatic certificates; set internal for a public IP (see below) # Leave ALLOWED_HOSTS empty: empty = loopback + the PUBLIC_BASE_URL hostname ``` @@ -822,8 +823,18 @@ What the override (`docker-compose.public.yml`) changes: | `TRUST_PROXY=true` | Exactly one Caddy in front (and the app port is loopback-only, so it cannot be reached directly), so API keys' `allowed_ips` see the real client IP | | `ALLOWED_HOSTS` extended with the domain + `127.0.0.1` | Non-empty means *full replacement*: a value already in `.env` would drop the domain (site-wide 400); missing loopback makes the container healthcheck fail → permanently unhealthy | | `HOST=0.0.0.0` | `.env.example` ships `HOST=127.0.0.1`, which would make the container listen on loopback only and Caddy's upstream unreachable | -| New Caddy service (`deploy/caddy/Caddyfile`) | Automatic HTTPS + HTTP→HTTPS 308; `flush_interval -1` keeps streaming frame-by-frame; `X-Forwarded-For` is overwritten so client spoofing is ignored; no proxy-level timeouts (those stay with the app's `STREAM_*` / `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`) | -| `/metrics` returns 404 | That endpoint has no auth; grab it from the host with `curl 127.0.0.1:${PORT}/metrics` when needed | +| New Caddy service (`deploy/caddy/`) | Automatic HTTPS + HTTP→HTTPS 308; `flush_interval -1` keeps streaming frame-by-frame; `X-Forwarded-For` is overwritten so client spoofing is ignored; no proxy-level timeouts (those stay with the app's `STREAM_*` / `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`) | +| `TLS_MODE` picks the certificate source | `acme` (default, domain) requests Let's Encrypt automatically; `internal` (public IP) signs with Caddy's built-in CA | +| `/metrics` returns 404 | That endpoint has no auth by default (`METRICS_TOKEN` can tighten it); grab it from the host with `curl 127.0.0.1:${PORT}/metrics` when needed | + +### Public IP only, no domain + +Caddy's automatic certificates (ACME) only work for domain names — it will not issue for a bare IP. Two options: + +1. **A certificate browsers trust** → use a public wildcard-DNS name such as sslip.io / nip.io: `203-0-113-10.sslip.io` (or `203.0.113.10.sslip.io` / `.nip.io` — both spellings work) resolves to `203.0.113.10` automatically. Set `PUBLIC_DOMAIN` to it and keep `ACME_EMAIL`; this is plain ACME. Trade-off: certificates are rate-limited per *registered domain*, and sslip.io / nip.io are two domains **shared by every user** (exhausted once in Feb 2026; both got quota increases since) — if issuance fails, switch to the other, or fall back to self-signed. +2. **API clients only, untrusted certificate is fine** → `PUBLIC_DOMAIN=` + `TLS_MODE=internal`. Caddy signs with its built-in CA: the connection is still encrypted, but the certificate is in no public trust store — browsers warn, `curl` needs `-k`, or install the root CA into the client (it lives in the `caddy_data` volume: `docker compose -f docker-compose.yml -f docker-compose.public.yml exec caddy cat /data/pki/authorities/local/root.crt`). + +> The self-signed mode relies on `default_sni {$PUBLIC_DOMAIN}` in the Caddyfile: browsers and curl **send no SNI for IP literals** (RFC 6066 allows hostnames only), so without it Caddy cannot pick a certificate and the client only sees `tlsv1 alert internal error`. It is already in the shipped Caddyfile. Notes: diff --git a/README.md b/README.md index 495c47b4..de67383d 100644 --- a/README.md +++ b/README.md @@ -815,13 +815,14 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users # 期 客户端 ──443/TLS──> Caddy(终止 TLS)──compose 内网──> coding2api:8000 ``` -前提:Docker Compose ≥ 2.24(覆盖文件用了 `!override`)、域名 A / AAAA 记录已指向本机、80 与 443(tcp + udp)空闲(80 要留给 ACME HTTP-01)。 +前提:Docker Compose ≥ 2.24(覆盖文件用了 `!override`)、站点地址(域名,或见下「只有公网 IP、没有域名」)的 A / AAAA 记录已指向本机、80 与 443(tcp + udp)空闲(80 要留给 ACME HTTP-01)。 `.env` 补两项(其余同「快速开始」): ```bash -PUBLIC_DOMAIN=ai-api.example.com # 必填:证书按它签发 -ACME_EMAIL=ops@example.com # 必填:Let's Encrypt 到期通知 +PUBLIC_DOMAIN=ai-api.example.com # 必填:证书按它签发;没有域名时填公网 IP(见下) +ACME_EMAIL=ops@example.com # 域名模式必填:Let's Encrypt 到期通知 +# TLS_MODE= # 留空 = 自动申请证书;公网 IP 自签填 internal(见下) # ALLOWED_HOSTS 留空即可:空 = 放行本地回环 + PUBLIC_BASE_URL 的主机名 ``` @@ -841,8 +842,18 @@ curl -fsS https://ai-api.example.com/health | `TRUST_PROXY=true` | 前面恰好一层 Caddy(且应用端口只在回环,无法直连绕过),Key 的 `allowed_ips` 才看得到真实来源 IP | | `ALLOWED_HOSTS` 补域名 + `127.0.0.1` | 非空即「完全替换」语义:.env 里已有的值会顶掉域名(全站 400);漏掉回环会让容器 healthcheck 判 400 → 永远 unhealthy | | `HOST=0.0.0.0` | `.env.example` 里的 `HOST=127.0.0.1` 会让容器只监听回环,Caddy 连不上 | -| 新增 Caddy 服务(`deploy/caddy/Caddyfile`) | 自动 HTTPS + HTTP→HTTPS 308;`flush_interval -1` 保流式逐帧;`X-Forwarded-For` 覆盖式写入,客户端自带伪造无效;反代不设超时上限(耗时口径交给应用自己的 `STREAM_*` / `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`) | -| `/metrics` 返 404 | 该端点无鉴权,公网入口直接摘掉;需要时在宿主 `curl 127.0.0.1:${PORT}/metrics` | +| 新增 Caddy 服务(`deploy/caddy/`) | 自动 HTTPS + HTTP→HTTPS 308;`flush_interval -1` 保流式逐帧;`X-Forwarded-For` 覆盖式写入,客户端自带伪造无效;反代不设超时上限(耗时口径交给应用自己的 `STREAM_*` / `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`) | +| `TLS_MODE` 选证书来源 | `acme`(默认,域名)自动申请 Let's Encrypt;`internal`(公网 IP)用 Caddy 自带 CA 自签 | +| `/metrics` 返 404 | 该端点默认无鉴权(可设 `METRICS_TOKEN` 收紧),公网入口直接摘掉;需要时在宿主 `curl 127.0.0.1:${PORT}/metrics` | + +### 只有公网 IP、没有域名 + +Caddy 的自动申请(ACME)只认域名,不给裸 IP 签发。两条路自选: + +1. **要浏览器直接认的证书** → 用 sslip.io / nip.io 这类公共通配 DNS 名当域名:`203-0-113-10.sslip.io`(或 `203.0.113.10.sslip.io`、`.nip.io` 两种写法)会自动解析到 `203.0.113.10`。`PUBLIC_DOMAIN` 填它、`ACME_EMAIL` 照填,走的就是常规 ACME。代价:证书按「注册域」限流,sslip.io / nip.io 是两个被**所有使用者共享**的域名(2026-02 被打满过一次,各自申请过配额上调)——签不出来就换另一个,或退回自签。 +2. **只给 API 客户端用、不介意证书不被信任** → `PUBLIC_DOMAIN=<公网 IP>` + `TLS_MODE=internal`。Caddy 用自带 CA 自签:链路照样加密,但证书不在公共信任库里——浏览器告警,`curl` 要 `-k`,或把根证书装进客户端信任(根证书在 `caddy_data` 卷里:`docker compose -f docker-compose.yml -f docker-compose.public.yml exec caddy cat /data/pki/authorities/local/root.crt`)。 + +> 自签模式依赖 Caddyfile 里的 `default_sni {$PUBLIC_DOMAIN}`:浏览器与 curl 对 **IP 字面量不发 SNI**(RFC 6066 只允许主机名),没有这行 Caddy 选不出证书,客户端只看到 `tlsv1 alert internal error`。已默认写入,不用手改。 注意: diff --git a/TECHNICAL.md b/TECHNICAL.md index 6d082896..0ab7785a 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -44,7 +44,7 @@ PROPOSAL.md 定方向,本文定实现。只写**当前代码现状**。 | `src/api/` | `deps.py` `endpoints.py` `chat.py` `responses.py` `messages.py` `context.py` `models.py` `model_merge.py` `model_catalog.py` `balance.py` `authorize.py` `usage.py` `admin_credentials.py` `admin_keys.py` `admin_settings.py` `admin_users.py` `admin_audit.py` `admin_alerts.py` `admin_badges.py` `admin_upstream.py` `admin_model_catalog.py` `admin_model_availability.py` `admin_backups.py` `admin_billing.py` `logs.py` `activate.py` `admin_stats.py` `admin_auth.py` `playground.py` `streaming.py` `metrics.py` | Services 容器 + 鉴权依赖/`dispatch_compat` 骨架/三个 /v1 出口/压缩接线/`GET /v1/models`(SWR + 落盘 + 白名单)/归一·合并·别名(纯函数)/目录快照/余额/TRAE 回调/`GET /v1/usage` 自助用量/凭证运维/Key CRUD/配置与任务/用户/审计/告警/角标/上游状态/模型目录 + 探活/备份与恢复/月度账单/日志页/激活/统计/登录 + 上游登录/Playground/SSE 包装/`/metrics` | | `src/audit/actions.py` | — | 审计动作常量 + 中文标签 | | `web/` | — | React 前端。**`.npmrc` 的 `auto-install-peers=false` 与 lock 的 `settings.autoInstallPeers` 强耦合**(避免拉 `@lobehub/ui`/`antd` 子树);`Dockerfile` 必须 `COPY web/.npmrc`,否则 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` | -| `deploy/` | — | launchd / logrotate / systemd 模板;`caddy/` 为公网 Docker 部署的入口配置 | +| `deploy/` | — | launchd / logrotate / systemd 模板;`caddy/` 为公网 Docker 部署的入口配置(`Caddyfile` + 按 `TLS_MODE` import 的 `tls_acme` / `tls_internal` 两个 TLS 片段) | | `diagrams/` | — | 架构图 / 时序图 / 状态机(HTML + 源 JSON) | | `scripts/` | — | 建号、回填、运维脚本 | | `tests/` | — | 按 `src/` 子包镜像,外加 `test_key_quota.py` / `test_model_alias.py` / `test_upstream_status.py` 这类顶层模块对应用例 | diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index bc4ffbb9..32bae746 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -1,21 +1,35 @@ # 公网入口(Docker):Caddy 终止 TLS,反代到同网络的 coding2api:PORT。 # # 由 `docker compose -f docker-compose.yml -f docker-compose.public.yml` 启动, -# 环境变量来自 compose(PUBLIC_DOMAIN / ACME_EMAIL / PORT,见 .env)。 +# 环境变量来自 compose(PUBLIC_DOMAIN / ACME_EMAIL / TLS_MODE / PORT,见 .env)。 # 证书与 ACME 账号落 /data(compose 具名卷 caddy_data)——**别 `down -v`**, # 清掉会重新申请,撞 Let's Encrypt 每周同域名 5 次的下发限额。 # -# 本地校验语法(不起服务): +# TLS 两种模式(站点块首行按 TLS_MODE 二选一 import): +# - acme(默认):站点地址是域名(或 sslip.io / nip.io 这类通配 DNS 名)→ +# 自动向 Let's Encrypt 申请 / 续期,ACME_EMAIL 收到期通知。 +# - internal:站点地址是**公网 IP**、没有域名 → Caddy 用自带 CA 自签。 +# 证书浏览器不认(告警 / 需 `curl -k` 或装根证书),但链路仍是加密的。 +# Caddy 对 IP 站点地址本来就不会走 ACME,`tls internal` 只是显式声明。 +# +# 本地校验语法(不起服务、不联网签发): # docker run --rm -e PUBLIC_DOMAIN=ai-api.example.com -e ACME_EMAIL=ops@example.com \ -# -e PORT=8000 -v "$PWD/deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ +# -e PORT=8000 -e TLS_MODE=acme -v "$PWD/deploy/caddy:/etc/caddy:ro" \ # caddy:2.11.7-alpine caddy validate --config /etc/caddy/Caddyfile + { - # Let's Encrypt 到期通知收件人;compose 侧已要求必填(留空时 email 指令会少参数报错) - email {$ACME_EMAIL} + # 无 SNI 的客户端要有个默认站点名来选证书。浏览器与 curl 对 **IP 字面量** + # 一律不发 SNI(RFC 6066 只允许主机名),自签模式(站点是公网 IP)没有这行 + # 握手会直接失败(客户端只看到 `tlsv1 alert internal error`)。 + default_sni {$PUBLIC_DOMAIN} } {$PUBLIC_DOMAIN} { - # /metrics 无鉴权,公网入口直接摘掉,需要时从宿主 127.0.0.1:${PORT} 直取 + # tls_acme.caddyfile:issuer acme + ACME_EMAIL;tls_internal.caddyfile:tls internal + import /etc/caddy/tls_{$TLS_MODE}.caddyfile + + # /metrics 默认无鉴权(可选 METRICS_TOKEN,见 .env.example),公网入口直接摘掉, + # 需要时从宿主 127.0.0.1:${PORT} 直取(或自己按令牌放行)。 @metrics path /metrics respond @metrics 404 @@ -28,7 +42,8 @@ # 关掉响应缓冲:逐帧下发,否则流式首字要等到缓冲区满 flush_interval -1 # 客户端可自带 XFF 伪造来源 IP(API Key 的 allowed_ips):只认直连对端, - # 覆盖式写入单值。TRUST_PROXY=true 才让应用采信它。 + # 覆盖式写入单值(Caddy 默认也会忽略入站 X-Forwarded-For,这行是显式挡一层)。 + # TRUST_PROXY=true 才让应用采信它。 header_up X-Forwarded-For {http.request.remote.host} header_up X-Forwarded-Proto {scheme} transport http { diff --git a/deploy/caddy/tls_acme.caddyfile b/deploy/caddy/tls_acme.caddyfile new file mode 100644 index 00000000..464a65b0 --- /dev/null +++ b/deploy/caddy/tls_acme.caddyfile @@ -0,0 +1,13 @@ +# TLS 模式 acme(TLS_MODE=acme,默认):站点地址是域名 → 自动申请 / 续期证书。 +# +# 由 deploy/caddy/Caddyfile 的站点块 import。显式写 issuer 是为了固定签发方: +# 不写时 Caddy 默认会同时注册 Let's Encrypt 与 ZeroSSL 两个 ACME 账号(后者 +# 多数情况用不上,却会占一个账号)。只留 Let's Encrypt,靠站点地址判定签发。 +# +# ACME_EMAIL 必填(域名模式):既是 Let's Encrypt 的到期通知收件人,也是 ACME +# 账号标识。留空会在这里解析失败——报错指向本文件,属预期。 +tls { + issuer acme { + email {$ACME_EMAIL} + } +} diff --git a/deploy/caddy/tls_internal.caddyfile b/deploy/caddy/tls_internal.caddyfile new file mode 100644 index 00000000..b0cf9d35 --- /dev/null +++ b/deploy/caddy/tls_internal.caddyfile @@ -0,0 +1,11 @@ +# TLS 模式 internal(TLS_MODE=internal):站点地址是公网 IP、没有域名时用。 +# +# 由 deploy/caddy/Caddyfile 的站点块 import。Caddy 用自带 CA 自签证书: +# 链路加密,但证书不在任何公共信任库里——浏览器告警,命令行客户端要 `-k` +# 或把根证书装进客户端信任(根证书在 caddy_data 卷里: +# `/data/pki/authorities/local/root.crt`)。 +# +# 想要**被信任**的证书又没有自己的域名:把 PUBLIC_DOMAIN 填成 sslip.io / nip.io +# 这类公共通配 DNS 名(如 `203-0-113-10.sslip.io`,它解析到 203.0.113.10), +# 不用改模式、Caddy 照常走 ACME。详见 README「公网部署(Docker)」。 +tls internal diff --git a/docker-compose.public.yml b/docker-compose.public.yml index 2fba78c4..a79e3208 100644 --- a/docker-compose.public.yml +++ b/docker-compose.public.yml @@ -2,8 +2,9 @@ # # docker compose -f docker-compose.yml -f docker-compose.public.yml up -d --build # -# .env 新增两项:PUBLIC_DOMAIN(必填,域名,A/AAAA 记录已指向本机)、 -# ACME_EMAIL(必填,Let's Encrypt 到期通知)。详见 README「公网部署(Docker)」。 +# .env 新增:PUBLIC_DOMAIN(必填,域名或公网 IP;A / AAAA 记录已指向本机)、 +# ACME_EMAIL(域名模式必填,Let's Encrypt 到期通知)、TLS_MODE(可选, +# acme 默认 / internal 自签)。详见 README「公网部署(Docker)」。 # # 需要 Docker Compose ≥ 2.24(下方 `!override` 标签;老版本读不了会报错)。 @@ -16,7 +17,7 @@ services: environment: # PUBLIC_BASE_URL 决定 TRAE 登录回调基址与 HSTS 下发;https 才发 HSTS。 # 它的主机名也自动进入 Host 白名单(ALLOWED_HOSTS 留空时的口径)。 - PUBLIC_BASE_URL: https://${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env (e.g. ai-api.example.com)} + PUBLIC_BASE_URL: https://${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env (domain, or public IP with TLS_MODE=internal)} # 前恰好一层 Caddy 且应用端口只在回环 → 采信 XFF 是安全的 TRUST_PROXY: "true" # Caddy 反代走 compose 网络,容器内必须监听 0.0.0.0;.env 里沿用 @@ -37,8 +38,11 @@ services: coding2api: condition: service_healthy environment: - PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env} - ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in .env (Let's Encrypt expiry notices)} + PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env (domain, or public IP with TLS_MODE=internal)} + ACME_EMAIL: ${ACME_EMAIL:-} + # acme(默认):站点地址是域名 → 自动申请 / 续期;ACME_EMAIL 必填。 + # internal:站点地址是公网 IP → 用 Caddy 自带 CA 自签(浏览器告警)。 + TLS_MODE: ${TLS_MODE:-acme} PORT: ${PORT:-8000} ports: # 80:ACME HTTP-01 挑战 + 之后 308 跳 https;443/udp 是 HTTP/3 @@ -46,7 +50,8 @@ services: - "443:443" - "443:443/udp" volumes: - - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + # 整个目录挂进去:站点块按 TLS_MODE import tls_{$TLS_MODE}.caddyfile + - ./deploy/caddy:/etc/caddy:ro # 证书、ACME 账号与 Caddy 状态:必须持久化,否则每次重建都重新签发 - caddy_data:/data - caddy_config:/config @@ -59,4 +64,4 @@ services: volumes: caddy_data: - caddy_config: \ No newline at end of file + caddy_config: diff --git a/tests/deploy/test_assets.py b/tests/deploy/test_assets.py index 10fcbcc3..81eabd54 100644 --- a/tests/deploy/test_assets.py +++ b/tests/deploy/test_assets.py @@ -1048,3 +1048,146 @@ def test_install_frpc_renders_placeholders_and_installs_agent(): assert 'TOKEN_FILE="$ROOT/secrets/frpc-token"' in text assert "umask 077" in text, "token 文件必须按 600 落盘" assert INSTALL_FRPC.stat().st_mode & 0o111, "安装脚本必须可执行" + + +# ------------------------------------------------- 公网部署(Docker + Caddy 自动 HTTPS) +# +# 有公网 IP 的 VPS 用覆盖文件在基础 compose 上叠一个 Caddy 入口:Caddy 终止 TLS、 +# 应用端口只绑回环、证书长期落具名卷。这里锁死的是「改了就出事且 CI 不一定报」 +# 的不变量:回环绑定(不给明文旁路)、ALLOWED_HOSTS 补回环(否则 healthcheck +# 永远 400 → 应用永远 unhealthy → Caddy 永远不启动)、XFF 覆盖式写入(否则 +# 客户端可伪造来源 IP 绕过 Key 的 allowed_ips)、default_sni(否则 IP 字面量 +# 客户端握手直接失败)。 + +PUBLIC_COMPOSE = ROOT / "docker-compose.public.yml" +CADDY_DIR = ROOT / "deploy" / "caddy" +CADDYFILE = CADDY_DIR / "Caddyfile" +CADDYFILE_ACME = CADDY_DIR / "tls_acme.caddyfile" +CADDYFILE_INTERNAL = CADDY_DIR / "tls_internal.caddyfile" + + +def test_public_compose_needs_compose_tls_override_tag(): + """`!override` 需要 Compose ≥ 2.24;老版本读不懂会直接报错。""" + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert "ports: !override" in text, "替换基础 compose 的端口映射必须用 !override" + assert "2.24" in text, "依赖的 Compose 最低版本要写在文件头注释里" + + +def test_public_compose_binds_app_port_to_loopback_only(): + """应用端口只绑 127.0.0.1:绑 0.0.0.0 会留一条明文 http 旁路。 + + TRUST_PROXY=true 后直连还能自带伪造的 X-Forwarded-For 绕过 Key 的 + allowed_ips,所以这条旁路必须彻底关掉。 + """ + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert '"127.0.0.1:${PORT:-8000}:${PORT:-8000}"' in text + bindings = re.findall(r'^\s+-\s+"?([^"\n]*\$\{PORT[^"\n]*)"?$', text, re.MULTILINE) + assert bindings, "覆盖文件里应有应用端口映射" + assert not [b for b in bindings if b.startswith("0.0.0.0:")], \ + f"应用端口不得绑 0.0.0.0(明文旁路): {bindings}" + + +def test_public_compose_trusts_proxy_and_pins_allowed_hosts(): + """HOST/PORT 重心、ALLOWED_HOSTS 必须显式补域名与回环。 + + 非空即「完全替换」语义:漏掉域名 → 全站 400 invalid host header; + 漏掉 127.0.0.1 → 容器 healthcheck 判 400 → 永远 unhealthy, + 而 Caddy `depends_on: service_healthy` 也就永远不启动。 + """ + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert 'TRUST_PROXY: "true"' in text + assert "HOST: 0.0.0.0" in text + allowed = re.search(r"^\s+ALLOWED_HOSTS: (.+)$", text, re.MULTILINE) + assert allowed, "覆盖文件必须显式覆盖 ALLOWED_HOSTS" + value = allowed.group(1) + assert "${PUBLIC_DOMAIN:?" in value and "127.0.0.1" in value, value + + +def test_public_compose_does_not_require_acme_email_for_self_signed_mode(): + """ACME_EMAIL 只在域名模式必填:自签模式(公网 IP)用不到它。 + + 要求写成 `${ACME_EMAIL:-}`(可空)——写成 `${ACME_EMAIL:?}` 会让「没有域名」 + 的部署也必须编一个邮箱,而 tls_internal 片段根本不引用它。 + """ + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert "${ACME_EMAIL:-}" in text + assert "${ACME_EMAIL:?" not in text, "自签模式不该被 ACME_EMAIL 卡住" + assert "TLS_MODE: ${TLS_MODE:-acme}" in text, "TLS_MODE 默认必须是 acme" + + +def test_public_compose_requires_public_domain(): + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert "${PUBLIC_DOMAIN:?" in text, "缺 PUBLIC_DOMAIN 必须直接失败,不能静默用默认值" + + +def test_public_compose_mounts_whole_caddy_dir_and_cert_volumes(): + """整个 deploy/caddy 必须挂进去(站点块要按 TLS_MODE import 片段)。""" + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert "./deploy/caddy:/etc/caddy:ro" in text + assert "caddy_data:/data" in text and "caddy_config:/config" in text + # 证书落具名卷才不随重建重新签发(撞 Let's Encrypt 每周 5 次限额) + assert re.search(r"^volumes:\s*$", text, re.MULTILINE) + + +def test_public_compose_waits_for_healthy_app_and_rotates_caddy_logs(): + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert re.search(r"coding2api:\s*\n\s+condition: service_healthy", text), \ + "Caddy 必须等应用 healthy,否则预热期持续 502" + assert 'max-size: "10m"' in text, "Caddy 也要有 json-file 日志上限(默认无上限)" + assert 'max-file: "5"' in text + + +def test_caddyfile_hides_metrics_and_streams_sse(): + text = CADDYFILE.read_text(encoding="utf-8") + assert "@metrics path /metrics" in text and "respond @metrics 404" in text + assert "flush_interval -1" in text, "不关响应缓冲会让流式首字等到缓冲区满" + assert "encode zstd gzip" in text + + +def test_caddyfile_overwrites_forwarded_for_and_proto(): + """客户端可自带伪造的 X-Forwarded-For:必须覆盖式写入单值。""" + text = CADDYFILE.read_text(encoding="utf-8") + assert "header_up X-Forwarded-For {http.request.remote.host}" in text + assert "header_up X-Forwarded-Proto {scheme}" in text + + +def test_caddyfile_sets_default_sni_for_ip_literal_clients(): + """浏览器 / curl 对 IP 字面量不发 SNI(RFC 6066 只允许主机名)。 + + 自签模式(站点是公网 IP)没有 default_sni 就选不出证书,客户端只看到 + `tlsv1 alert internal error` ——握手直接失败,不是「证书不受信任」。 + """ + text = CADDYFILE.read_text(encoding="utf-8") + assert "default_sni {$PUBLIC_DOMAIN}" in text + + +def test_caddyfile_selects_tls_snippet_by_mode(): + text = CADDYFILE.read_text(encoding="utf-8") + assert "import /etc/caddy/tls_{$TLS_MODE}.caddyfile" in text + + +def test_caddy_tls_snippets_cover_both_modes(): + """acme 走 Let's Encrypt、internal 自签;两个片段都要在,且互不越界。""" + acme = CADDYFILE_ACME.read_text(encoding="utf-8") + internal = CADDYFILE_INTERNAL.read_text(encoding="utf-8") + assert "issuer acme" in acme and "email {$ACME_EMAIL}" in acme + assert "tls internal" in internal + # 片段只该管 TLS,别把站点级指令也塞进来(两处配置必须同源) + assert "reverse_proxy" not in acme + internal + # internal 片段不得引用 ACME_EMAIL:自签模式没配它,引用就会解析失败 + assert "{$ACME_EMAIL}" not in internal + + +def test_env_example_documents_public_deploy_knobs(): + text = (ROOT / ".env.example").read_text(encoding="utf-8") + for name in ("PUBLIC_DOMAIN=", "ACME_EMAIL=", "TLS_MODE="): + assert name in text, f".env.example 缺少 {name}" + + +def test_ci_validates_public_deploy_in_both_tls_modes(): + """CI 必须把两种 TLS 模式都 validate 一遍,否则改了片段 CI 也不会报。""" + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + assert "for mode in acme internal" in workflow + assert 'TLS_MODE="$mode"' in workflow + assert "-v \"$PWD/deploy/caddy:/etc/caddy:ro\"" in workflow + assert "TLS_MODE=internal" in workflow, "compose 覆盖文件也要按自签模式拼一遍"