diff --git a/.github/workflows/_container.yml b/.github/workflows/_container.yml index f6f494266..e132b5bad 100644 --- a/.github/workflows/_container.yml +++ b/.github/workflows/_container.yml @@ -19,21 +19,24 @@ jobs: env: COMPOSE_PROJECT_NAME: cpr-ci-${{ github.run_id }}-${{ github.run_attempt }} CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }} - CPR_ADMIN_DEFAULT_PASSWORD: 'admin @:/?#%2F ci password' - CPR_POSTGRES_PASSWORD: 'pg @:/?#%2F ci' - CPR_REDIS_PASSWORD: 'redis @:/?#%2F ci' - CPR_SMOKE_PORT: '18080' TRIVY_QUIET: 'true' TRIVY_SKIP_VERSION_CHECK: 'true' steps: - - name: Define isolated runtime paths + - name: Define isolated runtime values shell: bash run: | + set -euo pipefail + admin_password="$(openssl rand -hex 24)" + postgres_password="$(openssl rand -hex 24)" + redis_password="$(openssl rand -hex 24)" + echo "::add-mask::$admin_password" + echo "::add-mask::$postgres_password" + echo "::add-mask::$redis_password" { - echo "CPR_CONFIG_FILE=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/config.yaml" - echo "CPR_DATA_DIR=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/data" - echo "CPR_LOG_DIR=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/logs" echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics" + echo "CI_ADMIN_PASSWORD=$admin_password" + echo "CI_POSTGRES_PASSWORD=$postgres_password" + echo "CI_REDIS_PASSWORD=$redis_password" } >> "$GITHUB_ENV" - name: Checkout @@ -94,19 +97,33 @@ jobs: shell: bash run: | set -euo pipefail - runtime_dir="$(dirname "$CPR_CONFIG_FILE")" - mkdir -p "$runtime_dir" "$DIAGNOSTICS_DIR" - sudo install -d -m 0770 -o "$(id -u)" -g 10001 "$CPR_DATA_DIR" "$CPR_LOG_DIR" - cp deploy/config.example.yaml "$CPR_CONFIG_FILE" - sudo chown "$(id -u):10001" "$CPR_CONFIG_FILE" - chmod 0640 "$CPR_CONFIG_FILE" + umask 0077 + install -d -m 0750 .runtime/postgres .runtime/redis + mkdir -p "$DIAGNOSTICS_DIR" + sudo install -d -m 0770 -o "$(id -u)" -g 10001 \ + .runtime/data .runtime/logs + ruby <<'RUBY' + path = "deploy/config.example.yaml" + content = File.read(path) + replacements = { + "password: &postgres_password ''" => "password: &postgres_password '#{ENV.fetch("CI_POSTGRES_PASSWORD")}'", + "password: &redis_password ''" => "password: &redis_password '#{ENV.fetch("CI_REDIS_PASSWORD")}'", + "default_password: ''" => "default_password: '#{ENV.fetch("CI_ADMIN_PASSWORD")}'", + } + replacements.each do |placeholder, configured| + raise "missing config placeholder: #{placeholder}" unless content.sub!(placeholder, configured) + end + File.write("deploy/config.yaml", content) + RUBY + sudo chown "$(id -u):10001" deploy/config.yaml + chmod 0640 deploy/config.yaml - name: Validate Docker Compose shell: bash run: | set -euo pipefail docker compose --project-name "$COMPOSE_PROJECT_NAME" \ - -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml config --quiet + -f deploy/compose.yaml config --quiet - name: Configure Redis host memory overcommit shell: bash @@ -120,25 +137,25 @@ jobs: run: | set -euo pipefail compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \ - -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml) + -f deploy/compose.yaml) "${compose[@]}" up -d --no-build --wait postgres redis - test "$("${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \ + test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \ psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai - "${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \ + "${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \ psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \ "CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');" - "${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \ + "${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \ redis-cli SET cpr:ci:persistence-probe persisted "${compose[@]}" up -d --no-build --wait --force-recreate postgres redis - test "$("${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \ + test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \ psql -At -U codex_proxy -d codex_proxy -c \ "SELECT value FROM cpr_ci_persistence_probe;")" = persisted - test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \ + test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \ redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted - "${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \ + "${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \ psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \ "DROP TABLE cpr_ci_persistence_probe;" - "${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \ + "${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \ redis-cli DEL cpr:ci:persistence-probe - name: Start and verify application @@ -146,24 +163,24 @@ jobs: run: | set -euo pipefail compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \ - -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml) + -f deploy/compose.yaml) "${compose[@]}" up -d --no-build --wait codex-proxy-rs status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ - "http://127.0.0.1:${CPR_SMOKE_PORT}/healthz")" + "http://127.0.0.1:8080/healthz")" test "$status" = 204 test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001 test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001 test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800 test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai "${compose[@]}" exec -T codex-proxy-rs sh -c \ - 'touch /app/data/.ci-write /app/logs/.ci-write && rm /app/data/.ci-write /app/logs/.ci-write' + 'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write' - name: Verify clean logs and graceful shutdown shell: bash run: | set -euo pipefail compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \ - -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml) + -f deploy/compose.yaml) "${compose[@]}" stop --timeout 30 codex-proxy-rs container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)" test -n "$container_id" @@ -195,7 +212,7 @@ jobs: mkdir -p "$DIAGNOSTICS_DIR" raw_dir="$(mktemp -d)" compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \ - -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml) + -f deploy/compose.yaml) "${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1 "${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1 "${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1 @@ -207,9 +224,9 @@ jobs: raw_dir = ENV.fetch("RAW_DIR") output_dir = ENV.fetch("OUTPUT_DIR") secrets = [ - ENV.fetch("CPR_ADMIN_DEFAULT_PASSWORD"), - ENV.fetch("CPR_POSTGRES_PASSWORD"), - ENV.fetch("CPR_REDIS_PASSWORD"), + ENV.fetch("CI_ADMIN_PASSWORD"), + ENV.fetch("CI_POSTGRES_PASSWORD"), + ENV.fetch("CI_REDIS_PASSWORD"), ] encoded_secrets = secrets.map do |secret| secret.encode("UTF-8").bytes.map do |byte| @@ -243,6 +260,5 @@ jobs: shell: bash run: | docker compose --project-name "$COMPOSE_PROJECT_NAME" \ - -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml \ - down --volumes --remove-orphans || true - sudo rm -rf "$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + -f deploy/compose.yaml down --remove-orphans || true + sudo rm -rf "$GITHUB_WORKSPACE/.runtime" "$GITHUB_WORKSPACE/deploy/config.yaml" diff --git a/.gitignore b/.gitignore index f5102e4c8..0ac3a6142 100644 --- a/.gitignore +++ b/.gitignore @@ -19,13 +19,7 @@ *.log # Local configuration and secrets -.env -.env.* -!.env.example -!**/.env.example -/.envrc -/config.yaml -/backend/config.yaml +/deploy/config.yaml # Editors and operating systems /.idea/ diff --git a/README.md b/README.md index dc343be3d..00315a417 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,6 @@ -后端使用 Rust、Tokio 和 Axum,管理界面使用 Vue 3、TypeScript 和 Vite。SSE 数据边收边转发,上游 WebSocket 连接按账号复用;前端构建后由同一个服务直接提供。 - > [!NOTE] > 当前兼容 OpenAI Responses API,不提供 `/v1/chat/completions`。 @@ -44,43 +42,46 @@ git clone https://github.com/zyycn/codex-proxy-rs.git cd codex-proxy-rs mkdir -p .runtime/data .runtime/logs -cp deploy/config.example.yaml .runtime/config.yaml -cp deploy/.env.example deploy/.env -chmod 0600 deploy/.env +install -d -m 0750 .runtime/postgres .runtime/redis +cp deploy/config.example.yaml deploy/config.yaml +sudo chown "$(id -u):10001" deploy/config.yaml +chmod 0640 deploy/config.yaml ``` -编辑 `deploy/.env`: +分别执行三次以下命令生成密码: -```dotenv -CPR_ADMIN_DEFAULT_PASSWORD='<至少 12 个字符的管理员密码>' -CPR_POSTGRES_PASSWORD='' -CPR_REDIS_PASSWORD='' +```bash +openssl rand -hex 24 ``` +把结果填入 `deploy/config.yaml` 的 `database.password`、`redis.password` 和 +`admin.default_password`。PostgreSQL 与 Redis 密码必须是 48 位十六进制字符;管理员密码 +至少 12 位且不能包含 `$`。 + > [!IMPORTANT] > 应用容器以非 root 用户 `10001:10001` 运行。Linux Bind Mount 会保留宿主机权限,因此需要允许容器用户组读取配置并写入数据和日志目录: > > ```bash > sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs > chmod 0770 .runtime/data .runtime/logs -> sudo chown "$(id -u):10001" .runtime/config.yaml -> chmod 0640 .runtime/config.yaml > ``` > -> macOS 和 Windows 的 Docker Desktop 通常不需要设置这些权限。 +> 配置文件通过 Compose `configs` 只读挂载;普通 Compose 会保留宿主机权限,因此由 +> 当前用户持有、仅向容器组开放读取权限。 +> macOS 和 Windows 的 Docker Desktop 通常也不需要调整数据目录权限。 ### 2. 启动服务 ```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml config --quiet -docker compose --env-file deploy/.env -f deploy/docker-compose.yml pull -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --no-build +docker compose -f deploy/compose.yaml config --quiet +docker compose -f deploy/compose.yaml pull +docker compose -f deploy/compose.yaml up -d --no-build ``` 启动后检查: ```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml ps +docker compose -f deploy/compose.yaml ps curl -i http://127.0.0.1:8080/healthz ``` @@ -88,14 +89,14 @@ curl -i http://127.0.0.1:8080/healthz ## 首次使用 -1. 使用 `admin@cpr.local` 和 `CPR_ADMIN_DEFAULT_PASSWORD` 中设置的密码登录。 +1. 使用 `admin@cpr.local` 和 `x-cpr.admin.default_password` 中设置的密码登录。 2. 打开“账号”,用 OAuth、Access/Refresh Token 或 JSON 文件添加账号。 3. 点一次连接测试,能看到模型和额度即可。 4. 打开“API 密钥”,创建一个 `sk_...` 客户端 Key。 5. 点击“使用密钥”复制 Codex CLI 配置,或一键导入 CCSwitch。 > [!TIP] -> `CPR_ADMIN_DEFAULT_PASSWORD` 只用于首次创建管理员。已有管理员后修改该变量不会重置登录密码,但 Compose 启动时仍要求提供有效值。 +> `admin.default_password` 只用于首次创建管理员。已有管理员后修改该字段不会重置登录密码。 ## 客户端接入 @@ -158,25 +159,27 @@ curl http://127.0.0.1:8080/v1/responses \ ### 升级 ```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml pull codex-proxy-rs -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --no-build +docker compose -f deploy/compose.yaml pull codex-proxy-rs +docker compose -f deploy/compose.yaml up -d --no-build ``` ### 查看日志 ```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs -f codex-proxy-rs +docker compose -f deploy/compose.yaml logs -f codex-proxy-rs ``` ### 从当前源码构建 ```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml build codex-proxy-rs -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d +docker compose -f deploy/compose.yaml build codex-proxy-rs +docker compose -f deploy/compose.yaml up -d ``` > [!WARNING] -> 不要使用 `docker compose down -v` 进行普通升级,它会删除 PostgreSQL 和 Redis 数据卷。请同时备份 PostgreSQL 与 `.runtime/data/identity_hmac_secret`;数据库备份中包含客户端凭据,应按敏感数据保管。 +> PostgreSQL、Redis、应用数据和文件日志都保存在仓库根目录 `.runtime/`。普通 +> `docker compose down` 不会删除这些绑定目录;删除 `.runtime/` 才会清除数据。升级前请备份 +> 整个目录,数据库备份中包含客户端凭据,应按敏感数据保管。 ## 常见问题 @@ -185,13 +188,14 @@ docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d 检查 PostgreSQL 和 Redis 容器是否健康: ```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml ps -docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs postgres redis +docker compose -f deploy/compose.yaml ps +docker compose -f deploy/compose.yaml logs postgres redis ``` ### 容器反复重启 -检查 `deploy/.env` 是否填写完整,以及 `.runtime/config.yaml`、`.runtime/data`、`.runtime/logs` 的权限是否正确。 +检查 `deploy/config.yaml` 的三个密码是否填写完整,以及 `.runtime/data`、`.runtime/logs` +的权限是否正确。 ### 需要从其他设备访问 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8f25c45ad..891f929be 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -359,7 +359,6 @@ dependencies = [ "bytes", "chrono", "config", - "dirs", "flate2", "futures", "hex", @@ -368,7 +367,6 @@ dependencies = [ "hyper-util", "indexmap", "ipnet", - "percent-encoding", "rand 0.10.2", "rand_core 0.6.4", "redis", @@ -653,27 +651,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -1388,15 +1365,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libredox" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "libc", -] - [[package]] name = "libsqlite3-sys" version = "0.37.0" @@ -1560,12 +1528,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "parking" version = "2.2.1" @@ -1856,17 +1818,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - [[package]] name = "regex" version = "1.13.0" @@ -2053,6 +2004,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" dependencies = [ + "serde", "zeroize", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index bd40ecfdb..23622fec1 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -13,7 +13,6 @@ base64 = "0.22.1" bytes = "1.11.1" chrono = { version = "0.4.45", features = ["serde", "clock"] } config = { version = "0.15.23", default-features = false, features = ["yaml"] } -dirs = "6.0.0" flate2 = "1.1.9" futures = "0.3.32" hex = "0.4.3" @@ -22,7 +21,6 @@ hyper = { version = "1.10.1", features = ["http1", "server"] } hyper-util = { version = "0.1.20", features = ["tokio"] } indexmap = { version = "2.0", features = ["serde"] } ipnet = "2.12.0" -percent-encoding = "2.3.2" rand = "0.10.1" # Enables password_hash::rand_core::OsRng for argon2 0.5. rand_core = { version = "0.6.4", features = ["getrandom"] } @@ -32,7 +30,7 @@ reqwest = { version = "=0.12.28", default-features = false, features = ["json", rustls = { version = "=0.23.36", default-features = false, features = ["ring", "std", "tls12"] } rustls-native-certs = "0.8.4" rustls-pki-types = "1.14.1" -secrecy = "0.10.3" +secrecy = { version = "0.10.3", features = ["serde"] } semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.150", features = ["preserve_order"] } diff --git a/backend/src/api/admin/auth_routes.rs b/backend/src/api/admin/auth_routes.rs index 3ccf41605..68af4ce78 100644 --- a/backend/src/api/admin/auth_routes.rs +++ b/backend/src/api/admin/auth_routes.rs @@ -114,7 +114,7 @@ pub(crate) async fn logout( .delete_session(&session_id) .await { - tracing::warn!(error = %error, "failed to revoke admin session during logout"); + tracing::warn!(error = %error, "Failed to revoke admin session during logout"); } let mut response = AdminResponse::new( diff --git a/backend/src/api/admin/dashboard_routes.rs b/backend/src/api/admin/dashboard_routes.rs index 32321d1a7..2c3cc737d 100644 --- a/backend/src/api/admin/dashboard_routes.rs +++ b/backend/src/api/admin/dashboard_routes.rs @@ -419,7 +419,7 @@ async fn account_quota_used_percent_by_id( } fn dashboard_data_error(source: &'static str, error: &impl std::fmt::Display) -> AdminError { - tracing::error!(source, error = %error, "failed to load dashboard data"); + tracing::error!(source, error = %error, "Failed to load dashboard data"); AdminError::internal("Failed to load dashboard data") } diff --git a/backend/src/api/client/auth.rs b/backend/src/api/client/auth.rs index 89c07aa69..bfe124213 100644 --- a/backend/src/api/client/auth.rs +++ b/backend/src/api/client/auth.rs @@ -93,13 +93,13 @@ fn log_client_api_key_auth_failure(error: &ClientApiKeyAuthError) { tracing::warn!( auth_failure = error.reason(), error = %message, - "client api key authorization failed" + "Client API key authorization failed" ); } _ => { tracing::info!( auth_failure = error.reason(), - "client api key authorization failed" + "Client API key authorization failed" ); } } diff --git a/backend/src/api/client/responses/websocket.rs b/backend/src/api/client/responses/websocket.rs index 62413bf92..0053df7c4 100644 --- a/backend/src/api/client/responses/websocket.rs +++ b/backend/src/api/client/responses/websocket.rs @@ -72,7 +72,7 @@ pub async fn responses_websocket(State(state): State, mut request: Req tracing::info!( websocket_connection_id = %connection_id, error = %error, - "invalid Responses WebSocket upgrade request" + "Invalid Responses WebSocket upgrade request" ); return openai_error_response( StatusCode::BAD_REQUEST, @@ -453,7 +453,7 @@ async fn forward_dispatch_stream( let events = match decoder.push(&chunk) { Ok(events) => events, Err(error) => { - tracing::warn!(request_id, error = %error, "failed to decode Responses SSE for WebSocket forwarding"); + tracing::warn!(request_id, error = %error, "Failed to decode Responses SSE for WebSocket forwarding"); let sent = send_error( socket, StatusCode::BAD_GATEWAY, @@ -507,7 +507,7 @@ async fn forward_dispatch_stream( let events = match decoder.finish() { Ok(events) => events, Err(error) => { - tracing::warn!(request_id, error = %error, "failed to finish Responses SSE decoding"); + tracing::warn!(request_id, error = %error, "Failed to finish Responses SSE decoding"); return if send_error( socket, StatusCode::BAD_GATEWAY, diff --git a/backend/src/api/middleware/trace.rs b/backend/src/api/middleware/trace.rs index 3ae408c58..b079a6720 100644 --- a/backend/src/api/middleware/trace.rs +++ b/backend/src/api/middleware/trace.rs @@ -48,7 +48,7 @@ fn make_http_span(request: &Request) -> Span { } fn on_http_request(_request: &Request, _span: &Span) { - debug!("received HTTP request"); + debug!("Received HTTP request"); } fn on_http_response(response: &Response, latency: Duration, _span: &Span) { @@ -58,7 +58,7 @@ fn on_http_response(response: &Response, latency: Duration, _span: &Span) info!( status = response.status().as_u16(), latency_ms = latency.as_millis(), - "completed HTTP request" + "Completed HTTP request" ); } @@ -70,7 +70,7 @@ fn on_http_failure(failure_class: ServerErrorsFailureClass, latency: Duration, _ warn!( failure = %failure_class, latency_ms = latency.as_millis(), - "failed HTTP request" + "Failed HTTP request" ); } diff --git a/backend/src/api/router.rs b/backend/src/api/router.rs index ccf04be7e..5181ca5e3 100644 --- a/backend/src/api/router.rs +++ b/backend/src/api/router.rs @@ -96,7 +96,7 @@ pub fn router_with_assets(dist_dir: impl AsRef) -> Router { async fn healthz(State(state): State) -> StatusCode { if let Err(error) = state.services.health_probe.check().await { - tracing::warn!(error, "health check failed"); + tracing::warn!(error, "Health check failed"); return StatusCode::SERVICE_UNAVAILABLE; } StatusCode::NO_CONTENT diff --git a/backend/src/bootstrap/config.rs b/backend/src/bootstrap/config.rs index 07452a645..d36844269 100644 --- a/backend/src/bootstrap/config.rs +++ b/backend/src/bootstrap/config.rs @@ -1,9 +1,23 @@ -use std::{collections::BTreeMap, env, path::Path}; +use std::{ + collections::{BTreeMap, HashSet}, + env, + path::{Path, PathBuf}, +}; -use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; -use serde::{Deserialize, Serialize}; +use axum::http::HeaderName; +use secrecy::{ExposeSecret, SecretBox}; +use serde::{Deserialize, de::IgnoredAny}; +use tracing_subscriber::EnvFilter; use url::Url; +const CONFIG_SCHEMA_VERSION: u32 = 1; +const CONFIG_RELATIVE_PATH: &str = "deploy/config.yaml"; +const SERVER_HOST_ENV: &str = "CPR_SERVER_HOST"; +const SERVER_PORT_ENV: &str = "CPR_SERVER_PORT"; +const DATABASE_URL_ENV: &str = "CPR_DATABASE_URL"; +const REDIS_URL_ENV: &str = "CPR_REDIS_URL"; +const SERVICE_PASSWORD_HEX_LENGTH: usize = 48; + const WEAK_ADMIN_PASSWORDS: &[&str] = &[ "", "admin", @@ -15,49 +29,234 @@ const WEAK_ADMIN_PASSWORDS: &[&str] = &[ "codex-proxy-rs", ]; -/// 应用运行总配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] +type SecretValue = SecretBox; + +/// 已加载并完成校验的启动配置。 +/// +/// 数据库、Redis 和管理员初始化密码只在启动阶段保留,并在释放时清零。 +#[derive(Debug)] +pub struct BootstrapConfig { + app: AppConfig, + database_url: SecretValue, + redis_url: SecretValue, + admin_default_password: SecretValue, +} + +impl BootstrapConfig { + /// 从当前目录或父目录中的 `deploy/config.yaml` 加载配置。 + pub fn load() -> Result { + let current_directory = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?; + let config_path = discover_config_path(¤t_directory)?; + let overrides = TopologyOverrides::from_environment()?; + Self::load_from_path_with_overrides(config_path, overrides) + } + + /// 从指定 YAML 文件加载配置,不应用容器拓扑覆盖。 + pub fn load_from_path(config_path: impl AsRef) -> Result { + Self::load_from_path_with_overrides(config_path, TopologyOverrides::default()) + } + + /// 从指定 YAML 文件加载配置,并应用明确的容器拓扑覆盖。 + pub fn load_from_path_with_overrides( + config_path: impl AsRef, + overrides: TopologyOverrides, + ) -> Result { + let config_path = absolute_path(config_path.as_ref())?; + let config_directory = config_path.parent().ok_or(ConfigError::InvalidConfigPath)?; + + let document: ConfigDocument = ::config::Config::builder() + .add_source(::config::File::from(config_path.as_path()).required(true)) + .build() + .and_then(::config::Config::try_deserialize) + .map_err(|_| ConfigError::InvalidDocument { + path: config_path.clone(), + })?; + + let ConfigDocument { + cpr: mut file_config, + _services: _, + } = document; + if file_config.schema_version != CONFIG_SCHEMA_VERSION { + return Err(ConfigError::UnsupportedSchemaVersion); + } + + overrides.apply(&mut file_config); + validate_file_config(&file_config)?; + + resolve_relative_path(config_directory, &mut file_config.runtime.data_directory); + resolve_relative_path(config_directory, &mut file_config.logging.file.directory); + + let database_url = connection_url_with_password( + &file_config.database.url, + &file_config.database.password, + "database.url", + )?; + let redis_url = connection_url_with_password( + &file_config.redis.url, + &file_config.redis.password, + "redis.url", + )?; + + let FileAppConfig { + schema_version: _, + server, + api, + database, + redis, + runtime, + quota, + tls, + ws_pool, + fingerprint, + admin, + logging, + telemetry, + } = file_config; + + let app = AppConfig { + server, + api, + model_aliases: BTreeMap::new(), + auth: AuthConfig::default(), + quota, + database: DatabaseConfig { url: database.url }, + redis: RedisConfig { url: redis.url }, + runtime, + tls, + ws_pool, + fingerprint, + admin: AdminConfig { + session_ttl_minutes: admin.session_ttl_minutes, + default_username: admin.default_username, + }, + logging, + telemetry, + }; + + Ok(Self { + app, + database_url, + redis_url, + admin_default_password: admin.default_password, + }) + } + + /// 返回不包含启动密码的应用运行配置。 + pub fn app(&self) -> &AppConfig { + &self.app + } + + /// 返回已安全注入密码的 PostgreSQL 连接地址。 + pub fn database_url(&self) -> &str { + self.database_url.expose_secret() + } + + /// 返回已安全注入密码的 Redis 连接地址。 + pub fn redis_url(&self) -> &str { + self.redis_url.expose_secret() + } + + pub(crate) fn into_parts(self) -> BootstrapConfigParts { + BootstrapConfigParts { + app: self.app, + database_url: self.database_url, + redis_url: self.redis_url, + admin_default_password: self.admin_default_password, + } + } +} + +pub(crate) struct BootstrapConfigParts { + pub(crate) app: AppConfig, + pub(crate) database_url: SecretValue, + pub(crate) redis_url: SecretValue, + pub(crate) admin_default_password: SecretValue, +} + +/// Docker Compose 对容器内部网络拓扑的固定覆盖。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TopologyOverrides { + /// 容器内监听地址。 + pub server_host: Option, + /// 容器内监听端口。 + pub server_port: Option, + /// 容器内 PostgreSQL 地址,不包含密码。 + pub database_url: Option, + /// 容器内 Redis 地址,不包含密码。 + pub redis_url: Option, +} + +impl TopologyOverrides { + fn from_environment() -> Result { + let server_port = optional_environment_value(SERVER_PORT_ENV)? + .map(|value| { + value + .parse::() + .ok() + .filter(|port| *port > 0) + .ok_or(ConfigError::InvalidTopologyOverride(SERVER_PORT_ENV)) + }) + .transpose()?; + + Ok(Self { + server_host: optional_environment_value(SERVER_HOST_ENV)?, + server_port, + database_url: optional_environment_value(DATABASE_URL_ENV)?, + redis_url: optional_environment_value(REDIS_URL_ENV)?, + }) + } + + fn apply(self, config: &mut FileAppConfig) { + if let Some(host) = self.server_host { + config.server.host = host; + } + if let Some(port) = self.server_port { + config.server.port = port; + } + if let Some(url) = self.database_url { + config.database.url = url; + } + if let Some(url) = self.redis_url { + config.redis.url = url; + } + } +} + +/// 应用运行总配置,不包含任何启动密码。 +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AppConfig { /// HTTP 服务配置。 pub server: ServerConfig, /// API 地址配置。 pub api: ApiConfig, /// 运行时模型别名,由数据库设置加载,配置文件不承载该字段。 - #[serde(skip)] pub model_aliases: BTreeMap, /// 认证与刷新配置,由固定默认值和数据库运行时设置承载,配置文件不承载该字段。 - #[serde(skip)] pub auth: AuthConfig, /// 配额配置。 - #[serde(default)] pub quota: QuotaConfig, - /// 数据库配置。 + /// PostgreSQL 基础连接地址,不包含密码。 pub database: DatabaseConfig, - /// Redis 运行态存储配置。 + /// Redis 基础连接地址,不包含密码。 pub redis: RedisConfig, + /// 本地持久运行目录。 + pub runtime: RuntimePathsConfig, /// TLS 偏好配置。 - #[serde(default)] pub tls: TlsConfig, /// WebSocket 连接池启动设置。 - #[serde(default)] pub ws_pool: WebSocketPoolSettings, /// 上游请求指纹默认配置。 - #[serde(default)] pub fingerprint: FingerprintConfig, - /// 管理员初始化配置。 - #[serde(default)] + /// 管理员初始化配置,不包含密码。 pub admin: AdminConfig, /// 日志配置。 - #[serde(default)] pub logging: LoggingConfig, /// PostgreSQL 遥测事实记录配置。 - #[serde(default)] pub telemetry: TelemetryConfig, } /// HTTP 监听配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ServerConfig { /// 监听主机。 @@ -67,14 +266,15 @@ pub struct ServerConfig { } /// 上游 API 地址配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ApiConfig { /// 上游基础 URL。 pub base_url: String, } /// 认证、轮换与 token 续期配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct AuthConfig { /// 提前刷新秒数。 pub refresh_margin_seconds: u64, @@ -113,58 +313,47 @@ impl Default for AuthConfig { } /// 配额刷新与跳过配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct QuotaConfig { /// 配额刷新周期。 - #[serde(default = "default_quota_refresh_interval_minutes")] pub refresh_interval_minutes: u64, /// 是否跳过已耗尽配额的账号。 - #[serde(default = "default_skip_exhausted")] pub skip_exhausted: bool, } -impl Default for QuotaConfig { - fn default() -> Self { - Self { - refresh_interval_minutes: default_quota_refresh_interval_minutes(), - skip_exhausted: default_skip_exhausted(), - } - } -} - -fn default_quota_refresh_interval_minutes() -> u64 { - 5 -} - -fn default_skip_exhausted() -> bool { - true -} - /// 数据库连接配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct DatabaseConfig { - /// PostgreSQL 连接 URL。 + /// PostgreSQL 基础连接 URL,不包含密码。 pub url: String, } /// Redis 连接配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RedisConfig { - /// Redis 连接 URL。 + /// Redis 基础连接 URL,不包含密码。 pub url: String, } +/// 本地持久运行目录。 +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimePathsConfig { + /// 持久身份密钥与更新状态目录。 + pub data_directory: PathBuf, +} + /// TLS/HTTP 协议偏好配置。 -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct TlsConfig { /// 是否强制 HTTP/1.1。 pub force_http11: bool, } /// WebSocket 连接池启动设置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WebSocketPoolSettings { /// 是否启用连接池。 @@ -174,31 +363,11 @@ pub struct WebSocketPoolSettings { /// 单账号最大连接数。 pub max_per_account: usize, /// 首个真实输出(首事件)到达前的绝对超时;`0` 表示禁用。 - /// - /// 从发出 `response.create` 起算,覆盖建连/发送/上游排队/思考的全程,直到收到 - /// 首个真实内容帧(`response.created`/`response.in_progress` 等纯生命周期帧不计)。 - /// 超时判定为连接落到病态上游后端,丢弃并换新连接重试。 - #[serde(default = "default_ws_initial_event_timeout_ms")] pub initial_event_timeout_ms: u64, } -fn default_ws_initial_event_timeout_ms() -> u64 { - 20_000 -} - -impl Default for WebSocketPoolSettings { - fn default() -> Self { - Self { - enabled: true, - max_age_ms: 3_300_000, - max_per_account: 8, - initial_event_timeout_ms: default_ws_initial_event_timeout_ms(), - } - } -} - /// 上游请求指纹默认配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct FingerprintConfig { /// 客户端来源名。 @@ -221,53 +390,8 @@ pub struct FingerprintConfig { pub header_order: Vec, } -impl Default for FingerprintConfig { - fn default() -> Self { - Self { - originator: "Codex Desktop".to_string(), - app_version: "26.519.81530".to_string(), - build_number: "3178".to_string(), - platform: "darwin".to_string(), - arch: "arm64".to_string(), - chromium_version: "146".to_string(), - user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(), - default_headers: vec![ - FingerprintHeaderConfig::new("Accept-Encoding", "gzip, deflate, br, zstd"), - FingerprintHeaderConfig::new("Accept-Language", "en-US,en;q=0.9"), - FingerprintHeaderConfig::new("sec-ch-ua-mobile", "?0"), - FingerprintHeaderConfig::new("sec-ch-ua-platform", "\"macOS\""), - FingerprintHeaderConfig::new("sec-fetch-site", "same-origin"), - FingerprintHeaderConfig::new("sec-fetch-mode", "cors"), - FingerprintHeaderConfig::new("sec-fetch-dest", "empty"), - ], - header_order: vec![ - "authorization".to_string(), - "chatgpt-account-id".to_string(), - "originator".to_string(), - "x-openai-internal-codex-residency".to_string(), - "x-client-request-id".to_string(), - "x-codex-installation-id".to_string(), - "x-codex-turn-state".to_string(), - "openai-beta".to_string(), - "user-agent".to_string(), - "sec-ch-ua".to_string(), - "sec-ch-ua-mobile".to_string(), - "sec-ch-ua-platform".to_string(), - "accept-encoding".to_string(), - "accept-language".to_string(), - "sec-fetch-site".to_string(), - "sec-fetch-mode".to_string(), - "sec-fetch-dest".to_string(), - "content-type".to_string(), - "accept".to_string(), - "cookie".to_string(), - ], - } - } -} - /// 指纹默认请求头配置项。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct FingerprintHeaderConfig { /// 请求头名称。 @@ -276,76 +400,20 @@ pub struct FingerprintHeaderConfig { pub value: String, } -impl FingerprintHeaderConfig { - fn new(name: &str, value: &str) -> Self { - Self { - name: name.to_string(), - value: value.to_string(), - } - } -} - -/// 管理员初始化与会话配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] +/// 管理员初始化与会话配置,不包含初始化密码。 +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AdminConfig { /// 会话有效期(分钟)。 pub session_ttl_minutes: u64, - /// 默认管理员用户名(首次启动时创建) - #[serde(default = "default_admin_username")] + /// 默认管理员用户名(首次启动时创建)。 pub default_username: String, - /// 默认管理员密码(首次启动时创建) - #[serde(default = "default_admin_password")] - pub default_password: String, -} - -impl Default for AdminConfig { - fn default() -> Self { - Self { - session_ttl_minutes: 1440, - default_username: default_admin_username(), - default_password: default_admin_password(), - } - } -} - -impl AdminConfig { - /// 校验首次启动创建管理员时使用的默认密码。 - pub fn validate_default_password(&self) -> Result<(), AdminConfigError> { - let password = self.default_password.trim(); - if password.len() < 12 { - return Err(AdminConfigError::WeakDefaultPassword); - } - if WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str()) { - return Err(AdminConfigError::WeakDefaultPassword); - } - Ok(()) - } -} - -/// 管理员配置校验错误。 -#[derive(Debug, Clone, Copy, thiserror::Error, PartialEq, Eq)] -pub enum AdminConfigError { - /// 默认管理员密码为空、过短或属于常见弱口令。 - #[error( - "admin.default_password must be set to a non-default password with at least 12 characters" - )] - WeakDefaultPassword, -} - -fn default_admin_username() -> String { - "admin".to_string() -} - -fn default_admin_password() -> String { - String::new() } /// 应用结构化日志配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct LoggingConfig { - /// 默认日志级别;`RUST_LOG` 存在时优先使用环境变量。 + /// 默认日志级别;进程 `RUST_LOG` 可作临时覆盖。 pub level: String, /// 是否写入标准输出。 pub stdout: bool, @@ -353,24 +421,14 @@ pub struct LoggingConfig { pub file: FileLoggingConfig, } -impl Default for LoggingConfig { - fn default() -> Self { - Self { - level: "info".to_string(), - stdout: true, - file: FileLoggingConfig::default(), - } - } -} - /// 应用结构化文件日志配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct FileLoggingConfig { /// 是否写入轮转文件。 pub enabled: bool, /// 日志目录。 - pub directory: String, + pub directory: PathBuf, /// 按中国自然日计算的保留天数。 pub retention_days: usize, /// 单文件大小上限,单位 MiB。 @@ -379,104 +437,296 @@ pub struct FileLoggingConfig { pub max_files: usize, } -impl Default for FileLoggingConfig { - fn default() -> Self { - Self { - enabled: true, - directory: ".runtime/logs".to_string(), - retention_days: 14, - max_file_size_mb: 20, - max_files: 20, - } - } -} - /// PostgreSQL 遥测事实记录配置。 -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct TelemetryConfig { /// 是否记录成功与失败代理事实。 pub enabled: bool, } -impl Default for TelemetryConfig { - fn default() -> Self { - Self { enabled: true } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ConfigDocument { + #[serde(rename = "x-cpr")] + cpr: FileAppConfig, + #[serde(rename = "services")] + _services: IgnoredAny, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileAppConfig { + schema_version: u32, + server: ServerConfig, + api: ApiConfig, + database: FileDatabaseConfig, + redis: FileRedisConfig, + runtime: RuntimePathsConfig, + quota: QuotaConfig, + tls: TlsConfig, + ws_pool: WebSocketPoolSettings, + fingerprint: FingerprintConfig, + admin: FileAdminConfig, + logging: LoggingConfig, + telemetry: TelemetryConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileDatabaseConfig { + url: String, + password: SecretValue, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileRedisConfig { + url: String, + password: SecretValue, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileAdminConfig { + session_ttl_minutes: u64, + default_username: String, + default_password: SecretValue, +} + +fn discover_config_path(start: &Path) -> Result { + start + .ancestors() + .map(|directory| directory.join(CONFIG_RELATIVE_PATH)) + .find(|candidate| candidate.is_file()) + .ok_or(ConfigError::ConfigFileNotFound) +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + let current_directory = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?; + Ok(current_directory.join(path)) +} + +fn resolve_relative_path(base: &Path, path: &mut PathBuf) { + if path.is_relative() { + *path = base.join(&*path); } } -const CONFIG_FILE_ENV: &str = "CPR_CONFIG_FILE"; -const ADMIN_DEFAULT_PASSWORD_ENV: &str = "CPR_ADMIN_DEFAULT_PASSWORD"; -const POSTGRES_PASSWORD_ENV: &str = "CPR_POSTGRES_PASSWORD"; -const REDIS_PASSWORD_ENV: &str = "CPR_REDIS_PASSWORD"; - -impl AppConfig { - /// 从 `CPR_CONFIG_FILE` 或当前目录 `config.yaml` 加载配置文件,并应用运行时密码。 - pub fn load() -> Result { - let config_file = env::var(CONFIG_FILE_ENV) - .ok() - .filter(|value| !value.trim().is_empty()); - match config_file { - Some(path) => load_file(path), - None => Self::load_from_dir("."), - } - } - - /// 从指定目录加载配置文件,并应用运行时密码。 - pub fn load_from_dir(config_dir: impl AsRef) -> Result { - load_file(config_dir.as_ref().join("config.yaml")) +fn optional_environment_value(name: &'static str) -> Result, ConfigError> { + match env::var(name) { + Ok(value) if value.trim().is_empty() => Err(ConfigError::InvalidTopologyOverride(name)), + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(None), + Err(env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidTopologyOverride(name)), } } -fn load_file(config_file: impl AsRef) -> Result { - let mut app_config: AppConfig = ::config::Config::builder() - .add_source(::config::File::from(config_file.as_ref()).required(true)) - .build()? - .try_deserialize()?; - apply_password_override( - &mut app_config.database.url, - "database.url", - POSTGRES_PASSWORD_ENV, +fn validate_file_config(config: &FileAppConfig) -> Result<(), ConfigError> { + validate_nonempty("server.host", &config.server.host)?; + if config.server.port == 0 { + return Err(ConfigError::InvalidField("server.port")); + } + validate_url( + "api.base_url", + &config.api.base_url, + &["http", "https"], + false, )?; - apply_password_override(&mut app_config.redis.url, "redis.url", REDIS_PASSWORD_ENV)?; - if let Some(password) = password_override(ADMIN_DEFAULT_PASSWORD_ENV)? { - app_config.admin.default_password = password; + validate_url( + "database.url", + &config.database.url, + &["postgres", "postgresql"], + true, + )?; + validate_url("redis.url", &config.redis.url, &["redis", "rediss"], true)?; + validate_service_password("database.password", &config.database.password)?; + validate_service_password("redis.password", &config.redis.password)?; + validate_admin_password(config.admin.default_password.expose_secret())?; + validate_nonempty("admin.default_username", &config.admin.default_username)?; + if config.admin.session_ttl_minutes == 0 { + return Err(ConfigError::InvalidField("admin.session_ttl_minutes")); } - Ok(app_config) -} - -fn apply_password_override( - raw_url: &mut String, - config_key: &str, - env_name: &str, -) -> Result<(), ::config::ConfigError> { - let Some(password) = password_override(env_name)? else { - return Ok(()); - }; - let mut url = Url::parse(raw_url).map_err(|_| { - ::config::ConfigError::Message(format!( - "{config_key} must be a valid URL when {env_name} is set" - )) - })?; - let encoded_password = utf8_percent_encode(&password, NON_ALPHANUMERIC).to_string(); - url.set_password(Some(&encoded_password)).map_err(|()| { - ::config::ConfigError::Message(format!( - "{config_key} does not support a password override from {env_name}" - )) - })?; - *raw_url = url.into(); + if config.runtime.data_directory.as_os_str().is_empty() { + return Err(ConfigError::InvalidField("runtime.data_directory")); + } + if config.quota.refresh_interval_minutes == 0 { + return Err(ConfigError::InvalidField("quota.refresh_interval_minutes")); + } + if config.ws_pool.max_age_ms == 0 { + return Err(ConfigError::InvalidField("ws_pool.max_age_ms")); + } + if config.ws_pool.max_per_account == 0 { + return Err(ConfigError::InvalidField("ws_pool.max_per_account")); + } + validate_fingerprint(&config.fingerprint)?; + validate_logging(&config.logging)?; Ok(()) } -fn password_override(env_name: &str) -> Result, ::config::ConfigError> { - match env::var(env_name) { - Ok(password) if password.is_empty() => Err(::config::ConfigError::Message(format!( - "{env_name} must not be empty" - ))), - Ok(password) => Ok(Some(password)), - Err(env::VarError::NotPresent) => Ok(None), - Err(env::VarError::NotUnicode(_)) => Err(::config::ConfigError::Message(format!( - "{env_name} must contain valid UTF-8" - ))), +fn validate_url( + field: &'static str, + raw_url: &str, + allowed_schemes: &[&str], + reject_password: bool, +) -> Result<(), ConfigError> { + let url = Url::parse(raw_url).map_err(|_| ConfigError::InvalidField(field))?; + if !allowed_schemes.contains(&url.scheme()) || url.host_str().is_none() { + return Err(ConfigError::InvalidField(field)); } + if reject_password && url.password().is_some() { + return Err(ConfigError::PasswordInUrl(field)); + } + Ok(()) +} + +fn validate_service_password( + field: &'static str, + password: &SecretValue, +) -> Result<(), ConfigError> { + let password = password.expose_secret(); + if password.len() != SERVICE_PASSWORD_HEX_LENGTH + || !password.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(ConfigError::InvalidServicePassword(field)); + } + Ok(()) +} + +fn validate_admin_password(password: &str) -> Result<(), ConfigError> { + let password = password.trim(); + if password.len() < 12 + || password.contains('$') + || WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str()) + { + return Err(ConfigError::WeakAdminPassword); + } + Ok(()) +} + +fn validate_fingerprint(config: &FingerprintConfig) -> Result<(), ConfigError> { + for (field, value) in [ + ("fingerprint.originator", config.originator.as_str()), + ("fingerprint.app_version", config.app_version.as_str()), + ("fingerprint.build_number", config.build_number.as_str()), + ("fingerprint.platform", config.platform.as_str()), + ("fingerprint.arch", config.arch.as_str()), + ( + "fingerprint.chromium_version", + config.chromium_version.as_str(), + ), + ( + "fingerprint.user_agent_template", + config.user_agent_template.as_str(), + ), + ] { + validate_nonempty(field, value)?; + } + + if config.default_headers.is_empty() || config.header_order.is_empty() { + return Err(ConfigError::InvalidField("fingerprint")); + } + + let mut header_names = HashSet::new(); + for header in &config.default_headers { + let name = header.name.trim().to_ascii_lowercase(); + if name.is_empty() + || header.value.is_empty() + || HeaderName::from_bytes(name.as_bytes()).is_err() + || !header_names.insert(name) + { + return Err(ConfigError::InvalidField("fingerprint.default_headers")); + } + } + + let mut ordered_headers = HashSet::new(); + for header in &config.header_order { + let header = header.trim().to_ascii_lowercase(); + if header.is_empty() + || HeaderName::from_bytes(header.as_bytes()).is_err() + || !ordered_headers.insert(header) + { + return Err(ConfigError::InvalidField("fingerprint.header_order")); + } + } + Ok(()) +} + +fn validate_logging(config: &LoggingConfig) -> Result<(), ConfigError> { + EnvFilter::try_new(&config.level).map_err(|_| ConfigError::InvalidField("logging.level"))?; + if !config.stdout && !config.file.enabled { + return Err(ConfigError::InvalidField("logging")); + } + if config.file.directory.as_os_str().is_empty() { + return Err(ConfigError::InvalidField("logging.file.directory")); + } + if config.file.retention_days == 0 { + return Err(ConfigError::InvalidField("logging.file.retention_days")); + } + if config.file.max_file_size_mb == 0 { + return Err(ConfigError::InvalidField("logging.file.max_file_size_mb")); + } + if config.file.max_files == 0 { + return Err(ConfigError::InvalidField("logging.file.max_files")); + } + Ok(()) +} + +fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ConfigError> { + if value.trim().is_empty() { + return Err(ConfigError::InvalidField(field)); + } + Ok(()) +} + +fn connection_url_with_password( + raw_url: &str, + password: &SecretValue, + field: &'static str, +) -> Result { + let mut url = Url::parse(raw_url).map_err(|_| ConfigError::InvalidField(field))?; + url.set_password(Some(password.expose_secret())) + .map_err(|()| ConfigError::InvalidField(field))?; + Ok(SecretBox::new(Box::new(url.into()))) +} + +/// 启动配置加载错误。错误文本不会包含配置值或连接地址。 +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ConfigError { + /// 无法确定当前工作目录。 + #[error("failed to determine the current working directory")] + CurrentDirectory, + /// 从当前目录及父目录找不到配置文件。 + #[error("deploy/config.yaml was not found in the current directory or its parents")] + ConfigFileNotFound, + /// 配置路径没有父目录。 + #[error("configuration file path is invalid")] + InvalidConfigPath, + /// YAML 无法读取、解析或不符合结构;不透传解析器文本以避免泄露密码。 + #[error("failed to load configuration file `{path}`; check its permissions and YAML structure")] + InvalidDocument { + /// 配置文件路径。 + path: PathBuf, + }, + /// 配置格式版本不受支持。 + #[error("x-cpr.schema_version must be 1")] + UnsupportedSchemaVersion, + /// 字段值非法。 + #[error("configuration field `{0}` is invalid")] + InvalidField(&'static str), + /// 连接 URL 不允许内嵌密码。 + #[error("configuration field `{0}` must not contain a password; use its password field")] + PasswordInUrl(&'static str), + /// PostgreSQL 或 Redis 密码格式非法。 + #[error("configuration field `{0}` must be exactly 48 hexadecimal characters")] + InvalidServicePassword(&'static str), + /// 管理员初始化密码非法。 + #[error("admin.default_password must be strong, at least 12 characters, and contain no `$`")] + WeakAdminPassword, + /// Compose 拓扑覆盖非法。 + #[error("container topology override `{0}` is invalid")] + InvalidTopologyOverride(&'static str), } diff --git a/backend/src/bootstrap/services.rs b/backend/src/bootstrap/services.rs index 4edbc4f78..2447f96c5 100644 --- a/backend/src/bootstrap/services.rs +++ b/backend/src/bootstrap/services.rs @@ -3,13 +3,14 @@ use std::{env, error::Error, future::IntoFuture, sync::Arc, time::Duration}; use axum::Router; +use secrecy::ExposeSecret; use crate::{ auth::{ service::SessionService, store::{PgAdminUserStore, RedisAdminSessionStore}, }, - bootstrap::config::AppConfig, + bootstrap::config::{AppConfig, BootstrapConfig}, dispatch::{ affinity::{RedisSessionAffinityStore, SessionAffinityService}, service::{ResponseDispatchService, ResponseDispatchServiceParts}, @@ -445,13 +446,12 @@ pub fn account_pool_options_from_config(config: &AppConfig) -> AccountPoolOption .pool_options(&settings_snapshot_from_config(config)) } -pub async fn run() -> Result<(), Box> { +pub async fn run(config: BootstrapConfig) -> Result<(), Box> { wait_for_scheduled_restart().await; - let config = AppConfig::load()?; - let host = config.server.host.clone(); - let port = config.server.port; - let log_guard = init_logging(&config)?; + let host = config.app().server.host.clone(); + let port = config.app().server.port; + let log_guard = init_logging(config.app())?; let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?; let (router, task_coordinator, connection_drain) = build_router(config).await?; let mut task_coordinator = Some(task_coordinator); @@ -501,7 +501,7 @@ pub async fn run() -> Result<(), Box> { Ok(()) } () = crate::bootstrap::shutdown::shutdown_signal() => { - tracing::warn!("received a second shutdown signal; cancelling remaining connections"); + tracing::warn!("Received a second shutdown signal; cancelling remaining connections"); Ok(()) } } @@ -534,7 +534,7 @@ pub async fn run() -> Result<(), Box> { const HTTP_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); async fn build_router( - mut config: AppConfig, + bootstrap_config: BootstrapConfig, ) -> Result< ( Router, @@ -543,8 +543,17 @@ async fn build_router( ), Box, > { - let pool = connect(&config.database.url).await?; - let redis = RedisConnection::connect(&config.redis.url, "cpr").await?; + let crate::bootstrap::config::BootstrapConfigParts { + app: mut config, + database_url, + redis_url, + admin_default_password, + } = bootstrap_config.into_parts(); + + let pool = connect(database_url.expose_secret()).await?; + drop(database_url); + let redis = RedisConnection::connect(redis_url.expose_secret(), "cpr").await?; + drop(redis_url); let settings = SettingsService::load_or_initialize(settings_snapshot_from_config(&config), &pool).await?; apply_settings_to_config(&mut config, &settings); @@ -567,16 +576,14 @@ async fn build_router( request_buckets: PgRequestBucketStore::new(pool), }; - config.admin.validate_default_password()?; - let default_admin_password = config.admin.default_password.clone(); - let data_dir = ensure_data_dir()?; + let data_dir = ensure_data_dir(&config.runtime.data_directory)?; let identity_secret = load_or_create_identity_secret(&data_dir)?; let default_fingerprint = fingerprint_from_config(&config.fingerprint); let runtime_fingerprint = fingerprint_store .ensure_current_seed(&default_fingerprint) .await?; let runtime_fingerprint = RuntimeFingerprint::new(runtime_fingerprint); - let runtime_config = crate::bootstrap::state::RuntimeConfig::from(config.clone()); + let runtime_config = crate::bootstrap::state::RuntimeConfig::from(&config); let services = Services::try_with_identity_secret_and_usage_record_options( &config, stores, @@ -588,8 +595,9 @@ async fn build_router( let created_default_admin = services .admin_sessions - .ensure_default_admin(&default_admin_password) + .ensure_default_admin(admin_default_password.expose_secret()) .await?; + drop(admin_default_password); tracing::info!( created = created_default_admin, "默认管理员账号已完成初始化检查" diff --git a/backend/src/bootstrap/state.rs b/backend/src/bootstrap/state.rs index 5e3d33b4b..568aba8a3 100644 --- a/backend/src/bootstrap/state.rs +++ b/backend/src/bootstrap/state.rs @@ -9,7 +9,6 @@ use crate::{ /// 运行时配置镜像(从 AppConfig 衍生)。 #[derive(Debug, Clone)] pub struct RuntimeConfig { - pub admin: crate::bootstrap::config::AdminConfig, pub auth: crate::bootstrap::config::AuthConfig, pub quota: crate::bootstrap::config::QuotaConfig, } @@ -40,12 +39,11 @@ impl HealthProbe for RuntimeHealthProbe { } } -impl From for RuntimeConfig { - fn from(config: AppConfig) -> Self { +impl From<&AppConfig> for RuntimeConfig { + fn from(config: &AppConfig) -> Self { Self { - admin: config.admin.clone(), auth: config.auth.clone(), - quota: config.quota, + quota: config.quota.clone(), } } } diff --git a/backend/src/bootstrap/tasks/fingerprint_update.rs b/backend/src/bootstrap/tasks/fingerprint_update.rs index b443946d4..5e6227a2a 100644 --- a/backend/src/bootstrap/tasks/fingerprint_update.rs +++ b/backend/src/bootstrap/tasks/fingerprint_update.rs @@ -44,8 +44,8 @@ pub fn start_fingerprint_update_task( }, PeriodicTaskConfig::new( APPCAST_POLL_INTERVAL.as_secs(), - "fingerprint 后台版本检查器已启动", - "fingerprint 后台版本检查器已关闭", + "Fingerprint 后台版本检查器已启动", + "Fingerprint 后台版本检查器已关闭", ), ) } @@ -68,15 +68,15 @@ impl PeriodicTaskRunner for FingerprintUpdateTask { info!( app_version = %app_version, build_number = %build_number, - "fingerprint 运行时版本已更新" + "Fingerprint 运行时版本已更新" ); } Ok(None) => {} Err(error) => { if first_tick { - warn!(error = %error, "fingerprint 首次版本检查失败"); + warn!(error = %error, "Fingerprint 首次版本检查失败"); } else { - warn!(error = %error, "fingerprint 定期版本检查失败"); + warn!(error = %error, "Fingerprint 定期版本检查失败"); } } } diff --git a/backend/src/bootstrap/tasks/quota_refresh.rs b/backend/src/bootstrap/tasks/quota_refresh.rs index ffd52d4a5..8f89d83f2 100644 --- a/backend/src/bootstrap/tasks/quota_refresh.rs +++ b/backend/src/bootstrap/tasks/quota_refresh.rs @@ -68,8 +68,8 @@ impl QuotaRefreshTask { pub fn start(self) -> SchedulerHandle { let config = PeriodicTaskConfig::new( self.interval_secs, - "quota 刷新任务已启动", - "quota 刷新任务已关闭", + "Quota 刷新任务已启动", + "Quota 刷新任务已关闭", ); spawn_periodic_task(self, config) } @@ -96,7 +96,7 @@ impl PeriodicTaskRunner for QuotaRefreshTask { ); } Err(error) => { - warn!(error = %error, "quota 刷新任务失败"); + warn!(error = %error, "Quota 刷新任务失败"); } } }) @@ -107,6 +107,6 @@ fn log_refreshed_summary(summary: QuotaRefreshSummary) { info!( refreshed = summary.refreshed, failed = summary.failed, - "quota 刷新任务完成" + "Quota 刷新任务完成" ); } diff --git a/backend/src/bootstrap/tasks/token_refresh.rs b/backend/src/bootstrap/tasks/token_refresh.rs index 3c02ad945..9e544b57f 100644 --- a/backend/src/bootstrap/tasks/token_refresh.rs +++ b/backend/src/bootstrap/tasks/token_refresh.rs @@ -54,8 +54,8 @@ where pub fn start(self) -> SchedulerHandle { let config = PeriodicTaskConfig::new( self.interval_secs, - "token 刷新任务已启动", - "token 刷新任务已关闭", + "Token 刷新任务已启动", + "Token 刷新任务已关闭", ); spawn_periodic_task(self, config) } @@ -73,7 +73,7 @@ where scheduled = summary.scheduled, immediate = summary.immediate, replaced = summary.replaced, - "token 刷新定时器已调度" + "Token 刷新定时器已调度" ); } Ok(summary) => { @@ -84,7 +84,7 @@ where ); } Err(error) => { - warn!(error = %error, "token 刷新定时器调度失败"); + warn!(error = %error, "Token 刷新定时器调度失败"); } } }) diff --git a/backend/src/dispatch/affinity/resolve.rs b/backend/src/dispatch/affinity/resolve.rs index ad388c0a0..3ce7d8fa1 100644 --- a/backend/src/dispatch/affinity/resolve.rs +++ b/backend/src/dispatch/affinity/resolve.rs @@ -62,7 +62,7 @@ pub(in crate::dispatch) async fn record_response_affinity( Err(error) => { tracing::warn!( error = %error, - "failed to parse completed response metadata for session affinity" + "Failed to parse completed response metadata for session affinity" ); return; } @@ -98,7 +98,7 @@ pub(in crate::dispatch) async fn record_response_affinity( error = %error, response_id = %metadata.response_id, account_id = %account_id, - "failed to record session affinity" + "Failed to record session affinity" ); } } diff --git a/backend/src/dispatch/affinity/service.rs b/backend/src/dispatch/affinity/service.rs index 9ea73cd0c..236849b05 100644 --- a/backend/src/dispatch/affinity/service.rs +++ b/backend/src/dispatch/affinity/service.rs @@ -65,7 +65,7 @@ impl SessionAffinityService { tracing::warn!( response_id, error = %error, - "failed to remove Redis session affinity" + "Failed to remove Redis session affinity" ); false } @@ -76,7 +76,7 @@ impl SessionAffinityService { match self.store.forget_account(account_id).await { Ok(forgotten) => forgotten > 0, Err(error) => { - tracing::warn!(account_id, error = %error, "failed to remove account affinities"); + tracing::warn!(account_id, error = %error, "Failed to remove account affinities"); false } } @@ -86,7 +86,7 @@ impl SessionAffinityService { match self.store.get(response_id, now, self.ttl).await { Ok(entry) => entry, Err(error) => { - tracing::warn!(response_id, error = %error, "failed to read Redis session affinity"); + tracing::warn!(response_id, error = %error, "Failed to read Redis session affinity"); None } } @@ -106,7 +106,7 @@ impl SessionAffinityService { { Ok(entry) => entry, Err(error) => { - tracing::warn!(conversation_id, error = %error, "failed to query Redis affinity index"); + tracing::warn!(conversation_id, error = %error, "Failed to query Redis affinity index"); None } } diff --git a/backend/src/dispatch/attempts.rs b/backend/src/dispatch/attempts.rs index b414bdd06..aa901f76c 100644 --- a/backend/src/dispatch/attempts.rs +++ b/backend/src/dispatch/attempts.rs @@ -80,7 +80,7 @@ impl AccountAttemptLedger { attempted_count = self.attempted.len(), busy_count = self.busy.len(), timeout_ms = ACCOUNT_CANDIDATE_WAIT_TIMEOUT.as_millis(), - "timed out waiting for a busy account candidate" + "Timed out waiting for a busy account candidate" ); return None; } diff --git a/backend/src/dispatch/recording.rs b/backend/src/dispatch/recording.rs index 2e738055d..0abc2ea81 100644 --- a/backend/src/dispatch/recording.rs +++ b/backend/src/dispatch/recording.rs @@ -181,7 +181,7 @@ pub(super) async fn record_response_upstream_error_event( enrich_response_request_semantics(&mut metadata, record.request); event.metadata = metadata; if let Err(error) = record.recorder.record_error(event).await { - tracing::error!(account_id = %record.account_id, error = %error, "failed to record upstream error event"); + tracing::error!(account_id = %record.account_id, error = %error, "Failed to record upstream error event"); } } @@ -251,7 +251,7 @@ pub(super) async fn record_prefetched_response_stream_failure_event( } event.metadata = metadata; if let Err(error) = record.recorder.record_error(event).await { - tracing::error!(account_id = %record.account_id, error = %error, "failed to record prefetched stream error event"); + tracing::error!(account_id = %record.account_id, error = %error, "Failed to record prefetched stream error event"); } } @@ -562,7 +562,7 @@ pub(super) async fn record_live_response_stream_event( event.latency_ms = Some(elapsed_millis_i64(context.started_at)); event.metadata = metadata; if let Err(error) = context.recorder.record_error(event).await { - tracing::error!(account_id = %context.account_id, error = %error, "failed to record live response stream error event"); + tracing::error!(account_id = %context.account_id, error = %error, "Failed to record live response stream error event"); } } } @@ -619,7 +619,7 @@ fn log_live_response_stream_finalized( failure_class = ?failure_class, failure_source = ?failure_source, failure_detail = ?failure_detail, - "live response stream finalized" + "Live response stream finalized" ); }; } diff --git a/backend/src/dispatch/recovery/cloudflare.rs b/backend/src/dispatch/recovery/cloudflare.rs index 1dcb14f86..d41b7df63 100644 --- a/backend/src/dispatch/recovery/cloudflare.rs +++ b/backend/src/dispatch/recovery/cloudflare.rs @@ -44,7 +44,7 @@ impl CloudflareRecovery { tracing::warn!( account_id, error = %error, - "failed to persist upstream set-cookie header" + "Failed to persist upstream set-cookie header" ); } } @@ -67,7 +67,7 @@ impl CloudflareRecovery { tracing::warn!( account_id, error = %error, - "failed to persist Cloudflare challenge cookie cleanup deadline" + "Failed to persist Cloudflare challenge cookie cleanup deadline" ); } } @@ -106,7 +106,7 @@ async fn delete_account_cookies(cookie_store: &PgCookieStore, account_id: &str, account_id, reason, error = %error, - "failed to delete account cookies after Cloudflare recovery signal" + "Failed to delete account cookies after Cloudflare recovery signal" ); } } diff --git a/backend/src/dispatch/stream/live.rs b/backend/src/dispatch/stream/live.rs index 1d8179513..6310032ae 100644 --- a/backend/src/dispatch/stream/live.rs +++ b/backend/src/dispatch/stream/live.rs @@ -368,7 +368,7 @@ async fn terminate_oversized_live_stream( account_id = %context.account_id, captured_bytes = body_bytes.len(), capture_limit_bytes = MAX_LIVE_RESPONSE_CAPTURE_BYTES, - "live response capture limit exceeded" + "Live response capture limit exceeded" ); let Some(body_text) = send_live_response_stream_tail( sender, @@ -533,7 +533,7 @@ pub(in crate::dispatch) async fn finalize_live_response_stream( } Ok(None) => None, Err(error) => { - tracing::warn!(account_id = %context.account_id, error = %error, "failed to extract streaming token usage"); + tracing::warn!(account_id = %context.account_id, error = %error, "Failed to extract streaming token usage"); None } }; @@ -605,7 +605,7 @@ pub(in crate::dispatch) async fn finalize_live_response_stream( code = ?failure.upstream_code.as_deref(), failure_source = %failure_source, failure_detail = ?failure_detail.as_deref(), - "live upstream stream ended with response.failed" + "Live upstream stream ended with response.failed" ); let mut metadata = stream_failure_metadata(&failure, usage); insert_first_token_ms(&mut metadata, first_token_ms); @@ -623,7 +623,7 @@ pub(in crate::dispatch) async fn finalize_live_response_stream( Ok(CollectedResponse::MissingCompleted | CollectedResponse::Empty) => { tracing::warn!( account_id = %context.account_id, - "live upstream stream ended without response.completed" + "Live upstream stream ended without response.completed" ); let mut metadata = serde_json::json!({ "stream": true, @@ -644,7 +644,7 @@ pub(in crate::dispatch) async fn finalize_live_response_stream( .await; } Err(error) => { - tracing::warn!(account_id = %context.account_id, error = %error, "failed to parse completed live stream"); + tracing::warn!(account_id = %context.account_id, error = %error, "Failed to parse completed live stream"); let mut metadata = serde_json::json!({ "stream": true, "sseParseError": error.to_string(), diff --git a/backend/src/dispatch/upstream_call.rs b/backend/src/dispatch/upstream_call.rs index 1e9cdea90..46ebb0b15 100644 --- a/backend/src/dispatch/upstream_call.rs +++ b/backend/src/dispatch/upstream_call.rs @@ -97,7 +97,7 @@ pub(crate) async fn verify_acquired_quota_if_required( quota_verify_result = "upstream_error", retry_with_another_account = false, error = %error, - "failed to verify stale quota state before upstream request" + "Failed to verify stale quota state before upstream request" ); return QuotaVerificationDecision::Ready(Box::new(acquired)); } @@ -116,7 +116,7 @@ pub(crate) async fn verify_acquired_quota_if_required( quota_verify_required = true, quota_verify_result = "limit_reached", retry_with_another_account = true, - "quota verification reported exhausted account before upstream request" + "Quota verification reported exhausted account before upstream request" ); return QuotaVerificationDecision::RetryWithAnotherAccount; } diff --git a/backend/src/fleet/manage/import.rs b/backend/src/fleet/manage/import.rs index a96a519bf..e37b542f9 100644 --- a/backend/src/fleet/manage/import.rs +++ b/backend/src/fleet/manage/import.rs @@ -229,7 +229,7 @@ impl AccountManageService { Err(error) => { tracing::warn!( error = %error, - "failed to fetch supplemental account information during import" + "Failed to fetch supplemental account information during import" ); ImportSupplementalAccountInfo { status: import_status_from_usage_error(&error), diff --git a/backend/src/fleet/manage/probe.rs b/backend/src/fleet/manage/probe.rs index 0d754c9c7..3ed51eec0 100644 --- a/backend/src/fleet/manage/probe.rs +++ b/backend/src/fleet/manage/probe.rs @@ -103,7 +103,7 @@ impl AccountManageService { account_id = %account.id, plan_type, error = %error, - "failed to refresh account plan models" + "Failed to refresh account plan models" ); } } @@ -214,7 +214,7 @@ impl AccountManageService { tracing::warn!( account_id, error = %error, - "failed to inspect account after connection test" + "Failed to inspect account after connection test" ); return None; } @@ -231,7 +231,7 @@ impl AccountManageService { account_id, status = %status, error = %error, - "failed to persist account status after connection test" + "Failed to persist account status after connection test" ); return None; } @@ -243,7 +243,7 @@ impl AccountManageService { tracing::warn!( account_id, error = %error, - "failed to clear token refresh schedule after connection test" + "Failed to clear token refresh schedule after connection test" ); } self.sync_account_pool_best_effort(account_id, "connection test") diff --git a/backend/src/fleet/manage/service.rs b/backend/src/fleet/manage/service.rs index 3ffaf514f..b57a4a724 100644 --- a/backend/src/fleet/manage/service.rs +++ b/backend/src/fleet/manage/service.rs @@ -121,7 +121,7 @@ impl AccountManageService { account_id, operation, error = %error, - "failed to sync runtime account pool after admin account update" + "Failed to sync runtime account pool after admin account update" ); } } @@ -145,7 +145,7 @@ impl AccountManageService { tracing::warn!( account_id, error = %error, - "failed to inspect account while evicting websocket pool" + "Failed to inspect account while evicting WebSocket pool" ); } } diff --git a/backend/src/fleet/pool/mod.rs b/backend/src/fleet/pool/mod.rs index e48093eb7..ea98e142c 100644 --- a/backend/src/fleet/pool/mod.rs +++ b/backend/src/fleet/pool/mod.rs @@ -321,7 +321,7 @@ impl AccountPoolService { tracing::error!( account_id, error = %error, - "failed to persist account request usage" + "Failed to persist account request usage" ); } } @@ -334,7 +334,7 @@ impl AccountPoolService { tracing::error!( account_id, error = %error, - "failed to persist account status" + "Failed to persist account status" ); false } @@ -351,7 +351,7 @@ impl AccountPoolService { tracing::error!( account_id, error = %error, - "failed to persist quota cooldown state" + "Failed to persist quota cooldown state" ); false } @@ -454,7 +454,7 @@ impl AccountPoolService { tracing::error!( account_id = %refreshed.account.id, error = %error, - "failed to persist refreshed runtime account state" + "Failed to persist refreshed runtime account state" ); } if refreshed.sync_usage_window @@ -469,7 +469,7 @@ impl AccountPoolService { tracing::error!( account_id = %refreshed.account.id, error = %error, - "failed to persist refreshed runtime account usage window" + "Failed to persist refreshed runtime account usage window" ); } } @@ -530,7 +530,7 @@ impl AccountPoolService { tracing::error!( account_id, error = %error, - "failed to persist account token usage" + "Failed to persist account token usage" ); } self.pool.lock().await.record_window_token_usage( @@ -562,7 +562,7 @@ impl AccountPoolService { tracing::error!( account_id, error = %error, - "failed to persist empty response usage" + "Failed to persist empty response usage" ); } if image_generation_requested { @@ -607,7 +607,7 @@ impl AccountPoolService { tracing::error!( account_id = %account_id, error = %error, - "failed to read existing quota json before passive rate-limit sync" + "Failed to read existing quota JSON before passive rate-limit sync" ); None } @@ -634,7 +634,7 @@ impl AccountPoolService { tracing::error!( account_id, error = %error, - "failed to persist verified quota snapshot" + "Failed to persist verified quota snapshot" ); false } @@ -649,7 +649,7 @@ impl AccountPoolService { tracing::error!( account_id, error = %error, - "failed to persist verified quota window" + "Failed to persist verified quota window" ); } @@ -712,7 +712,7 @@ impl AccountPoolService { tracing::error!( account_id, error = %error, - "failed to persist Cloudflare cooldown state" + "Failed to persist Cloudflare cooldown state" ); false } diff --git a/backend/src/fleet/pool/state.rs b/backend/src/fleet/pool/state.rs index 3b3b63bac..5169baf83 100644 --- a/backend/src/fleet/pool/state.rs +++ b/backend/src/fleet/pool/state.rs @@ -418,7 +418,7 @@ impl AccountPool { quota_limit_reached = selected.quota_limit_reached, quota_cooldown_until = ?selected.quota_cooldown_until, previous_slot_at = ?previous_slot_at, - "account selected for upstream request" + "Account selected for upstream request" ); let selected_id = selected.id.clone(); @@ -645,7 +645,7 @@ impl AccountPool { last_used_at = account.last_used_at.as_deref().unwrap_or_default(), quota_limit_reached = account.quota_limit_reached, quota_cooldown_until = ?account.quota_cooldown_until, - "account selected for model refresh" + "Account selected for model refresh" ); selected_accounts.push((plan_type, account)); } diff --git a/backend/src/fleet/quota/runtime.rs b/backend/src/fleet/quota/runtime.rs index d59247e89..c598f3b6a 100644 --- a/backend/src/fleet/quota/runtime.rs +++ b/backend/src/fleet/quota/runtime.rs @@ -215,7 +215,7 @@ impl QuotaRefreshService { warn!( account_id = %account.id, error = %error, - "quota usage 拉取失败" + "Quota usage 拉取失败" ); summary.failed += 1; } @@ -246,7 +246,7 @@ impl QuotaRefreshService { warn!( account_id = %account_id, error = %error, - "failed to sync runtime account pool after quota refresh" + "Failed to sync runtime account pool after quota refresh" ); } } diff --git a/backend/src/fleet/refresh/service.rs b/backend/src/fleet/refresh/service.rs index 9c2a32a51..e60077716 100644 --- a/backend/src/fleet/refresh/service.rs +++ b/backend/src/fleet/refresh/service.rs @@ -148,7 +148,7 @@ impl Drop for RefreshLeaseGuard { drop(tasks.spawn_on( async move { if let Err(error) = store.release(&account_id, &owner).await { - warn!(account_id, error = %error, "failed to release cancelled token refresh lease"); + warn!(account_id, error = %error, "Failed to release cancelled token refresh lease"); } }, &runtime, @@ -452,7 +452,7 @@ where warn!( account_id = %timer_account_id, error = %error, - "scheduled token refresh failed" + "Scheduled token refresh failed" ); } }); @@ -503,7 +503,7 @@ where warn!( remaining_tasks = self.tasks.len(), timeout_secs = REFRESH_TASK_SHUTDOWN_TIMEOUT.as_secs(), - "waiting for in-flight token refresh tasks timed out" + "Waiting for in-flight token refresh tasks timed out" ); } } @@ -561,7 +561,7 @@ where if delay.is_zero() { debug!( account_id = %account.id, - "refreshed token is already within refresh margin; skipping next timer" + "Refreshed token is already within refresh margin; skipping next timer" ); return; } @@ -617,7 +617,7 @@ where next_refresh_at = ?updated.next_refresh_at, refresh_token_present = updated.refresh_token.is_some(), refresh_token_rotated, - "token refresh succeeded" + "Token refresh succeeded" ); return Ok(TokenRefreshOutcome::Refreshed(Box::new(updated))); } diff --git a/backend/src/infra/database.rs b/backend/src/infra/database.rs index fcf81f006..e38f81c90 100644 --- a/backend/src/infra/database.rs +++ b/backend/src/infra/database.rs @@ -4,6 +4,9 @@ use std::time::Duration; use sha2::{Digest, Sha256}; use sqlx::{PgPool, Postgres, Row, Transaction, postgres::PgPoolOptions}; +use tracing::info; + +const MIGRATION_LOCK_KEY: i64 = i64::from_be_bytes(*b"CPR-MIGR"); const MIGRATIONS: &[Migration] = &[Migration { version: 1, @@ -50,7 +53,7 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { validate_migration_catalog()?; let mut tx = pool.begin().await?; - ensure_migrations_table(&mut tx).await?; + let migration_table_created = ensure_migrations_table(&mut tx).await?; let applied = applied_migrations(&mut tx).await?; if applied.is_empty() { @@ -63,7 +66,14 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { record_migration(&mut tx, migration).await?; } - tx.commit().await + tx.commit().await?; + + if migration_table_created { + info!("数据库迁移表已创建"); + } else { + info!("数据库迁移表已存在,跳过创建"); + } + Ok(()) } fn validate_migration_catalog() -> Result<(), sqlx::Error> { @@ -86,9 +96,29 @@ fn validate_migration_catalog() -> Result<(), sqlx::Error> { Ok(()) } -async fn ensure_migrations_table(tx: &mut Transaction<'_, Postgres>) -> Result<(), sqlx::Error> { +async fn ensure_migrations_table(tx: &mut Transaction<'_, Postgres>) -> Result { + sqlx::query("select pg_advisory_xact_lock($1)") + .bind(MIGRATION_LOCK_KEY) + .execute(&mut **tx) + .await?; + + let exists = sqlx::query_scalar( + "select exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'schema_migrations' + and table_type = 'BASE TABLE' + )", + ) + .fetch_one(&mut **tx) + .await?; + if exists { + return Ok(false); + } + sqlx::query( - "create table if not exists schema_migrations ( + "create table schema_migrations ( version bigint not null check (version > 0), name text not null, checksum text not null, @@ -98,7 +128,7 @@ async fn ensure_migrations_table(tx: &mut Transaction<'_, Postgres>) -> Result<( ) .execute(&mut **tx) .await?; - Ok(()) + Ok(true) } async fn applied_migrations( diff --git a/backend/src/infra/logging.rs b/backend/src/infra/logging.rs index b62657225..52c3ad7b0 100644 --- a/backend/src/infra/logging.rs +++ b/backend/src/infra/logging.rs @@ -4,7 +4,7 @@ use std::{ backtrace::Backtrace, env, fmt, fs, fs::{File, OpenOptions}, - io::{self, Write}, + io::{self, IsTerminal, Write}, panic, path::{Path, PathBuf}, sync::mpsc, @@ -22,7 +22,7 @@ use tracing_subscriber::{ util::SubscriberInitExt, }; -use crate::infra::time::{china_date, china_rfc3339_millis}; +use crate::infra::time::{china_date, china_datetime, china_rfc3339_millis}; const LOG_PREFIX: &str = "codex-proxy-rs"; const LOG_SUFFIX: &str = "log"; @@ -169,6 +169,7 @@ pub fn init_tracing(config: &TracingConfig) -> Result { let mut writer_guards = Vec::new(); let mut counters = Vec::new(); + let stdout_is_terminal = io::stdout().is_terminal(); let stdout_writer = config.stdout.then(|| { let (writer, guard) = NonBlockingBuilder::default() .lossy(true) @@ -194,13 +195,20 @@ pub fn init_tracing(config: &TracingConfig) -> Result { writer }); - let stdout_layer = stdout_writer.map(json_layer); + let (compact_stdout_writer, json_stdout_writer) = match stdout_writer { + Some(writer) if stdout_is_terminal => (Some(writer), None), + Some(writer) => (None, Some(writer)), + None => (None, None), + }; + let compact_stdout_layer = compact_stdout_writer.map(compact_layer); + let json_stdout_layer = json_stdout_writer.map(json_layer); let file_layer = file_writer.map(json_layer); let drop_monitor = DroppedLogMonitor::start(counters)?; let init_result = tracing_subscriber::registry() .with(filter) - .with(stdout_layer) + .with(compact_stdout_layer) + .with(json_stdout_layer) .with(file_layer) .try_init() .map_err(|_| LogError::SubscriberAlreadyInitialized); @@ -217,13 +225,36 @@ pub fn init_tracing(config: &TracingConfig) -> Result { }) } +fn compact_layer( + writer: tracing_appender::non_blocking::NonBlocking, +) -> tracing_subscriber::fmt::Layer< + S, + tracing_subscriber::fmt::format::DefaultFields, + tracing_subscriber::fmt::format::Format< + tracing_subscriber::fmt::format::Compact, + ChinaConsoleTimer, + >, + tracing_appender::non_blocking::NonBlocking, +> +where + S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, +{ + tracing_fmt::layer() + .compact() + .with_timer(ChinaConsoleTimer) + .with_writer(writer) + .with_ansi(true) + .with_target(true) + .with_level(true) +} + fn install_panic_hook() { let previous_hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| { tracing::error!( panic = %panic_info, backtrace = %Backtrace::force_capture(), - "process panicked" + "Process panicked" ); previous_hook(panic_info); })); @@ -316,6 +347,15 @@ impl Write for ChinaDailyFileAppender { } } +#[derive(Debug, Clone, Copy)] +struct ChinaConsoleTimer; + +impl FormatTime for ChinaConsoleTimer { + fn format_time(&self, writer: &mut Writer<'_>) -> fmt::Result { + write!(writer, "{}", china_datetime(&Utc::now())) + } +} + #[derive(Debug, Clone, Copy)] struct ChinaLogTimer; diff --git a/backend/src/infra/paths.rs b/backend/src/infra/paths.rs index 4fcdbbab7..afee2dcd5 100644 --- a/backend/src/infra/paths.rs +++ b/backend/src/infra/paths.rs @@ -10,15 +10,11 @@ use rand::Rng; const IDENTITY_SECRET_FILE_NAME: &str = "identity_hmac_secret"; -fn data_dir() -> PathBuf { - dirs::data_local_dir().unwrap_or_else(|| PathBuf::from(".runtime/data")) -} - /// 确保本地数据目录存在。 -pub fn ensure_data_dir() -> std::io::Result { - let dir = data_dir(); - std::fs::create_dir_all(&dir)?; - Ok(dir) +pub fn ensure_data_dir(directory: impl AsRef) -> std::io::Result { + let directory = directory.as_ref(); + std::fs::create_dir_all(directory)?; + Ok(directory.to_path_buf()) } /// 读取或创建账号身份隔离使用的 256-bit HMAC 密钥。 diff --git a/backend/src/keys/service.rs b/backend/src/keys/service.rs index 1961bc65a..06129ea1e 100644 --- a/backend/src/keys/service.rs +++ b/backend/src/keys/service.rs @@ -86,7 +86,7 @@ impl KeyVerifier { pub async fn flush_pending_last_used(&self) { let updates = self.take_pending_last_used(); if let Err(error) = self.store.touch_last_used_batch(&updates).await { - tracing::error!(error = %error, "failed to flush client key last_used_at batch"); + tracing::error!(error = %error, "Failed to flush client key last_used_at batch"); merge_last_used_updates( &mut self .pending_last_used diff --git a/backend/src/main.rs b/backend/src/main.rs index 2960d20f3..9bd251e17 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,25 +1,29 @@ use std::{env, error::Error, io}; -#[tokio::main] -async fn main() -> Result<(), Box> { +use codex_proxy_rs::bootstrap::config::BootstrapConfig; + +fn main() -> Result<(), Box> { let mut arguments = env::args_os().skip(1); match arguments.next().as_deref().and_then(|value| value.to_str()) { None | Some("serve") => { reject_extra_arguments(arguments)?; - codex_proxy_rs::bootstrap::services::run().await + let config = BootstrapConfig::load()?; + runtime()?.block_on(codex_proxy_rs::bootstrap::services::run(config)) } Some("rebuild-buckets") => { reject_extra_arguments(arguments)?; - let config = codex_proxy_rs::bootstrap::config::AppConfig::load()?; - let pool = codex_proxy_rs::infra::database::connect(&config.database.url).await?; - let report = codex_proxy_rs::telemetry::rebuild::rebuild_buckets(&pool).await?; - println!( - "request_time_buckets rebuilt: cutoff={}, deleted={}, rebuilt={}", - report.cutoff.to_rfc3339(), - report.deleted_rows, - report.rebuilt_rows - ); - Ok(()) + let config = BootstrapConfig::load()?; + runtime()?.block_on(async move { + let pool = codex_proxy_rs::infra::database::connect(config.database_url()).await?; + let report = codex_proxy_rs::telemetry::rebuild::rebuild_buckets(&pool).await?; + println!( + "request_time_buckets rebuilt: cutoff={}, deleted={}, rebuilt={}", + report.cutoff.to_rfc3339(), + report.deleted_rows, + report.rebuilt_rows + ); + Ok(()) + }) } Some(command) => Err(invalid_cli(&format!( "unknown command {command:?}; expected serve or rebuild-buckets" @@ -28,6 +32,12 @@ async fn main() -> Result<(), Box> { } } +fn runtime() -> Result { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() +} + fn reject_extra_arguments(mut arguments: impl Iterator) -> Result<(), io::Error> { if arguments.next().is_some() { return Err(invalid_cli("unexpected extra command arguments")); diff --git a/backend/src/telemetry/recorder.rs b/backend/src/telemetry/recorder.rs index 3c941fbac..349f5ae60 100644 --- a/backend/src/telemetry/recorder.rs +++ b/backend/src/telemetry/recorder.rs @@ -69,7 +69,7 @@ impl Recorder { account_id = event.account_id, model = event.model, status_code = event.status_code, - "rejected invalid success usage fact" + "Rejected invalid success usage fact" ); return Err(RecorderError::InvalidUsageFact); } @@ -124,7 +124,7 @@ pub(crate) async fn record_dispatch_error_event(record: DispatchErrorLogRecord<' tracing::error!( account_id = record.account_id.unwrap_or(""), error = %error, - "failed to record dispatch error event" + "Failed to record dispatch error event" ); } } @@ -169,7 +169,7 @@ pub(crate) async fn record_response_event(record: ResponseUsageRecord<'_>) { event.metadata = metadata; if let Err(error) = record.recorder.record_usage(event).await { - tracing::error!(account_id = %record.account_id, error = %error, "failed to record response event"); + tracing::error!(account_id = %record.account_id, error = %error, "Failed to record response event"); } } diff --git a/backend/src/update/service.rs b/backend/src/update/service.rs index a0188c832..ba0a63648 100644 --- a/backend/src/update/service.rs +++ b/backend/src/update/service.rs @@ -366,7 +366,7 @@ impl SystemUpdateConfig { }); let state_file = env_string("CPR_UPDATE_STATE_FILE") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/app/data/update-state.json")); + .unwrap_or_else(|| PathBuf::from("/app/.runtime/data/update-state.json")); let update_lock_file = env_string("CPR_UPDATE_LOCK_FILE") .map(PathBuf::from) .unwrap_or_else(|| state_file.with_extension("lock")); diff --git a/backend/src/upstream/openai/transport/client.rs b/backend/src/upstream/openai/transport/client.rs index f146f1dfc..0dd067e6a 100644 --- a/backend/src/upstream/openai/transport/client.rs +++ b/backend/src/upstream/openai/transport/client.rs @@ -416,7 +416,7 @@ fn log_websocket_pool_decision( ws_pool_reason = reason, conversation_id_hash = pool_context.map_or("", |context| context.conversation_id_hash.as_str()), ws_pool_key_hash = pool_context.map_or("", |context| context.pool_key_hash.as_str()), - "websocket pool decision" + "WebSocket pool decision" ); } else { tracing::info!( @@ -426,7 +426,7 @@ fn log_websocket_pool_decision( ws_pool = decision.kind(), conversation_id_hash = pool_context.map_or("", |context| context.conversation_id_hash.as_str()), ws_pool_key_hash = pool_context.map_or("", |context| context.pool_key_hash.as_str()), - "websocket pool decision" + "WebSocket pool decision" ); } } diff --git a/backend/src/upstream/openai/transport/client_sse.rs b/backend/src/upstream/openai/transport/client_sse.rs index 881c6cf82..5f0b3762a 100644 --- a/backend/src/upstream/openai/transport/client_sse.rs +++ b/backend/src/upstream/openai/transport/client_sse.rs @@ -195,7 +195,7 @@ impl CodexBackendClient { fallback_transport = "http_sse", fallback_reason = "websocket_error", error = %error, - "websocket response failed; falling back to HTTP SSE" + "WebSocket response failed; falling back to HTTP SSE" ); self.create_response_http_sse(&upstream_request, context, started_at) .await @@ -245,7 +245,7 @@ impl CodexBackendClient { fallback_transport = "http_sse", fallback_reason = "websocket_error", error = %error, - "websocket response stream failed; falling back to HTTP SSE" + "WebSocket response stream failed; falling back to HTTP SSE" ); self.create_response_stream_http_sse(&upstream_request, context) .await @@ -278,7 +278,7 @@ impl CodexBackendClient { websocket_payload_audit_snapshot(&websocket_request), ); if let Err(error) = write_websocket_audit_artifact_from_env(&artifact).await { - tracing::warn!(error = %error, "failed to write Codex WebSocket audit artifact"); + tracing::warn!(error = %error, "Failed to write Codex WebSocket audit artifact"); } let pool_key = self.websocket_pool_key(upstream_request, context, pool_account_id); let pool_log_context = pool_key.as_ref().map(WebSocketPoolLogContext::from_key); @@ -345,7 +345,7 @@ impl CodexBackendClient { websocket_payload_audit_snapshot(&websocket_request), ); if let Err(error) = write_websocket_audit_artifact_from_env(&artifact).await { - tracing::warn!(error = %error, "failed to write Codex WebSocket audit artifact"); + tracing::warn!(error = %error, "Failed to write Codex WebSocket audit artifact"); } let pool_key = self.websocket_pool_key(upstream_request, context, pool_account_id); let pool_log_context = pool_key.as_ref().map(WebSocketPoolLogContext::from_key); @@ -373,7 +373,7 @@ impl CodexBackendClient { account_id = pool_account_id.or(context.account_id).unwrap_or_default(), websocket_connection_id = %exchange.websocket_connection_id, ws_pool = exchange.pool_decision.map_or("unpooled", WebSocketPoolDecision::kind), - "websocket response stream established" + "WebSocket response stream established" ); log_websocket_pool_decision( context.request_id, diff --git a/backend/src/upstream/openai/transport/websocket_frames.rs b/backend/src/upstream/openai/transport/websocket_frames.rs index 5ec180737..8825a404a 100644 --- a/backend/src/upstream/openai/transport/websocket_frames.rs +++ b/backend/src/upstream/openai/transport/websocket_frames.rs @@ -814,7 +814,7 @@ async fn discard_stream_websocket( pump_exit_reason, pump_exit_detail, pooled = pool_return.is_some(), - "discarding Responses WebSocket stream" + "Discarding Responses WebSocket stream" ); if let Some(pool_return) = pool_return { pool_return.lease.discard().await; diff --git a/backend/src/upstream/openai/transport/websocket_pool.rs b/backend/src/upstream/openai/transport/websocket_pool.rs index 8aecac68e..4a0f67828 100644 --- a/backend/src/upstream/openai/transport/websocket_pool.rs +++ b/backend/src/upstream/openai/transport/websocket_pool.rs @@ -351,7 +351,7 @@ impl CodexWebSocketPool { account_id = key.account_id(), conversation_id_hash = key.conversation_id_hash(), reservation_id = %reservation.id, - "removed stale WebSocket pool reservation" + "Removed stale WebSocket pool reservation" ); } None => {} diff --git a/backend/tests/api/admin/system_routes/mod.rs b/backend/tests/api/admin/system_routes/mod.rs index def283051..a7bbaf1ea 100644 --- a/backend/tests/api/admin/system_routes/mod.rs +++ b/backend/tests/api/admin/system_routes/mod.rs @@ -1199,9 +1199,9 @@ fn system_update_config(repository: &str, github_api_base: &str) -> SystemUpdate github_api_base: format!("{github_api_base}/repos"), executable_path: Some(PathBuf::from("/app/bin/codex-proxy-rs")), web_dist_dir: PathBuf::from("/app/web/dist"), - update_state_file: PathBuf::from("/app/data/update-state.json"), - update_lock_file: PathBuf::from("/app/data/update-state.lock"), - update_temp_dir: PathBuf::from("/app/data/update-tmp"), + update_state_file: PathBuf::from("/app/.runtime/data/update-state.json"), + update_lock_file: PathBuf::from("/app/.runtime/data/update-state.lock"), + update_temp_dir: PathBuf::from("/app/.runtime/data/update-tmp"), self_restart_enabled: false, } } diff --git a/backend/tests/api/middleware/trace.rs b/backend/tests/api/middleware/trace.rs index fd3917a71..a81e3ee2b 100644 --- a/backend/tests/api/middleware/trace.rs +++ b/backend/tests/api/middleware/trace.rs @@ -61,7 +61,7 @@ impl Write for SharedLogWriter { async fn wait_for_trace_output(logs: &SharedLogBuffer) -> String { for _ in 0..20 { let output = logs.content(); - if output.contains("completed HTTP request") { + if output.contains("Completed HTTP request") { return output; } sleep(Duration::from_millis(5)).await; @@ -105,15 +105,15 @@ async fn http_trace_should_include_request_id_and_completion_fields() { let output = wait_for_trace_output(&logs).await; assert!( output.contains("req_trace") - && output.contains("received HTTP request") - && output.contains("completed HTTP request") + && output.contains("Received HTTP request") + && output.contains("Completed HTTP request") && output.contains("\"status\":204") && output.contains("latency_ms"), "unexpected trace output: {output}" ); let received = json_events(&output) .into_iter() - .find(|event| event["fields"]["message"] == "received HTTP request") + .find(|event| event["fields"]["message"] == "Received HTTP request") .expect("received event should exist"); assert!(received["fields"].get("request_id").is_none()); assert!(received["fields"].get("method").is_none()); @@ -186,8 +186,8 @@ async fn http_trace_should_emit_one_terminal_event_for_server_error() { sleep(Duration::from_millis(10)).await; let output = logs.content(); - assert_eq!(output.matches("completed HTTP request").count(), 0); - assert_eq!(output.matches("failed HTTP request").count(), 1); + assert_eq!(output.matches("Completed HTTP request").count(), 0); + assert_eq!(output.matches("Failed HTTP request").count(), 1); } #[tokio::test(flavor = "current_thread")] diff --git a/backend/tests/bootstrap/config/mod.rs b/backend/tests/bootstrap/config/mod.rs index d5567dac5..f5a77bab8 100644 --- a/backend/tests/bootstrap/config/mod.rs +++ b/backend/tests/bootstrap/config/mod.rs @@ -1,515 +1,272 @@ -use codex_proxy_rs::bootstrap::config::LoggingConfig; -use codex_proxy_rs::bootstrap::config::{AdminConfig, AdminConfigError, AppConfig}; -use codex_proxy_rs::bootstrap::services::account_pool_options_from_config; -use serde::de::DeserializeOwned; -use std::{fs, process::Command}; +use std::fs; -const DEFAULT_CONFIG_YAML: &str = r#" -server: - host: 0.0.0.0 - port: 8080 -api: - base_url: https://chatgpt.com/backend-api -database: - url: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy -redis: - url: redis://127.0.0.1:6379 -quota: - refresh_interval_minutes: 5 - skip_exhausted: true -tls: - force_http11: false -ws_pool: - enabled: true - max_age_ms: 3300000 - max_per_account: 8 -fingerprint: - originator: Codex Desktop - app_version: 26.519.81530 - build_number: "3178" - platform: darwin - arch: arm64 - chromium_version: "146" - user_agent_template: "Codex Desktop/{version} ({platform}; {arch})" - default_headers: - - name: Accept-Encoding - value: gzip, deflate, br, zstd - - name: Accept-Language - value: en-US,en;q=0.9 - - name: sec-ch-ua-mobile - value: "?0" - - name: sec-ch-ua-platform - value: "\"macOS\"" - - name: sec-fetch-site - value: same-origin - - name: sec-fetch-mode - value: cors - - name: sec-fetch-dest - value: empty - header_order: - - authorization - - chatgpt-account-id - - originator - - x-openai-internal-codex-residency - - x-client-request-id - - x-codex-installation-id - - x-codex-turn-state - - openai-beta - - user-agent - - sec-ch-ua - - sec-ch-ua-mobile - - sec-ch-ua-platform - - accept-encoding - - accept-language - - sec-fetch-site - - sec-fetch-mode - - sec-fetch-dest - - content-type - - accept - - cookie -admin: - session_ttl_minutes: 1440 - default_username: admin - default_password: test-admin-password -logging: - level: info - stdout: true - file: - enabled: true - directory: .runtime/logs - retention_days: 14 - max_file_size_mb: 20 - max_files: 20 -telemetry: - enabled: true -"#; +use codex_proxy_rs::bootstrap::{ + config::{BootstrapConfig, ConfigError, TopologyOverrides}, + services::account_pool_options_from_config, +}; + +const CONFIG_EXAMPLE: &str = include_str!("../../../../deploy/config.example.yaml"); +const POSTGRES_PASSWORD: &str = "111111111111111111111111111111111111111111111111"; +const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222"; +const ADMIN_PASSWORD: &str = "test-admin-password"; #[test] -fn default_config_keeps_only_codex_backend() { - let cfg: AppConfig = parse_yaml_config(DEFAULT_CONFIG_YAML).unwrap(); - assert_eq!(cfg.server.host, "0.0.0.0"); - assert_eq!(cfg.api.base_url, "https://chatgpt.com/backend-api"); - assert!(cfg.model_aliases.is_empty()); - assert_eq!(cfg.auth.refresh_margin_seconds, 3600); - assert_eq!(cfg.auth.rotation_strategy, "smart"); - assert_eq!(cfg.auth.oauth_client_id, "app_EMoamEEZ73f0CkXaXp7hrann"); +fn config_loader_should_load_complete_example() { + let directory = write_config(&complete_config()); + + let loaded = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap(); + let config = loaded.app(); + assert_eq!( - cfg.auth.oauth_token_endpoint, - "https://auth.openai.com/oauth/token" + ( + config.server.host.as_str(), + config.server.port, + config.api.base_url.as_str(), + config.fingerprint.app_version.as_str(), + config.fingerprint.build_number.as_str(), + config.quota.refresh_interval_minutes, + config.ws_pool.initial_event_timeout_ms, + config.admin.default_username.as_str(), + config.telemetry.enabled, + ), + ( + "127.0.0.1", + 8080, + "https://chatgpt.com/backend-api", + "26.707.51957", + "5175", + 5, + 20_000, + "admin@cpr.local", + true, + ) ); - assert_eq!(cfg.quota.refresh_interval_minutes, 5); - assert!(cfg.quota.skip_exhausted); - assert!(cfg.ws_pool.enabled); - assert_eq!(cfg.ws_pool.max_age_ms, 3_300_000); - assert_eq!(cfg.ws_pool.max_per_account, 8); - assert_eq!(cfg.fingerprint.originator, "Codex Desktop"); - assert_eq!(cfg.fingerprint.app_version, "26.519.81530"); - assert_eq!(cfg.fingerprint.default_headers[0].name, "Accept-Encoding"); } #[test] -fn default_config_keeps_runtime_artifacts_under_runtime_directory() { - let cfg: AppConfig = parse_yaml_config(DEFAULT_CONFIG_YAML).unwrap(); +fn config_loader_should_resolve_paths_relative_to_config_file() { + let directory = write_config(&complete_config()); + + let loaded = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap(); assert_eq!( - cfg.database.url, - "postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy" + ( + loaded.app().runtime.data_directory.as_path(), + loaded.app().logging.file.directory.as_path(), + ), + ( + directory.path().join("../.runtime/data").as_path(), + directory.path().join("../.runtime/logs").as_path(), + ) ); - assert_eq!(cfg.redis.url, "redis://127.0.0.1:6379"); - assert_eq!(cfg.logging.file.directory, ".runtime/logs"); - assert!(cfg.telemetry.enabled); +} + +#[test] +fn config_loader_should_inject_service_passwords_into_connection_urls() { + let directory = write_config(&complete_config()); + + let loaded = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap(); + + assert_eq!( + (loaded.database_url(), loaded.redis_url()), + ( + "postgres://codex_proxy:111111111111111111111111111111111111111111111111@127.0.0.1:5432/codex_proxy", + "redis://:222222222222222222222222222222222222222222222222@127.0.0.1:6379/", + ) + ); +} + +#[test] +fn config_loader_should_apply_only_explicit_topology_overrides() { + let directory = write_config(&complete_config()); + let overrides = TopologyOverrides { + server_host: Some("0.0.0.0".to_string()), + server_port: Some(18080), + database_url: Some("postgres://codex_proxy@postgres:5432/codex_proxy".to_string()), + redis_url: Some("redis://redis:6379/".to_string()), + }; + + let loaded = BootstrapConfig::load_from_path_with_overrides( + directory.path().join("config.yaml"), + overrides, + ) + .unwrap(); + + assert_eq!( + ( + loaded.app().server.host.as_str(), + loaded.app().server.port, + loaded.app().database.url.as_str(), + loaded.app().redis.url.as_str(), + ), + ( + "0.0.0.0", + 18080, + "postgres://codex_proxy@postgres:5432/codex_proxy", + "redis://redis:6379/", + ) + ); +} + +#[test] +fn bootstrap_config_debug_should_redact_all_passwords() { + let directory = write_config(&complete_config()); + let loaded = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap(); + + let debug = format!("{loaded:?}"); + + assert!( + !debug.contains(POSTGRES_PASSWORD) + && !debug.contains(REDIS_PASSWORD) + && !debug.contains(ADMIN_PASSWORD) + && debug.contains("[REDACTED]") + ); +} + +#[test] +fn config_loader_should_reject_unknown_fields() { + let yaml = complete_config().replace(" port: 8080", " port: 8080\n unexpected: true"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert!(matches!(error, ConfigError::InvalidDocument { .. })); +} + +#[test] +fn config_loader_should_reject_missing_explicit_fields() { + let yaml = complete_config().replace(" enabled: true\n\n\n# Compose", "\n\n# Compose"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert!(matches!(error, ConfigError::InvalidDocument { .. })); +} + +#[test] +fn config_loader_should_reject_unsupported_schema_version() { + let yaml = complete_config().replace("schema_version: 1", "schema_version: 2"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert_eq!(error, ConfigError::UnsupportedSchemaVersion); +} + +#[test] +fn config_loader_should_reject_embedded_database_password() { + let yaml = complete_config().replace( + "postgres://codex_proxy@127.0.0.1", + "postgres://codex_proxy:duplicated@127.0.0.1", + ); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert_eq!(error, ConfigError::PasswordInUrl("database.url")); +} + +#[test] +fn config_loader_should_reject_non_hex_postgres_password() { + let yaml = complete_config().replace(POSTGRES_PASSWORD, "not-a-hex-password"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert_eq!( + error, + ConfigError::InvalidServicePassword("database.password") + ); +} + +#[test] +fn config_loader_should_reject_wrong_length_redis_password() { + let yaml = complete_config().replace(REDIS_PASSWORD, "2222222222222222"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert_eq!(error, ConfigError::InvalidServicePassword("redis.password")); +} + +#[test] +fn config_loader_should_reject_weak_admin_password() { + let yaml = complete_config().replace(ADMIN_PASSWORD, "password"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert_eq!(error, ConfigError::WeakAdminPassword); +} + +#[test] +fn config_loader_should_reject_admin_password_with_compose_interpolation() { + let yaml = complete_config().replace(ADMIN_PASSWORD, "strong$password-value"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert_eq!(error, ConfigError::WeakAdminPassword); +} + +#[test] +fn config_loader_should_reject_duplicate_fingerprint_headers() { + let yaml = complete_config().replace( + " - name: 'Accept-Language'", + " - name: 'Accept-Encoding'", + ); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert_eq!( + error, + ConfigError::InvalidField("fingerprint.default_headers") + ); +} + +#[test] +fn config_loader_should_reject_disabled_all_log_outputs() { + let yaml = complete_config() + .replace(" stdout: true", " stdout: false") + .replace(" enabled: true", " enabled: false"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert_eq!(error, ConfigError::InvalidField("logging")); } #[test] fn account_pool_options_should_use_quota_skip_exhausted() { - let mut cfg: AppConfig = parse_yaml_config(DEFAULT_CONFIG_YAML).unwrap(); - cfg.quota.skip_exhausted = false; + let directory = write_config(&complete_config()); + let loaded = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap(); + let mut config = loaded.app().clone(); + config.quota.skip_exhausted = false; - let options = account_pool_options_from_config(&cfg); + let options = account_pool_options_from_config(&config); assert!(!options.skip_quota_limited); } -#[test] -fn admin_config_should_reject_weak_default_password() { - let config = AdminConfig { - default_password: "123456".to_string(), - ..AdminConfig::default() - }; - - assert_eq!( - config.validate_default_password(), - Err(AdminConfigError::WeakDefaultPassword) - ); +fn complete_config() -> String { + CONFIG_EXAMPLE + .replace( + "password: &postgres_password ''", + &format!("password: &postgres_password '{POSTGRES_PASSWORD}'"), + ) + .replace( + "password: &redis_password ''", + &format!("password: &redis_password '{REDIS_PASSWORD}'"), + ) + .replace( + "default_password: ''", + &format!("default_password: '{ADMIN_PASSWORD}'"), + ) } -#[test] -fn admin_config_should_accept_explicit_strong_default_password() { - let config = AdminConfig { - default_password: "correct-horse-battery-staple".to_string(), - ..AdminConfig::default() - }; - - assert_eq!(config.validate_default_password(), Ok(())); -} - -#[test] -fn config_should_reject_unknown_top_level_sections() { - let err = parse_yaml_config::( - r" -server: - host: 127.0.0.1 - port: 8080 -api: - base_url: https://chatgpt.com/backend-api -unexpected: {} -database: - url: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy -redis: - url: redis://127.0.0.1:6379 -", - ) - .unwrap_err(); - - assert!( - err.to_string().contains("unexpected"), - "expected unknown section to be rejected, got {err}" - ); -} - -#[test] -fn config_loader_should_read_only_config_yaml() { - const CASE_ENV: &str = "CODEX_PROXY_TEST_CONFIG_FILE_ONLY_CASE"; - if std::env::var(CASE_ENV).as_deref() != Ok("child") { - run_isolated_config_test("config_loader_should_read_only_config_yaml", |command| { - command.env(CASE_ENV, "child"); - }); - return; - } - - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("config.yaml"), - r#" -server: - host: 127.0.0.1 - port: 8080 -api: - base_url: https://chatgpt.com/backend-api -database: - url: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy -redis: - url: redis://127.0.0.1:6379 -quota: - skip_exhausted: false -tls: - force_http11: false -ws_pool: - enabled: true - max_age_ms: 3300000 - max_per_account: 8 -fingerprint: - originator: Codex Desktop - app_version: 26.519.81530 - build_number: "3178" - platform: darwin - arch: arm64 - chromium_version: "146" - user_agent_template: "Codex Desktop/{version} ({platform}; {arch})" - default_headers: - - name: Accept-Encoding - value: gzip, deflate, br, zstd - - name: Accept-Language - value: en-US,en;q=0.9 - - name: sec-ch-ua-mobile - value: "?0" - - name: sec-ch-ua-platform - value: "\"macOS\"" - - name: sec-fetch-site - value: same-origin - - name: sec-fetch-mode - value: cors - - name: sec-fetch-dest - value: empty - header_order: - - authorization - - chatgpt-account-id - - originator - - x-openai-internal-codex-residency - - x-client-request-id - - x-codex-installation-id - - x-codex-turn-state - - openai-beta - - user-agent - - sec-ch-ua - - sec-ch-ua-mobile - - sec-ch-ua-platform - - accept-encoding - - accept-language - - sec-fetch-site - - sec-fetch-mode - - sec-fetch-dest - - content-type - - accept - - cookie -admin: - session_ttl_minutes: 1440 -logging: - level: info - stdout: true - file: - enabled: true - directory: .runtime/logs - retention_days: 14 - max_file_size_mb: 20 - max_files: 20 -"#, - ) - .unwrap(); - fs::write( - dir.path().join("ignored-extra.yaml"), - r" -server: - host: 0.0.0.0 -logging: - directory: ignored-logs -ws_pool: - enabled: false - max_age_ms: 120000 - max_per_account: 2 -", - ) - .unwrap(); - - let cfg = AppConfig::load_from_dir(dir.path()).unwrap(); - - assert_eq!(cfg.server.host, "127.0.0.1"); - assert_eq!(cfg.server.port, 8080); - assert_eq!( - cfg.database.url, - "postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy" - ); - assert_eq!(cfg.redis.url, "redis://127.0.0.1:6379"); - assert!(cfg.model_aliases.is_empty()); - assert_eq!(cfg.auth.max_concurrent_per_account, 3); - assert_eq!(cfg.quota.refresh_interval_minutes, 5); - assert!(!cfg.quota.skip_exhausted); - assert_eq!(cfg.auth.oauth_client_id, "app_EMoamEEZ73f0CkXaXp7hrann"); - assert_eq!( - cfg.auth.oauth_token_endpoint, - "https://auth.openai.com/oauth/token" - ); - assert!(cfg.ws_pool.enabled); - assert_eq!(cfg.ws_pool.max_age_ms, 3_300_000); - assert_eq!(cfg.ws_pool.max_per_account, 8); - assert_eq!(cfg.logging.file.directory, ".runtime/logs"); - assert_eq!(cfg.logging.file.retention_days, 14); - assert_eq!(cfg.logging.file.max_file_size_mb, 20); - assert_eq!(cfg.logging.file.max_files, 20); -} - -#[test] -fn server_config_should_reject_unknown_fields() { - let err = parse_yaml_config::( - &DEFAULT_CONFIG_YAML.replace(" port: 8080", " port: 8080\n trusted_proxies: []"), - ) - .unwrap_err(); - - assert!( - err.to_string().contains("trusted_proxies"), - "expected unknown server field to be rejected, got {err}" - ); -} - -#[test] -fn config_loader_should_read_explicit_config_file_from_env() { - const CASE_ENV: &str = "CODEX_PROXY_TEST_EXPLICIT_CONFIG_FILE_CASE"; - if std::env::var(CASE_ENV).as_deref() == Ok("child") { - let cfg = AppConfig::load().unwrap(); - assert_eq!(cfg.server.host, "127.0.0.2"); - assert_eq!(cfg.logging.file.directory, ".runtime/env-logs"); - return; - } - - let dir = tempfile::tempdir().unwrap(); - let config_file = dir.path().join("custom-config.yaml"); - fs::write( - &config_file, - DEFAULT_CONFIG_YAML - .replace("host: 0.0.0.0", "host: 127.0.0.2") - .replace("directory: .runtime/logs", "directory: .runtime/env-logs"), - ) - .unwrap(); - run_isolated_config_test( - "config_loader_should_read_explicit_config_file_from_env", - |command| { - command - .env(CASE_ENV, "child") - .env("CPR_CONFIG_FILE", &config_file); - }, - ); -} - -#[test] -fn config_loader_should_override_runtime_passwords_from_env() { - const CASE_ENV: &str = "CODEX_PROXY_TEST_CONFIG_PASSWORD_OVERRIDE_CASE"; - if std::env::var(CASE_ENV).as_deref() != Ok("child") { - run_isolated_config_test( - "config_loader_should_override_runtime_passwords_from_env", - |command| { - command - .env(CASE_ENV, "child") - .env("CPR_ADMIN_DEFAULT_PASSWORD", "admin @:/?#% password") - .env("CPR_POSTGRES_PASSWORD", "pg @:/?#%") - .env("CPR_REDIS_PASSWORD", "redis @:/?#%"); - }, - ); - return; - } - - let dir = write_default_config(); - let cfg = AppConfig::load_from_dir(dir.path()).unwrap(); - - assert_eq!( - cfg.database.url, - "postgres://codex_proxy:pg%20%40%3A%2F%3F%23%25@127.0.0.1:5432/codex_proxy" - ); - assert_eq!( - cfg.redis.url, - "redis://:redis%20%40%3A%2F%3F%23%25@127.0.0.1:6379" - ); - assert_eq!(cfg.admin.default_password, "admin @:/?#% password"); -} - -#[test] -fn config_loader_should_reject_empty_password_env() { - const CASE_ENV: &str = "CODEX_PROXY_TEST_EMPTY_CONFIG_PASSWORD_CASE"; - if std::env::var(CASE_ENV).as_deref() != Ok("child") { - run_isolated_config_test( - "config_loader_should_reject_empty_password_env", - |command| { - command - .env(CASE_ENV, "child") - .env("CPR_POSTGRES_PASSWORD", ""); - }, - ); - return; - } - - let dir = write_default_config(); - let error = AppConfig::load_from_dir(dir.path()).unwrap_err(); - - assert_eq!(error.to_string(), "CPR_POSTGRES_PASSWORD must not be empty"); -} - -#[test] -fn config_loader_should_reject_empty_admin_password_env() { - const CASE_ENV: &str = "CODEX_PROXY_TEST_EMPTY_ADMIN_PASSWORD_CASE"; - if std::env::var(CASE_ENV).as_deref() != Ok("child") { - run_isolated_config_test( - "config_loader_should_reject_empty_admin_password_env", - |command| { - command - .env(CASE_ENV, "child") - .env("CPR_ADMIN_DEFAULT_PASSWORD", ""); - }, - ); - return; - } - - let dir = write_default_config(); - let error = AppConfig::load_from_dir(dir.path()).unwrap_err(); - - assert_eq!( - error.to_string(), - "CPR_ADMIN_DEFAULT_PASSWORD must not be empty" - ); -} - -#[test] -fn config_loader_should_not_expose_password_when_url_is_invalid() { - const CASE_ENV: &str = "CODEX_PROXY_TEST_INVALID_CONFIG_URL_CASE"; - const SECRET: &str = "do-not-leak-this-password"; - if std::env::var(CASE_ENV).as_deref() != Ok("child") { - run_isolated_config_test( - "config_loader_should_not_expose_password_when_url_is_invalid", - |command| { - command - .env(CASE_ENV, "child") - .env("CPR_REDIS_PASSWORD", SECRET); - }, - ); - return; - } - - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("config.yaml"), - DEFAULT_CONFIG_YAML.replace("redis://127.0.0.1:6379", "not a URL"), - ) - .unwrap(); - let error = AppConfig::load_from_dir(dir.path()).unwrap_err(); - let message = error.to_string(); - - assert!(message.contains("redis.url")); - assert!(message.contains("CPR_REDIS_PASSWORD")); - assert!(!message.contains(SECRET)); -} - -#[test] -fn logging_config_should_reject_unknown_fields() { - let err = parse_yaml_config::( - r" -level: info -stdout: true -unexpected: true -file: - enabled: false - directory: .runtime/logs - retention_days: 14 - max_file_size_mb: 20 - max_files: 20 -", - ) - .unwrap_err(); - - assert!( - err.to_string().contains("unexpected"), - "expected unknown logging field to be rejected, got {err}" - ); -} - -fn parse_yaml_config(yaml: &str) -> Result { - config::Config::builder() - .add_source(config::File::from_str(yaml, config::FileFormat::Yaml)) - .build()? - .try_deserialize() -} - -fn write_default_config() -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("config.yaml"), DEFAULT_CONFIG_YAML).unwrap(); - dir -} - -fn run_isolated_config_test(test_name: &str, configure: impl FnOnce(&mut Command)) { - let current_exe = std::env::current_exe().expect("current test binary path"); - let mut command = Command::new(current_exe); - command - .arg("--exact") - .arg(format!("bootstrap::config::{test_name}")) - .arg("--nocapture") - .env_remove("CPR_CONFIG_FILE") - .env_remove("CPR_ADMIN_DEFAULT_PASSWORD") - .env_remove("CPR_POSTGRES_PASSWORD") - .env_remove("CPR_REDIS_PASSWORD"); - configure(&mut command); - - let output = command.output().expect("run isolated config test case"); - assert!( - output.status.success(), - "isolated config test {test_name} failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); +fn write_config(yaml: &str) -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("config.yaml"), yaml).unwrap(); + directory } diff --git a/backend/tests/infra/database.rs b/backend/tests/infra/database.rs index 9678a5fb0..a6674733e 100644 --- a/backend/tests/infra/database.rs +++ b/backend/tests/infra/database.rs @@ -61,6 +61,19 @@ async fn postgres_migrate_should_reject_changed_applied_sql_checksum() { assert!(error.to_string().contains("checksum mismatch")); } +#[tokio::test] +async fn postgres_migrate_should_skip_existing_migration_table() { + let (pool, _guard) = init_test_db("schema-existing-migration-table").await; + + migrate(&pool).await.unwrap(); + + let migration_count: i64 = sqlx::query_scalar("select count(*) from schema_migrations") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(migration_count, 1); +} + #[tokio::test] async fn postgres_schema_keeps_retrievable_client_keys_and_hashes_admin_credentials() { let (pool, _guard) = init_test_db("schema-credential-storage").await; diff --git a/backend/tests/infra/logging.rs b/backend/tests/infra/logging.rs index a1a94ec6d..dd6d02ec6 100644 --- a/backend/tests/infra/logging.rs +++ b/backend/tests/infra/logging.rs @@ -1,4 +1,4 @@ -use std::{fs, io::Write}; +use std::{fs, io::Write, process::Command}; use chrono::{Duration, Utc}; use codex_proxy_rs::infra::{ @@ -13,6 +13,53 @@ fn tracing_config_should_reject_disabled_outputs() { assert!(result.is_err()); } +#[test] +fn tracing_stdout_should_stay_json_with_china_time_when_not_a_terminal() { + const CASE_ENV: &str = "CODEX_PROXY_TEST_JSON_STDOUT_LOGGING"; + + if std::env::var(CASE_ENV).as_deref() == Ok("child") { + let _guard = init_tracing(&TracingConfig::new("info", true, None)).unwrap(); + tracing::info!(probe = 42, "logging format probe"); + return; + } + + let output = Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("infra::logging::tracing_stdout_should_stay_json_with_china_time_when_not_a_terminal") + .arg("--nocapture") + .env(CASE_ENV, "child") + .output() + .unwrap(); + assert!( + output.status.success(), + "isolated logging test failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let event = output + .stdout + .split(|byte| *byte == b'\n') + .find_map(|line| serde_json::from_slice::(line).ok()) + .unwrap(); + assert_eq!( + ( + event["level"].as_str(), + event["fields"]["message"].as_str(), + event["fields"]["probe"].as_i64(), + event["timestamp"] + .as_str() + .map(|timestamp| timestamp.ends_with("+08:00")), + ), + ( + Some("INFO"), + Some("logging format probe"), + Some(42), + Some(true) + ) + ); +} + #[test] fn rolling_appender_should_write_daily_codex_log_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/backend/tests/infra/paths.rs b/backend/tests/infra/paths.rs index c45dd3c40..2878c5cda 100644 --- a/backend/tests/infra/paths.rs +++ b/backend/tests/infra/paths.rs @@ -1,34 +1,14 @@ -use std::{fs, path::PathBuf, process::Command}; +use std::fs; use codex_proxy_rs::infra::paths::{ensure_data_dir, load_or_create_identity_secret}; const IDENTITY_SECRET_FILE_NAME: &str = "identity_hmac_secret"; -const DATA_DIR_CASE_ENV: &str = "CODEX_PROXY_TEST_DATA_DIR_CASE"; - #[test] -fn data_dir_should_use_xdg_data_home_directly() { - if std::env::var(DATA_DIR_CASE_ENV).as_deref() == Ok("child") { - let expected = PathBuf::from(std::env::var_os("XDG_DATA_HOME").unwrap()); - assert_eq!(ensure_data_dir().unwrap(), expected); - return; - } - +fn data_dir_should_use_explicit_configured_directory() { let dir = tempfile::tempdir().unwrap(); - let output = Command::new(std::env::current_exe().unwrap()) - .arg("--exact") - .arg("infra::paths::data_dir_should_use_xdg_data_home_directly") - .arg("--nocapture") - .env(DATA_DIR_CASE_ENV, "child") - .env("XDG_DATA_HOME", dir.path()) - .output() - .unwrap(); + let configured = dir.path().join("configured-data"); - assert!( - output.status.success(), - "isolated data directory test failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + assert_eq!(ensure_data_dir(&configured).unwrap(), configured); } #[test] diff --git a/backend/tests/infra/time.rs b/backend/tests/infra/time.rs index 237d86c26..b2b345bef 100644 --- a/backend/tests/infra/time.rs +++ b/backend/tests/infra/time.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use codex_proxy_rs::infra::time::{ - china_day_start, china_filename_timestamp_millis, china_hour, china_hour_start, + china_datetime, china_day_start, china_filename_timestamp_millis, china_hour, china_hour_start, china_quarter_hour_start, china_rfc3339_millis, }; @@ -26,6 +26,13 @@ fn china_rfc3339_millis_should_keep_china_offset() { ); } +#[test] +fn china_datetime_should_include_complete_china_calendar_time() { + let value = "2026-07-13T16:10:06.969Z".parse::>().unwrap(); + + assert_eq!(china_datetime(&value), "2026-07-14 00:10:06"); +} + #[test] fn china_filename_timestamp_millis_should_keep_china_offset_without_colons() { let value = "2026-06-24T16:36:59.190910486Z" diff --git a/backend/tests/support/config.rs b/backend/tests/support/config.rs index 94d213147..2c01fb058 100644 --- a/backend/tests/support/config.rs +++ b/backend/tests/support/config.rs @@ -1,6 +1,6 @@ use codex_proxy_rs::bootstrap::config::{ AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, FileLoggingConfig, - FingerprintConfig, LoggingConfig, QuotaConfig, RedisConfig, ServerConfig, TelemetryConfig, + LoggingConfig, QuotaConfig, RedisConfig, RuntimePathsConfig, ServerConfig, TelemetryConfig, TlsConfig, WebSocketPoolSettings, }; @@ -33,22 +33,29 @@ pub(crate) fn test_config(database_url: String) -> AppConfig { redis: RedisConfig { url: crate::support::storage::test_redis_url(), }, + runtime: RuntimePathsConfig { + data_directory: std::path::PathBuf::from(".runtime/test-data"), + }, tls: TlsConfig { force_http11: false, }, - ws_pool: WebSocketPoolSettings::default(), - fingerprint: FingerprintConfig::default(), + ws_pool: WebSocketPoolSettings { + enabled: true, + max_age_ms: 3_300_000, + max_per_account: 8, + initial_event_timeout_ms: 20_000, + }, + fingerprint: crate::support::fingerprint::test_fingerprint_config(), admin: AdminConfig { session_ttl_minutes: 1440, default_username: "admin".to_string(), - default_password: "test-admin-password".to_string(), }, logging: LoggingConfig { level: "info".to_string(), stdout: false, file: FileLoggingConfig { enabled: false, - directory: "logs".to_string(), + directory: std::path::PathBuf::from("logs"), retention_days: 14, max_file_size_mb: 20, max_files: 20, diff --git a/backend/tests/support/fingerprint.rs b/backend/tests/support/fingerprint.rs index a9c1eb0a5..401a57971 100644 --- a/backend/tests/support/fingerprint.rs +++ b/backend/tests/support/fingerprint.rs @@ -1,10 +1,63 @@ use codex_proxy_rs::{ - bootstrap::{config::FingerprintConfig, services::fingerprint_from_config}, + bootstrap::{ + config::{FingerprintConfig, FingerprintHeaderConfig}, + services::fingerprint_from_config, + }, upstream::openai::fingerprint::{Fingerprint, RuntimeFingerprint}, }; +pub(crate) fn test_fingerprint_config() -> FingerprintConfig { + let header = |name: &str, value: &str| FingerprintHeaderConfig { + name: name.to_string(), + value: value.to_string(), + }; + + FingerprintConfig { + originator: "Codex Desktop".to_string(), + app_version: "26.707.51957".to_string(), + build_number: "5175".to_string(), + platform: "darwin".to_string(), + arch: "arm64".to_string(), + chromium_version: "146".to_string(), + user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(), + default_headers: vec![ + header("Accept-Encoding", "gzip, deflate, br, zstd"), + header("Accept-Language", "en-US,en;q=0.9"), + header("sec-ch-ua-mobile", "?0"), + header("sec-ch-ua-platform", "\"macOS\""), + header("sec-fetch-site", "same-origin"), + header("sec-fetch-mode", "cors"), + header("sec-fetch-dest", "empty"), + ], + header_order: [ + "authorization", + "chatgpt-account-id", + "originator", + "x-openai-internal-codex-residency", + "x-client-request-id", + "x-codex-installation-id", + "x-codex-turn-state", + "openai-beta", + "user-agent", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "accept-encoding", + "accept-language", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "content-type", + "accept", + "cookie", + ] + .map(str::to_string) + .to_vec(), + } +} + pub(crate) fn test_fingerprint() -> Fingerprint { - fingerprint_from_config(&FingerprintConfig::default()) + fingerprint_from_config(&test_fingerprint_config()) } pub(crate) fn test_fingerprint_with_updated_at(updated_at: Option<&str>) -> Fingerprint { diff --git a/backend/tests/upstream/openai/transport/headers.rs b/backend/tests/upstream/openai/transport/headers.rs index d2c514573..8c3c73a31 100644 --- a/backend/tests/upstream/openai/transport/headers.rs +++ b/backend/tests/upstream/openai/transport/headers.rs @@ -646,7 +646,7 @@ async fn codex_backend_client_usage_should_use_wham_usage_headers() { headers .get("user-agent") .and_then(|value| value.to_str().ok()), - Some("Codex Desktop/26.519.81530 (darwin; arm64)") + Some("Codex Desktop/26.707.51957 (darwin; arm64)") ); assert_eq!( headers.get("accept").and_then(|value| value.to_str().ok()), @@ -762,7 +762,7 @@ async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order( request.turn_metadata = Some("turn-meta".to_string()); request.beta_features = Some("beta-a".to_string()); request.include_timing_metrics = Some("true".to_string()); - request.version = Some("26.519.81530".to_string()); + request.version = Some("26.707.51957".to_string()); request.codex_window_id = Some("cw_1".to_string()); request.parent_thread_id = Some("parent-1".to_string()); let client = CodexBackendClient::new( diff --git a/deploy/.env.example b/deploy/.env.example deleted file mode 100644 index 69eff9b48..000000000 --- a/deploy/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# Docker deployment secrets. Copy this file to deploy/.env and set every value. -CPR_ADMIN_DEFAULT_PASSWORD='' -CPR_POSTGRES_PASSWORD='' -CPR_REDIS_PASSWORD='' diff --git a/deploy/Dockerfile b/deploy/Dockerfile index af3ea1b7b..020d6e135 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -59,7 +59,8 @@ RUN apt-get update \ && printf '%s\n' "${TZ}" > /etc/timezone \ && groupadd --system --gid 10001 cpr \ && useradd --system --uid 10001 --gid cpr --home-dir /app --shell /usr/sbin/nologin cpr \ - && install -d -o cpr -g cpr /app/bin /app/data /app/logs /app/web \ + && install -d -o cpr -g cpr \ + /app/bin /app/deploy /app/.runtime/data /app/.runtime/logs /app/web \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/deploy/README.md b/deploy/README.md index 2897396f0..06b496d60 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,82 +1,125 @@ # 部署 -本目录包含 Dockerfile、Compose 文件和 Docker 配置模板。 +本目录只有两个配置入口: -## Compose +- `config.yaml`:应用行为与真实凭据,由 `config.example.yaml` 复制得到并被 Git 忽略。 +- `compose.yaml`:镜像、容器网络、端口、目录映射、健康检查和资源限制。 -首次部署先复制样例配置: +项目不使用 `.env` 配置文件。Compose 中的少量环境变量只描述容器内部拓扑,不是用户配置入口。 + +## 准备 + +从仓库根目录执行: ```bash mkdir -p .runtime/data .runtime/logs -cp deploy/config.example.yaml .runtime/config.yaml +install -d -m 0750 .runtime/postgres .runtime/redis +cp deploy/config.example.yaml deploy/config.yaml +sudo chown "$(id -u):10001" deploy/config.yaml +chmod 0640 deploy/config.yaml +``` + +分别执行三次以下命令: + +```bash +openssl rand -hex 24 +``` + +把结果写入 `deploy/config.yaml`: + +- `x-cpr.database.password` +- `x-cpr.redis.password` +- `x-cpr.admin.default_password` + +PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。管理员初始化密码至少 12 位、不能是 +常见弱口令且不能包含 `$`。三个密码不会通过环境变量覆盖,也不能嵌入连接 URL。 + +Linux 上应用容器以 `10001:10001` 运行,需要允许该组写入应用数据和日志目录: + +```bash sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs chmod 0770 .runtime/data .runtime/logs -sudo chown "$(id -u):10001" .runtime/config.yaml -chmod 0640 .runtime/config.yaml -cp deploy/.env.example deploy/.env -chmod 0600 deploy/.env ``` -`deploy/config.example.yaml` 是 Docker 部署模板。Compose 默认使用仓库根目录的 `.runtime/`: +`config.yaml` 通过 Compose `configs` 只读挂载。普通 Compose 对本地文件保留宿主机的 +UID/GID 和 mode,因此配置由当前用户持有,并只向容器组 `10001` 开放读取权限。 -- `.runtime/config.yaml` -> `/app/config.yaml` -- `.runtime/data` -> `/app/data` -- `.runtime/logs` -> `/app/logs` - -应用日志同时写入 `docker logs` 与 `.runtime/logs`。Compose 对应用、PostgreSQL、Redis -统一启用 `json-file` 的 `10m × 5` 轮转;应用文件日志还受配置中的自然日、单文件大小和文件总数限制。 - -Runtime 镜像和 PostgreSQL 的显示、日志时区固定为 `Asia/Shanghai`。PostgreSQL 时间列仍使用 -`timestamptz` 保存绝对时间点,修改时区只影响查询与日志的显示,不需要迁移或改写已有数据。 - -`deploy/.env` 是 Docker 部署的密钥配置文件,已被 Git 忽略且必须保持 mode `0600`。其中 -`CPR_ADMIN_DEFAULT_PASSWORD`、`CPR_POSTGRES_PASSWORD`、`CPR_REDIS_PASSWORD` 三项均为必填, -留空时 Compose 会在启动前失败。管理员密码必须至少 12 位且不能是常见弱口令。 - -Compose 会把 PostgreSQL、Redis 密码的同一个值同时传给服务端和应用;应用使用 URL API 覆盖连接串 -密码,并用管理员密码覆盖 `admin.default_password`。因此无需修改 `.runtime/config.yaml` 中的密码, -特殊字符也无需手工编码。本地 Rust 集成测试若复用该 Compose 服务,应使用与 `deploy/.env` 一致的 -测试连接串。 -编辑密码时保留 `deploy/.env` 中的单引号,使 `#`、`$` 等 Compose 语法字符按字面值传递。 - -如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,确保反代正常传递 `CF-Connecting-IP`、`X-Real-IP` 或 `X-Forwarded-For`。后端会按这组头自动解析真实客户端 IP;没有这些头时回落到直连 peer IP。 - -构建并启动: +## 启动 ```bash -# 先编辑 deploy/.env,设置全部三项密码。 -docker compose --env-file deploy/.env -f deploy/docker-compose.yml build -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d +docker compose -f deploy/compose.yaml config --quiet +docker compose -f deploy/compose.yaml pull +docker compose -f deploy/compose.yaml up -d --no-build +docker compose -f deploy/compose.yaml ps ``` -可以通过环境变量覆盖宿主机路径。容器内始终通过 `CPR_CONFIG_FILE=/app/config.yaml` 读取挂载后的配置: +健康检查: ```bash -CPR_CONFIG_FILE=/path/to/config.yaml \ -CPR_DATA_DIR=/path/to/data \ -CPR_LOG_DIR=/path/to/logs \ -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d +curl -i http://127.0.0.1:8080/healthz ``` -## 构建 +`204 No Content` 表示应用、PostgreSQL 和 Redis 均可用。 -Docker 构建上下文保持仓库根目录: +不要把未脱敏的 `docker compose config` 或 `docker inspect` 输出上传到工单;它们会包含 +PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`。 + +## 本地开发 + +本地 PostgreSQL 和 Redis 可继续由 Compose 启动: ```bash -docker build -f deploy/Dockerfile -t codex-proxy-rs:latest . +docker compose -f deploy/compose.yaml up -d postgres redis +cd backend +cargo run ``` -也可以用 Compose 构建: +后端会从当前目录向上查找 `deploy/config.yaml`。相对数据和日志目录以该文件所在目录解析; +Compose 只把监听地址和数据库、Redis 地址固定覆盖为容器内部服务名。 + +## 持久化 + +Compose 使用以下绑定目录: + +- `.runtime/data` → 应用身份密钥和更新状态 +- `.runtime/logs` → 应用文件日志 +- `.runtime/postgres` → PostgreSQL +- `.runtime/redis` → Redis AOF + +普通 `docker compose down` 不会删除这些目录。删除 `.runtime` 会永久清除本地状态,应在升级或 +迁移前备份整个目录。 + +旧版命名卷不会自动迁移到绑定目录。已有部署必须先备份 PostgreSQL 和 Redis,再启用新版 +`compose.yaml`,否则新容器会看到空的 `.runtime/postgres` 与 `.runtime/redis`。 + +## 密码语义 + +- `admin.default_password` 只在首次创建管理员时使用。 +- PostgreSQL 官方镜像只在空数据目录初始化时使用 `database.password`。 +- Redis 在每次容器创建时使用 `redis.password`。 + +已有 PostgreSQL 数据目录后,直接修改 `database.password` 不会修改数据库用户密码,只会导致 +应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。 + +## 构建与升级 ```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml build +docker compose -f deploy/compose.yaml build codex-proxy-rs +docker compose -f deploy/compose.yaml up -d ``` -需要写入版本元数据时: +拉取发布镜像: + +```bash +docker compose -f deploy/compose.yaml pull codex-proxy-rs +docker compose -f deploy/compose.yaml up -d --no-build +``` + +构建元数据仍可作为一次性进程环境传入,不需要 `.env` 文件: ```bash CPR_VERSION="$(ruby -ryaml -e 'puts YAML.load_file("release/version.yaml").fetch("version").delete_prefix("v")')" \ CPR_GIT_SHA="$(git rev-parse HEAD)" \ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -docker compose --env-file deploy/.env -f deploy/docker-compose.yml build +docker compose -f deploy/compose.yaml build codex-proxy-rs ``` diff --git a/deploy/docker-compose.yml b/deploy/compose.yaml similarity index 51% rename from deploy/docker-compose.yml rename to deploy/compose.yaml index 460c808a0..c671f9ecd 100644 --- a/deploy/docker-compose.yml +++ b/deploy/compose.yaml @@ -3,33 +3,37 @@ name: codex-proxy-rs x-json-logging: &json-logging driver: json-file options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' services: postgres: + extends: + file: config.yaml + service: postgres-credentials image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818 restart: unless-stopped command: - [ - "postgres", - "-c", - "timezone=Asia/Shanghai", - "-c", - "log_timezone=Asia/Shanghai", - ] + - postgres + - -c + - timezone=Asia/Shanghai + - -c + - log_timezone=Asia/Shanghai environment: TZ: Asia/Shanghai POSTGRES_DB: codex_proxy POSTGRES_USER: codex_proxy - POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:?CPR_POSTGRES_PASSWORD must be set in deploy/.env} POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256 volumes: - - postgres-data:/var/lib/postgresql + - type: bind + source: ../.runtime/postgres + target: /var/lib/postgresql + bind: + create_host_path: false ports: - - "127.0.0.1:5432:5432" + - '127.0.0.1:5432:5432' healthcheck: - test: ["CMD-SHELL", "pg_isready -U codex_proxy -d codex_proxy"] + test: [CMD-SHELL, pg_isready -U codex_proxy -d codex_proxy] interval: 5s timeout: 5s retries: 10 @@ -38,30 +42,20 @@ services: logging: *json-logging redis: + extends: + file: config.yaml + service: redis-credentials image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005 restart: unless-stopped - command: - [ - "redis-server", - "--appendonly", - "yes", - "--requirepass", - "${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env}", - ] volumes: - - redis-data:/data + - type: bind + source: ../.runtime/redis + target: /data + bind: + create_host_path: false ports: - - "127.0.0.1:6379:6379" + - '127.0.0.1:6379:6379' healthcheck: - test: - [ - "CMD", - "redis-cli", - "--no-auth-warning", - "-a", - "${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env}", - "ping", - ] interval: 5s timeout: 5s retries: 10 @@ -80,9 +74,8 @@ services: CPR_GIT_SHA: ${CPR_GIT_SHA:-unknown} CPR_BUILD_TIME: ${CPR_BUILD_TIME:-unknown} CPR_BUILD_TYPE: ${CPR_BUILD_TYPE:-release} - container_name: codex-proxy-rs restart: unless-stopped - user: "10001:10001" + user: '10001:10001' init: true stop_grace_period: 30s depends_on: @@ -91,14 +84,24 @@ services: redis: condition: service_healthy pids_limit: 256 - cpus: "2.0" + cpus: '2.0' mem_limit: 1g ports: - - "127.0.0.1:8080:8080" + - '127.0.0.1:8080:8080' volumes: - - ${CPR_CONFIG_FILE:-../.runtime/config.yaml}:/app/config.yaml:ro - - ${CPR_DATA_DIR:-../.runtime/data}:/app/data - - ${CPR_LOG_DIR:-../.runtime/logs}:/app/logs + - type: bind + source: ../.runtime/data + target: /app/.runtime/data + bind: + create_host_path: false + - type: bind + source: ../.runtime/logs + target: /app/.runtime/logs + bind: + create_host_path: false + configs: + - source: cpr_config + target: /app/deploy/config.yaml security_opt: - no-new-privileges:true cap_drop: @@ -106,28 +109,27 @@ services: tmpfs: - /tmp:size=64m,mode=1777 healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"] + test: [CMD, curl, -fsS, http://127.0.0.1:8080/healthz] interval: 30s timeout: 5s retries: 3 start_period: 10s environment: HOME: /app - XDG_DATA_HOME: /app/data - CPR_CONFIG_FILE: /app/config.yaml - CPR_ADMIN_DEFAULT_PASSWORD: ${CPR_ADMIN_DEFAULT_PASSWORD:?CPR_ADMIN_DEFAULT_PASSWORD must be set in deploy/.env} - CPR_POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:?CPR_POSTGRES_PASSWORD must be set in deploy/.env} - CPR_REDIS_PASSWORD: ${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env} + CPR_SERVER_HOST: 0.0.0.0 + CPR_SERVER_PORT: '8080' + CPR_DATABASE_URL: postgres://codex_proxy@postgres:5432/codex_proxy + CPR_REDIS_URL: redis://redis:6379/ CPR_DEPLOYMENT_MODE: docker CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs CPR_UPDATE_CHANNEL: stable - CPR_UPDATE_TEMP_DIR: /app/data/update-tmp - CPR_UPDATE_STATE_FILE: /app/data/update-state.json - CPR_UPDATE_LOCK_FILE: /app/data/update.lock + CPR_UPDATE_TEMP_DIR: /app/.runtime/data/update-tmp + CPR_UPDATE_STATE_FILE: /app/.runtime/data/update-state.json + CPR_UPDATE_LOCK_FILE: /app/.runtime/data/update.lock CPR_WEB_DIST_DIR: /app/web/dist - CPR_ENABLE_SELF_RESTART: "true" + CPR_ENABLE_SELF_RESTART: 'true' logging: *json-logging -volumes: - postgres-data: - redis-data: +configs: + cpr_config: + file: ./config.yaml diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 3d61d7ecc..e352e3c93 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1,106 +1,142 @@ -server: - # 容器内监听地址。Docker 部署保持 0.0.0.0。 - host: '0.0.0.0' - # 容器内服务端口,需与 docker-compose.yml 的 target port 一致。 - port: 8080 +# Codex Proxy RS 应用配置模板。 +# +# 使用: +# cp deploy/config.example.yaml deploy/config.yaml +# sudo chown "$(id -u):10001" deploy/config.yaml +# chmod 640 deploy/config.yaml +# +# 三个密码建议分别执行 `openssl rand -hex 24` 生成。 -api: - # ChatGPT / Codex 上游 API 地址。 - base_url: 'https://chatgpt.com/backend-api' +x-cpr: + schema_version: 1 -database: - # PostgreSQL 持久层。 - url: 'postgres://codex_proxy:codex_proxy@postgres:5432/codex_proxy' + server: + # 本地 cargo run 使用;Docker 内由 compose.yaml 固定覆盖为 0.0.0.0:8080。 + host: '127.0.0.1' + port: 8080 -redis: - # Redis 仅承载可丢弃的运行态与缓存。 - url: 'redis://:codex_proxy@redis:6379/' + api: + base_url: 'https://chatgpt.com/backend-api' -quota: - # 账号额度刷新间隔,单位分钟。 - refresh_interval_minutes: 5 - # 调度时跳过已耗尽账号。 - skip_exhausted: true + database: + # 本地连接地址;Docker 内由 compose.yaml 固定改用 postgres 服务名。 + # 密码不要写进 URL。 + url: 'postgres://codex_proxy@127.0.0.1:5432/codex_proxy' + # 必填:48 位十六进制字符。 + password: &postgres_password '' -tls: - # 某些网络环境下可强制 HTTP/1.1;默认使用自动协商。 - force_http11: false + redis: + # 本地连接地址;Docker 内由 compose.yaml 固定改用 redis 服务名。 + # 密码不要写进 URL。 + url: 'redis://127.0.0.1:6379/' + # 必填:48 位十六进制字符。 + password: &redis_password '' -ws_pool: - # 上游 WebSocket 连接池。 - enabled: true - # 单个连接最大存活时间,单位毫秒。 - max_age_ms: 3300000 - # 每个账号最多保留的连接数。 - max_per_account: 8 + runtime: + # 相对路径以 deploy/config.yaml 所在目录为基准。 + data_directory: '../.runtime/data' -fingerprint: - # 上游请求指纹。通常保持默认,除非需要模拟其他客户端版本。 - originator: Codex Desktop - app_version: '26.623.42026' - build_number: '4514' - platform: darwin - arch: arm64 - chromium_version: '146' - user_agent_template: Codex Desktop/{version} ({platform}; {arch}) - default_headers: - - name: Accept-Encoding - value: gzip, deflate, br, zstd - - name: Accept-Language - value: en-US,en;q=0.9 - - name: sec-ch-ua-mobile - value: '?0' - - name: sec-ch-ua-platform - value: '"macOS"' - - name: sec-fetch-site - value: same-origin - - name: sec-fetch-mode - value: cors - - name: sec-fetch-dest - value: empty - header_order: - # 上游请求头顺序。通常保持默认。 - - authorization - - chatgpt-account-id - - originator - - x-openai-internal-codex-residency - - x-client-request-id - - x-codex-installation-id - - x-codex-turn-state - - openai-beta - - user-agent - - sec-ch-ua - - sec-ch-ua-mobile - - sec-ch-ua-platform - - accept-encoding - - accept-language - - sec-fetch-site - - sec-fetch-mode - - sec-fetch-dest - - content-type - - accept - - cookie + quota: + refresh_interval_minutes: 5 + skip_exhausted: true -admin: - # 管理端登录会话有效期,单位分钟。 - session_ttl_minutes: 1440 - # 首次启动创建的默认管理员账号。Docker 部署通过 CPR_ADMIN_DEFAULT_PASSWORD 注入密码。 - default_username: admin@cpr.local - default_password: '' + tls: + force_http11: false -logging: - # RUST_LOG 存在时优先使用环境变量。 - level: info - # 同时写入 docker logs。 - stdout: true - file: - # 文件日志挂载到仓库 .runtime/logs。 + ws_pool: enabled: true - directory: /app/logs - retention_days: 14 - max_file_size_mb: 20 - max_files: 20 + max_age_ms: 3300000 + max_per_account: 8 + # 首个真实输出的绝对超时,单位毫秒;0 表示禁用。 + initial_event_timeout_ms: 20000 -telemetry: - # 记录真实 /v1 代理调用的成功与失败事实。 - enabled: true + fingerprint: + # PostgreSQL 中不存在当前指纹时,使用这里的数据初始化。 + originator: 'Codex Desktop' + app_version: '26.707.51957' + build_number: '5175' + platform: 'darwin' + arch: 'arm64' + chromium_version: '146' + user_agent_template: 'Codex Desktop/{version} ({platform}; {arch})' + + default_headers: + - name: 'Accept-Encoding' + value: 'gzip, deflate, br, zstd' + - name: 'Accept-Language' + value: 'en-US,en;q=0.9' + - name: 'sec-ch-ua-mobile' + value: '?0' + - name: 'sec-ch-ua-platform' + value: '"macOS"' + - name: 'sec-fetch-site' + value: 'same-origin' + - name: 'sec-fetch-mode' + value: 'cors' + - name: 'sec-fetch-dest' + value: 'empty' + + header_order: + - authorization + - chatgpt-account-id + - originator + - x-openai-internal-codex-residency + - x-client-request-id + - x-codex-installation-id + - x-codex-turn-state + - openai-beta + - user-agent + - sec-ch-ua + - sec-ch-ua-mobile + - sec-ch-ua-platform + - accept-encoding + - accept-language + - sec-fetch-site + - sec-fetch-mode + - sec-fetch-dest + - content-type + - accept + - cookie + + admin: + session_ttl_minutes: 1440 + default_username: 'admin@cpr.local' + # 仅首次创建管理员时使用;至少 12 个字符且不能包含 `$`。 + default_password: '' + + logging: + level: 'info' + stdout: true + file: + enabled: true + # 相对路径以 deploy/config.yaml 所在目录为基准。 + directory: '../.runtime/logs' + retention_days: 14 + max_file_size_mb: 20 + max_files: 20 + + telemetry: + enabled: true + + +# Compose 凭据桥接区。通常不需要修改。 +services: + postgres-credentials: + environment: + POSTGRES_PASSWORD: *postgres_password + + redis-credentials: + command: + - redis-server + - --appendonly + - 'yes' + - --requirepass + - *redis_password + healthcheck: + test: + - CMD + - redis-cli + - --no-auth-warning + - -a + - *redis_password + - ping diff --git a/deploy/docker-compose.smoke.yml b/deploy/docker-compose.smoke.yml deleted file mode 100644 index a6ed40c1f..000000000 --- a/deploy/docker-compose.smoke.yml +++ /dev/null @@ -1,11 +0,0 @@ -services: - postgres: - ports: !reset [] - - redis: - ports: !reset [] - - codex-proxy-rs: - container_name: ${COMPOSE_PROJECT_NAME}-app - ports: !override - - "127.0.0.1:${CPR_SMOKE_PORT:-18080}:8080" diff --git a/docs/architecture.md b/docs/architecture.md index f83f86e59..4dc11479d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,7 +105,7 @@ infra/ - `redis.rs`:创建 Redis `ConnectionManager`,统一添加 `cpr:` 键前缀,提供 ping。 - `identity.rs`:管理员密码哈希、API Key 和会话令牌生成、账号作用域 HMAC 伪名。 - `paths.rs`:确定本地数据目录,读取或创建 `identity_hmac_secret`。 -- `logging.rs`:结构化日志、stdout 输出和文件轮转。 +- `logging.rs`:TTY stdout 使用带颜色的 compact 格式;非 TTY stdout 与轮转文件使用 JSON,时间统一为中国时区。 - `time.rs`、`json.rs`、`format.rs`:跨模块使用的解析与格式化工具。 ### upstream @@ -344,7 +344,7 @@ bootstrap/ └── fingerprint_update.rs ``` -- `config.rs`:启动配置 schema、YAML 加载和密码环境变量覆盖。 +- `config.rs`:`x-cpr` YAML schema、相对路径解析、严格校验和启动秘密隔离。 - `services.rs`:创建存储、领域服务、上游客户端、路由和后台任务。 - `state.rs`:进程级运行配置和 PostgreSQL/Redis 健康检查。 - `shutdown.rs`:信号、管理端重启和进程替换协调。 @@ -354,16 +354,20 @@ bootstrap/ `serve` 的启动顺序固定: -1. 从 `CPR_CONFIG_FILE` 指定路径加载 YAML;未设置时读取当前目录的 `config.yaml`。 -2. 用 `CPR_ADMIN_DEFAULT_PASSWORD`、`CPR_POSTGRES_PASSWORD`、`CPR_REDIS_PASSWORD` 覆盖对应密码。 -3. 初始化日志并绑定监听地址。 -4. 连接 PostgreSQL,校验并执行迁移。 -5. 连接 Redis,统一使用 `cpr:` 键前缀。 -6. 读取或初始化 `runtime_settings`,把数据库设置应用到运行配置。 -7. 创建各领域存储、服务和 OpenAI 上游客户端。 -8. 从本地数据目录读取或创建 `identity_hmac_secret`。 -9. 初始化默认指纹、管理员、模型运行时缓存和内存账号池。 -10. 启动后台任务,挂载 HTTP 路由。 +1. 在创建 Tokio runtime 前,从当前目录向上查找 `deploy/config.yaml`。 +2. 解析 `x-cpr`、校验全部显式字段,并以配置文件目录为基准解析相对路径。 +3. 把 YAML 中的 PostgreSQL/Redis 密码注入基础 URL;Docker 只通过进程环境覆盖容器拓扑地址。 +4. 初始化日志并绑定监听地址。 +5. 连接 PostgreSQL,校验并执行迁移。 +6. 连接 Redis,统一使用 `cpr:` 键前缀。 +7. 读取或初始化 `runtime_settings`,把数据库设置应用到运行配置。 +8. 创建各领域存储、服务和 OpenAI 上游客户端。 +9. 从配置的数据目录读取或创建 `identity_hmac_secret`。 +10. 初始化默认指纹、管理员、模型运行时缓存和内存账号池。 +11. 释放管理员初始化密码并启动后台任务、挂载 HTTP 路由。 + +数据库、Redis 和管理员密码使用自动脱敏、Drop 时清零的启动类型,不进入 `AppConfig` 或 +`RuntimeConfig`,也不支持旧密码环境变量覆盖。 关闭时先停止接收新请求,再统一结束 HTTP 长流和入站 WebSocket,同时关闭上游 WebSocket 池与后台任务。全部连接最多排空 20 秒,单个后台任务最多等待 5 秒;超时后才强制结束剩余连接。 @@ -490,20 +494,25 @@ Redis 使用统一的 `cpr:` 前缀。 ### 本地文件 -Docker 默认把宿主机 `.runtime/data` 挂载到 `/app/data`,并设置 `XDG_DATA_HOME=/app/data`。 +Docker 把仓库根目录 `.runtime` 下的四类状态分别绑定到对应容器。应用配置通过 Compose +`configs` 只读挂载到 `/app/deploy/config.yaml`;宿主文件为当前用户所有、容器组 `10001` +只读,mode 为 `0640`。 ```text .runtime/ -├── config.yaml ├── data/ │ ├── identity_hmac_secret │ ├── update-state.json │ ├── update.lock │ └── update-tmp/ -└── logs/ +├── logs/ +├── postgres/ +└── redis/ ``` -PostgreSQL 数据不在 `.runtime/data`。Compose 使用 `postgres-data` 命名卷;Redis 使用 `redis-data` 命名卷并启用 AOF。更换应用镜像不会删除这些卷,执行 `docker compose down -v` 才会删除命名卷。 +应用容器中的数据与日志目录分别是 `/app/.runtime/data` 和 `/app/.runtime/logs`。PostgreSQL +使用 `.runtime/postgres`,Redis 使用 `.runtime/redis` 并启用 AOF。普通 +`docker compose down` 不删除绑定目录;删除 `.runtime` 才会清除本地状态。 ## 后台任务 diff --git a/skills/codex-proxy-rs/SKILL.md b/skills/codex-proxy-rs/SKILL.md index 1463fdcfe..07eedd413 100644 --- a/skills/codex-proxy-rs/SKILL.md +++ b/skills/codex-proxy-rs/SKILL.md @@ -21,7 +21,7 @@ description: Codex Proxy RS 仓库开发指南。Use when working on its Rust/Ax - 后端:遵循 `backend/src` 当前领域边界;测试统一放在 `backend/tests`,禁止写入 `backend/src`。 - 存储:PostgreSQL 是权威持久化,Redis 保存运行态数据,本地数据目录保存身份密钥和更新状态。 - 前端:使用 Vue 3 `