mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
refactor: consolidate runtime configuration
Use deploy/config.yaml as the structured source for application settings and service credentials, with strict validation and runtime-relative paths. Consolidate Compose assets under deploy, remove obsolete environment and smoke files, keep terminal logs readable while retaining JSON file output, and refresh the Codex Desktop fingerprint. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
+1
-7
@@ -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/
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
后端使用 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='<PostgreSQL 密码>'
|
||||
CPR_REDIS_PASSWORD='<Redis 密码>'
|
||||
```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`
|
||||
的权限是否正确。
|
||||
|
||||
### 需要从其他设备访问
|
||||
|
||||
|
||||
Generated
+1
-49
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
+1
-3
@@ -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"] }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ pub async fn responses_websocket(State(state): State<AppState>, 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,
|
||||
|
||||
@@ -48,7 +48,7 @@ fn make_http_span(request: &Request<Body>) -> Span {
|
||||
}
|
||||
|
||||
fn on_http_request(_request: &Request<Body>, _span: &Span) {
|
||||
debug!("received HTTP request");
|
||||
debug!("Received HTTP request");
|
||||
}
|
||||
|
||||
fn on_http_response(response: &Response<Body>, latency: Duration, _span: &Span) {
|
||||
@@ -58,7 +58,7 @@ fn on_http_response(response: &Response<Body>, 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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ pub fn router_with_assets(dist_dir: impl AsRef<Path>) -> Router<AppState> {
|
||||
|
||||
async fn healthz(State(state): State<AppState>) -> 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
|
||||
|
||||
+519
-269
@@ -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<String>;
|
||||
|
||||
/// 已加载并完成校验的启动配置。
|
||||
///
|
||||
/// 数据库、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<Self, ConfigError> {
|
||||
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<Path>) -> Result<Self, ConfigError> {
|
||||
Self::load_from_path_with_overrides(config_path, TopologyOverrides::default())
|
||||
}
|
||||
|
||||
/// 从指定 YAML 文件加载配置,并应用明确的容器拓扑覆盖。
|
||||
pub fn load_from_path_with_overrides(
|
||||
config_path: impl AsRef<Path>,
|
||||
overrides: TopologyOverrides,
|
||||
) -> Result<Self, ConfigError> {
|
||||
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<String>,
|
||||
/// 容器内监听端口。
|
||||
pub server_port: Option<u16>,
|
||||
/// 容器内 PostgreSQL 地址,不包含密码。
|
||||
pub database_url: Option<String>,
|
||||
/// 容器内 Redis 地址,不包含密码。
|
||||
pub redis_url: Option<String>,
|
||||
}
|
||||
|
||||
impl TopologyOverrides {
|
||||
fn from_environment() -> Result<Self, ConfigError> {
|
||||
let server_port = optional_environment_value(SERVER_PORT_ENV)?
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<u16>()
|
||||
.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<String, String>,
|
||||
/// 认证与刷新配置,由固定默认值和数据库运行时设置承载,配置文件不承载该字段。
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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<PathBuf, ConfigError> {
|
||||
start
|
||||
.ancestors()
|
||||
.map(|directory| directory.join(CONFIG_RELATIVE_PATH))
|
||||
.find(|candidate| candidate.is_file())
|
||||
.ok_or(ConfigError::ConfigFileNotFound)
|
||||
}
|
||||
|
||||
fn absolute_path(path: &Path) -> Result<PathBuf, ConfigError> {
|
||||
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<Self, ::config::ConfigError> {
|
||||
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<Path>) -> Result<Self, ::config::ConfigError> {
|
||||
load_file(config_dir.as_ref().join("config.yaml"))
|
||||
fn optional_environment_value(name: &'static str) -> Result<Option<String>, 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<Path>) -> Result<AppConfig, ::config::ConfigError> {
|
||||
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<Option<String>, ::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<SecretValue, ConfigError> {
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -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<dyn Error + Send + Sync>> {
|
||||
pub async fn run(config: BootstrapConfig) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
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<dyn Error + Send + Sync>> {
|
||||
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<dyn Error + Send + Sync>> {
|
||||
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<dyn Error + Send + Sync>,
|
||||
> {
|
||||
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,
|
||||
"默认管理员账号已完成初始化检查"
|
||||
|
||||
@@ -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<AppConfig> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 定期版本检查失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 刷新任务完成"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 刷新定时器调度失败");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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<bool, sqlx::Error> {
|
||||
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(
|
||||
|
||||
@@ -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<LogGuard, LogError> {
|
||||
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<LogGuard, LogError> {
|
||||
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<LogGuard, LogError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_layer<S>(
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
let dir = data_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
pub fn ensure_data_dir(directory: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
let directory = directory.as_ref();
|
||||
std::fs::create_dir_all(directory)?;
|
||||
Ok(directory.to_path_buf())
|
||||
}
|
||||
|
||||
/// 读取或创建账号身份隔离使用的 256-bit HMAC 密钥。
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-13
@@ -1,25 +1,29 @@
|
||||
use std::{env, error::Error, io};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
use codex_proxy_rs::bootstrap::config::BootstrapConfig;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
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<dyn Error + Send + Sync>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Result<tokio::runtime::Runtime, io::Error> {
|
||||
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"));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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::<AppConfig>(
|
||||
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::<AppConfig>(
|
||||
&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::<LoggingConfig>(
|
||||
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<T: DeserializeOwned>(yaml: &str) -> Result<T, config::ConfigError> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<serde_json::Value>(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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::<DateTime<Utc>>().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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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=''
|
||||
+2
-1
@@ -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
|
||||
|
||||
+90
-47
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
+131
-95
@@ -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
|
||||
|
||||
@@ -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"
|
||||
+25
-16
@@ -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` 才会清除本地状态。
|
||||
|
||||
## 后台任务
|
||||
|
||||
|
||||
@@ -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 `<script setup>`、TypeScript、Tailwind v4、已有组件和主题 token。
|
||||
- Docker / 发布:保留非 root runtime、Compose 密钥注入、命名卷、GitHub Release、GHCR 和在线更新契约。
|
||||
- Docker / 发布:保留非 root runtime、YAML 凭据桥接、`.runtime` 绑定目录、GitHub Release、GHCR 和在线更新契约。
|
||||
- 文档:README 面向部署者和使用者;`docs/architecture.md` 面向开发者。不要新增过程型审计或迁移文档。
|
||||
|
||||
## 验证
|
||||
@@ -35,10 +35,10 @@ cargo clippy --all-targets --all-features --locked
|
||||
cargo test --test main --locked
|
||||
pnpm --dir frontend format:check
|
||||
pnpm --dir frontend build
|
||||
docker compose --env-file deploy/.env -f deploy/docker-compose.yml config
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
```
|
||||
|
||||
后端集成测试需要可用的 PostgreSQL 和 Redis;通过 `CPR_TEST_DATABASE_URL`、`CPR_TEST_REDIS_URL` 指定测试连接。使用部署 Compose 时,连接密码必须与 `deploy/.env` 一致。
|
||||
后端集成测试需要可用的 PostgreSQL 和 Redis;通过 `CPR_TEST_DATABASE_URL`、`CPR_TEST_REDIS_URL` 指定测试连接。使用部署 Compose 时,连接密码必须与 `deploy/config.yaml` 一致。
|
||||
|
||||
涉及在线更新时,至少运行 `api::admin::system_routes` 相关测试。涉及发布或镜像时,按 `references/repo-guide.md` 核对版本元数据、Release asset 和镜像 tag。
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- `backend/tests/`:后端集成测试和 fixtures。测试代码禁止放进 `backend/src/`。
|
||||
- `backend/build.rs`:把版本、Git SHA、构建时间和构建类型写入编译期环境变量。
|
||||
- `frontend/`:Vue 3、Vite 8、Tailwind v4、Pinia、Vue Router、Axios、Lucide 和 ECharts 管理端。
|
||||
- `deploy/`:Dockerfile、Compose、`config.example.yaml` 和 `.env.example`。
|
||||
- `deploy/`:Dockerfile、唯一的 `compose.yaml` 和 `config.example.yaml`。
|
||||
- `docs/architecture.md`:唯一长期架构文档。
|
||||
- `release/`:版本、目标平台和发布脚本。
|
||||
- `skills/`:项目本地 Codex skills。
|
||||
@@ -114,34 +114,32 @@ cargo run -- serve
|
||||
- Builder 使用 Node 24 和 Rust 1.97,构建工具不得进入 runtime。
|
||||
- 默认应用镜像是 `ghcr.io/zyycn/codex-proxy-rs:latest`。
|
||||
|
||||
`deploy/.env.example` 是 Docker 密钥模板,包含:
|
||||
|
||||
- `CPR_ADMIN_DEFAULT_PASSWORD`
|
||||
- `CPR_POSTGRES_PASSWORD`
|
||||
- `CPR_REDIS_PASSWORD`
|
||||
|
||||
`deploy/config.example.yaml` 是启动配置模板。真实密码只放在 `deploy/.env`,应用通过环境变量覆盖连接 URL 的密码。
|
||||
`deploy/config.example.yaml` 是完整启动配置模板。真实配置复制为被 Git 忽略的
|
||||
`deploy/config.yaml`,其中同时保存应用行为、管理员初始化密码、PostgreSQL 密码和 Redis 密码。
|
||||
服务密码通过 YAML anchor 与 Compose `extends` 传给内置服务,不使用 `.env` 配置文件。
|
||||
|
||||
首次部署:
|
||||
|
||||
```bash
|
||||
mkdir -p .runtime/data .runtime/logs
|
||||
cp deploy/config.example.yaml .runtime/config.yaml
|
||||
cp deploy/.env.example deploy/.env
|
||||
# 设置 deploy/.env 后执行
|
||||
docker compose --env-file deploy/.env -f deploy/docker-compose.yml config
|
||||
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
|
||||
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/config.yaml 中的三个密码后执行
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
docker compose -f deploy/compose.yaml up -d
|
||||
```
|
||||
|
||||
挂载和卷:
|
||||
|
||||
- `.runtime/config.yaml` -> `/app/config.yaml`,只读
|
||||
- `.runtime/data` -> `/app/data`
|
||||
- `.runtime/logs` -> `/app/logs`
|
||||
- `postgres-data` -> PostgreSQL 权威数据
|
||||
- `redis-data` -> Redis AOF
|
||||
- `deploy/config.yaml` -> `/app/deploy/config.yaml`,Compose config、只读(宿主 mode `0640`)
|
||||
- `.runtime/data` -> `/app/.runtime/data`
|
||||
- `.runtime/logs` -> `/app/.runtime/logs`
|
||||
- `.runtime/postgres` -> PostgreSQL 权威数据
|
||||
- `.runtime/redis` -> Redis AOF
|
||||
|
||||
普通升级不得执行 `docker compose down -v`。该命令会删除 PostgreSQL 和 Redis 命名卷。
|
||||
普通 `docker compose down` 不删除绑定目录;删除 `.runtime` 才会清除本地状态。
|
||||
|
||||
## 发布和在线更新
|
||||
|
||||
@@ -172,9 +170,9 @@ GHCR 发布 `<version>` 和 `sha-<git-sha>` tag;只有稳定版本更新 `late
|
||||
- `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`
|
||||
|
||||
@@ -205,7 +203,7 @@ CPR_TEST_DATABASE_URL
|
||||
CPR_TEST_REDIS_URL
|
||||
```
|
||||
|
||||
使用部署 Compose 时,测试 URL 的密码必须与 `deploy/.env` 一致并正确进行 URL 编码。不要在日志或提交中输出真实密码。
|
||||
使用部署 Compose 时,测试 URL 的密码必须与 `deploy/config.yaml` 一致并正确进行 URL 编码。不要在日志或提交中输出真实密码。
|
||||
|
||||
前端:
|
||||
|
||||
@@ -217,7 +215,7 @@ pnpm --dir frontend build
|
||||
Compose:
|
||||
|
||||
```bash
|
||||
docker compose --env-file deploy/.env -f deploy/docker-compose.yml config
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
```
|
||||
|
||||
聚焦测试应使用现有集成测试模块过滤;只有跨模块契约、存储或用户流程变化时才扩大到完整门禁。发布与镜像改动还需核对 `.github/workflows/_quality.yml`、`.github/workflows/_container.yml` 和 `.github/workflows/release.yml`。
|
||||
|
||||
Reference in New Issue
Block a user