Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ ENABLE_DOCS=false
# 是否采信 X-Forwarded-For(仅当本服务前面恰好一层受信反代时开启)。
TRUST_PROXY=false

# ---- 公网部署(Docker + Caddy,见 README「公网部署(Docker)」)----
# 有公网 IP 的 VPS:填域名即自动申请 / 续期证书;本地与内网部署保持留空
# (留空时 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 等一律不采信。
# ⚠️ 容器里 127.0.0.1 指容器自身,不是宿主:代理跑在宿主时须写
Expand Down
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,36 @@ jobs:
run: docker compose config --quiet
env:
APP_SECRET: ci-placeholder-secret
- name: 校验 compose 公网覆盖文件语法(Caddy 入口,两种 TLS 模式)
env:
APP_SECRET: ci-placeholder-secret
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 不联网签发)
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
Expand Down
56 changes: 56 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tag>` + `: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.
Expand Down Expand Up @@ -786,6 +788,60 @@ 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`), 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 (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
```

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/`) | 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=<public IP>` + `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:

- **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`.
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` → 发 `<tag>` + `:latest`。

容器跑在**有公网 IP 的 VPS** 上要 https 入口 → 见「[公网部署(Docker + Caddy 自动 HTTPS)](#公网部署docker--caddy-自动-https)」。

## 用户与角色

账号存 SQLite `users` 表;「用户管理」页(仅管理员)增删改。
Expand Down Expand Up @@ -805,6 +807,60 @@ 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`)、站点地址(域名,或见下「只有公网 IP、没有域名」)的 A / AAAA 记录已指向本机、80 与 443(tcp + udp)空闲(80 要留给 ACME HTTP-01)。

`.env` 补两项(其余同「快速开始」):

```bash
PUBLIC_DOMAIN=ai-api.example.com # 必填:证书按它签发;没有域名时填公网 IP(见下)
ACME_EMAIL=ops@example.com # 域名模式必填:Let's Encrypt 到期通知
# TLS_MODE= # 留空 = 自动申请证书;公网 IP 自签填 internal(见下)
# 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/`) | 自动 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`。已默认写入,不用手改。

注意:

- **证书落具名卷 `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`。
Expand Down
4 changes: 2 additions & 2 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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 部署的入口配置(`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` 这类顶层模块对应用例 |
Expand Down
Loading
Loading