feat: add encrypted admin account import

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-12 01:23:50 +08:00
co-authored by Codex
parent a02c6b420e
commit 228febde67
11 changed files with 732 additions and 5 deletions
+42
View File
@@ -116,6 +116,48 @@ Success response:
}
```
### `GET /admin/accounts`
Returns stored Codex accounts with cursor pagination. Access tokens and refresh tokens are never decrypted or returned.
### `POST /admin/accounts/import`
Imports accounts into encrypted SQLite storage. The request body uses the Rust import format, which accepts the exported account object fields needed by this service and ignores unrelated fields.
Request:
```json
{
"accounts": [
{
"id": "acct_01",
"email": "user@example.com",
"accountId": "chatgpt-account",
"userId": "chatgpt-user",
"label": "primary",
"planType": "plus",
"token": "access-token",
"refreshToken": "refresh-token",
"status": "active"
}
]
}
```
Success response:
```json
{
"code": 200,
"message": "OK",
"data": {
"imported": 1,
"skipped": 0
},
"requestId": "req_01"
}
```
Success body:
```json
+2 -2
View File
@@ -9,14 +9,14 @@ Update this file before each feature commit.
| Configuration | Completed | `9ed8579` + `e0680bf` + `cfec8ba` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_default_local_and_cprs_env_overrides`; full `fmt/test/clippy` before commit | Defines Codex-only config, default/local/env loading, default YAML, `Arc<AppServices>` state shell, `gpt-5.5` default model, and in-scope model/auth/quota/usage/logging settings fields without proxy/Ollama/provider/OpenAI-key knobs. |
| SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. |
| Admin auth and client API keys | Completed | `24d5799` + `4e0d1e5` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; full `fmt/test/clippy` before commit | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. |
| Secret encryption | Completed | `105aa58` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1:<nonce>:<ciphertext>` without plaintext. |
| Secret encryption | Completed | `105aa58` + `pending` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; `cargo test secret_box_load_or_create_should_reuse_existing_master_key_file`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1:<nonce>:<ciphertext>` without plaintext and can load/create the runtime master key file used by repositories. |
| Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page<T>` camelCase serialization, admin `/admin/logs` query API, event log repository, runtime tracing initialization, HTTP trace layer, and date/size-based file rotation. |
| TLS headers and fingerprint | Completed | `1bdd08c` | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. |
| Cookie persistence | Completed | `4710926` + `5d3cc6e` | `cargo test cookie_jar_captures_and_replays_account_scoped_cookies`; `cargo test cookie_repository_encrypts_and_replays_account_scoped_cookies`; full `fmt/test/clippy` before commit | Adds account-scoped in-memory Cookie jar and encrypted SQLite Cookie repository; Cookies are replayed only for the same account/domain. |
| Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; full `fmt/test/clippy` before commit | Implements base account status filtering, refresh scheduler semantics, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, and capacity summary. Still missing settings-backed scheduling config, SQLite repository integration for pool snapshots/release usage, upstream retry/fallback wiring, and admin account CRUD. |
| Account repository | Completed | `14fe706` | `cargo test --test account_repository_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds encrypted SQLite account token storage, decrypting reads, cursor pagination, status/label updates, release-time usage counter persistence, and refresh-token-preserving token updates. |
| Translation | Completed | `eff182e` | `cargo test chat_completion_translates_to_codex_response_request`; full `fmt/test/clippy` before commit | Adds OpenAI Chat request types, Codex Responses request type, translation, and OpenAI-compatible error body helper. |
| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. Still missing admin account/auth/API-key management routes, settings mutation/persistence, diagnostics, usage stats, backend model refresh, model cache persistence, and additional Responses routes. |
| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `pending` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/import`, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Still missing OAuth/device/CLI account login flows, admin account mutation/health/quota/Cookie routes, admin API-key management routes, settings mutation/persistence, diagnostics, usage stats, backend model refresh, model cache persistence, and additional Responses routes. |
| Admin/frontend response contract | Completed | `1c34ea7` + `b0c10f2` | `cargo test admin_envelope_serializes_lower_camel_case_request_id`; `cargo test admin_logs_reject_missing_admin_session_cookie`; `cargo test v1_response_has_request_id_header_without_admin_body_field`; full `fmt/test/clippy` before commit | Adds reusable admin envelopes with body codes, lower camelCase JSON, paginated list shape, requestId bodies, admin session cookie enforcement, and keeps `/v1/*` OpenAI-compatible. |
| HTTP auth boundary | Completed | `cec4607` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_logs_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds a real `src/http/auth.rs` boundary for client API key and admin session cookie parsing. `/v1/*` accepts only `Bearer cpr_...`, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. |
| Model catalog | Partial | `6341336` | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; full `fmt/test/clippy` before commit | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, and debug summary. Still missing backend Codex model fetching per plan, cache persistence, `/admin/refresh-models`, plan allowlist integration from fetched models, and full model metadata parity. |
@@ -2962,6 +2962,7 @@ git commit -m "docs: document codex-proxy-rs scope"
- [x] Set `gpt-5.5` as the default/test model baseline across code, tests, and plan examples. `/v1/models` now asserts the exact default model instead of only checking for a `gpt` prefix.
- [x] Add read-only `/admin/settings` for the retained Rust settings surface. The response is admin-session gated, lower camelCase, and excludes proxy URL, Ollama, third-party provider, OpenAI official API key, proxy API key, and Electron/self-update fields. Persistent settings mutation remains a separate task.
- [x] Add a native static model catalog foundation. It exposes `gpt-5.5`, configured aliases, reasoning/service tier suffix parsing, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, and `/debug/models`. Backend model fetching/cache and `/admin/refresh-models` remain separate tasks.
- [x] Add encrypted admin account import/list foundations. `/admin/accounts/import` stores access/refresh tokens through `SecretBox` and `/admin/accounts` returns paginated sanitized account metadata without tokens or token decryption. The runtime now loads or creates the master key file before constructing repository-backed routes.
## 2026-06-11 Original Project Gap Audit
@@ -2971,7 +2972,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav
- [ ] **HTTP auth boundary:** restore a real `src/http/auth.rs` module instead of scattered header checks. It must validate `Bearer cpr_...` client API keys for `/v1/*`, validate admin session cookies for `/admin/*`, reject cross-use in both directions, and return the documented OpenAI/admin response families.
- [ ] **Account SQLite repository:** implement `src/accounts/repository.rs` for encrypted access/refresh tokens, account metadata, quota JSON, usage counters, labels, status transitions, pagination, and batch-safe mutations. Do not add TypeScript JSON migration or legacy import shims.
- [ ] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned.
- [ ] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned. Encrypted import and paginated sanitized list are complete; export, mutations, health, quota, Cookie CRUD, and warnings remain.
- [ ] **Login routes:** implement OAuth PKCE login-start/code-relay/callback, device-login/device-poll, CLI auth import, manual access-token import, and logout as account-login flows. Keep this separate from admin password login and client API key creation.
- [ ] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access.
- [ ] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. Read-only `/admin/settings` is complete; persistent mutation and config-store writeback remain. Remove proxy URL, Ollama, third-party provider, OpenAI official key, and Electron/self-update settings.
+84
View File
@@ -62,6 +62,20 @@ pub struct StoredAccount {
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct StoredAccountMetadata {
pub id: String,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub status: AccountStatus,
pub added_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy)]
pub struct UsageDelta {
pub input_tokens: i64,
@@ -128,6 +142,14 @@ impl AccountRepository {
row.map(|row| self.account_from_row(&row)).transpose()
}
pub async fn exists(&self, id: &str) -> AccountRepositoryResult<bool> {
let row = sqlx::query("select 1 from accounts where id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
pub async fn list(
&self,
cursor: Option<String>,
@@ -171,6 +193,49 @@ impl AccountRepository {
Ok(Page { items, next_cursor })
}
pub async fn list_metadata(
&self,
cursor: Option<String>,
limit: u32,
) -> AccountRepositoryResult<Page<StoredAccountMetadata>> {
let fetch_limit = i64::from(limit) + 1;
let rows = if let Some(cursor) = cursor {
let (created_at, id) =
decode_cursor(&cursor).ok_or(AccountRepositoryError::InvalidCursor)?;
sqlx::query(
"select id, email, account_id, user_id, label, plan_type, access_token_expires_at, status, added_at, updated_at from accounts where added_at < ? or (added_at = ? and id < ?) order by added_at desc, id desc limit ?",
)
.bind(&created_at)
.bind(created_at)
.bind(id)
.bind(fetch_limit)
.fetch_all(&self.pool)
.await?
} else {
sqlx::query(
"select id, email, account_id, user_id, label, plan_type, access_token_expires_at, status, added_at, updated_at from accounts order by added_at desc, id desc limit ?",
)
.bind(fetch_limit)
.fetch_all(&self.pool)
.await?
};
let has_next = rows.len() > limit as usize;
let take_count = rows.len().min(limit as usize);
let mut items = Vec::with_capacity(take_count);
for row in rows.into_iter().take(take_count) {
items.push(metadata_from_row(&row)?);
}
let next_cursor = if has_next {
items
.last()
.map(|account| encode_cursor(&account.added_at.to_rfc3339(), &account.id))
} else {
None
};
Ok(Page { items, next_cursor })
}
pub async fn set_status(
&self,
id: &str,
@@ -338,3 +403,22 @@ fn usage_from_row(row: &sqlx::sqlite::SqliteRow) -> AccountRepositoryResult<Acco
last_used_at: parse_optional_rfc3339(row.get::<Option<String>, _>("last_used_at"))?,
})
}
fn metadata_from_row(
row: &sqlx::sqlite::SqliteRow,
) -> AccountRepositoryResult<StoredAccountMetadata> {
Ok(StoredAccountMetadata {
id: row.get("id"),
email: row.get("email"),
account_id: row.get("account_id"),
user_id: row.get("user_id"),
label: row.get("label"),
plan_type: row.get("plan_type"),
access_token_expires_at: parse_optional_rfc3339(
row.get::<Option<String>, _>("access_token_expires_at"),
)?,
status: status_from_db(&row.get::<String, _>("status"))?,
added_at: parse_rfc3339(&row.get::<String, _>("added_at"))?,
updated_at: parse_rfc3339(&row.get::<String, _>("updated_at"))?,
})
}
+3 -1
View File
@@ -7,7 +7,7 @@ use tower_http::trace::TraceLayer;
use crate::{
http::{
admin::{login, logs, settings},
admin::{accounts, import_accounts, login, logs, settings},
health::health,
middleware::attach_request_id,
v1::{debug_models, model_catalog, model_detail, model_info, models, responses},
@@ -28,6 +28,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/admin/login", post(login))
.route("/admin/logs", get(logs))
.route("/admin/settings", get(settings))
.route("/admin/accounts", get(accounts))
.route("/admin/accounts/import", post(import_accounts))
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(from_fn(attach_request_id))
+33
View File
@@ -1,3 +1,5 @@
use std::{fs, path::Path};
use aes_gcm::{
aead::{rand_core::RngCore, Aead, OsRng},
Aes256Gcm, KeyInit, Nonce,
@@ -22,6 +24,10 @@ pub enum CryptoError {
UnsupportedVersion,
#[error("secret is not valid utf-8: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("secret key file io error: {0}")]
Io(#[from] std::io::Error),
#[error("stored secret key must decode to 32 bytes")]
InvalidStoredKeyLength,
}
pub type CryptoResult<T> = Result<T, CryptoError>;
@@ -36,6 +42,26 @@ impl SecretBox {
Self { key }
}
pub fn load_or_create(path: impl AsRef<Path>) -> CryptoResult<Self> {
let path = path.as_ref();
if path.exists() {
let encoded = fs::read_to_string(path)?;
let key = decode_key(encoded.trim())?;
return Ok(Self::new(key));
}
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)?;
}
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
fs::write(path, URL_SAFE_NO_PAD.encode(key))?;
Ok(Self::new(key))
}
pub fn encrypt(&self, plaintext: &SecretString) -> CryptoResult<String> {
let cipher =
Aes256Gcm::new_from_slice(&self.key).map_err(|_| CryptoError::InvalidKeyLength)?;
@@ -74,3 +100,10 @@ impl SecretBox {
Ok(SecretString::new(String::from_utf8(plaintext)?.into()))
}
}
fn decode_key(encoded: &str) -> CryptoResult<[u8; 32]> {
URL_SAFE_NO_PAD
.decode(encoded)?
.try_into()
.map_err(|_| CryptoError::InvalidStoredKeyLength)
}
+260
View File
@@ -8,11 +8,16 @@ use axum::{
Extension, Json,
};
use chrono::{Duration, Utc};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use crate::{
accounts::{
model::AccountStatus,
repository::{NewAccount, StoredAccountMetadata},
},
auth::admin_session::verify_admin_password,
config::{AppConfig, QuotaWarningThresholds},
http::{auth::admin_session_id, middleware::RequestId},
@@ -118,6 +123,13 @@ pub struct LogsQuery {
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountsQuery {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginRequest {
@@ -153,6 +165,67 @@ pub struct AdminSettingsData {
pub usage_history_retention_days: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountImportPayload {
pub accounts: Vec<AccountImportEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountImportEntry {
pub id: Option<String>,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub token: String,
pub refresh_token: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountImportData {
pub imported: u32,
pub skipped: u32,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAccountData {
pub id: String,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub status: String,
pub access_token_expires_at: Option<String>,
pub added_at: String,
pub updated_at: String,
}
impl From<StoredAccountMetadata> for AdminAccountData {
fn from(account: StoredAccountMetadata) -> Self {
Self {
id: account.id,
email: account.email,
account_id: account.account_id,
user_id: account.user_id,
label: account.label,
plan_type: account.plan_type,
status: account_status_value(account.status).to_string(),
access_token_expires_at: account
.access_token_expires_at
.map(|value| value.to_rfc3339()),
added_at: account.added_at.to_rfc3339(),
updated_at: account.updated_at.to_rfc3339(),
}
}
}
impl AdminSettingsData {
fn from_config(config: &AppConfig) -> Self {
Self {
@@ -345,6 +418,159 @@ pub async fn settings(
.into_response()
}
pub async fn accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<AccountsQuery>,
) -> Response {
let request_id = request_id.as_str().to_string();
let Some(pool) = state.db() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
)
.into_response();
};
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
return response;
}
let Some(repo) = state.account_repository() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(
50001,
"Account repository is not initialized",
(),
request_id,
),
)
.into_response();
};
let limit = clamp_limit(query.limit.unwrap_or(50));
match repo.list_metadata(query.cursor, limit).await {
Ok(page) => {
let Page { items, next_cursor } = page;
let page = Page {
items: items.into_iter().map(AdminAccountData::from).collect(),
next_cursor,
};
AdminResponse::new(
StatusCode::OK,
AdminPageEnvelope::ok(page, limit, request_id),
)
.into_response()
}
Err(_) => AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Failed to list accounts", (), request_id),
)
.into_response(),
}
}
pub async fn import_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<AccountImportPayload>,
) -> Response {
let request_id = request_id.as_str().to_string();
let Some(pool) = state.db() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
)
.into_response();
};
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
return response;
}
let Some(repo) = state.account_repository() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(
50001,
"Account repository is not initialized",
(),
request_id,
),
)
.into_response();
};
if payload.accounts.is_empty() {
return AdminResponse::new(
StatusCode::BAD_REQUEST,
AdminEnvelope::new(40001, "No importable accounts found", (), request_id),
)
.into_response();
}
let mut imported = 0u32;
let mut skipped = 0u32;
for entry in payload.accounts {
let token = entry.token.trim();
if token.is_empty() {
skipped += 1;
continue;
}
let id = normalized_account_id(entry.id);
match repo.exists(&id).await {
Ok(true) => {
skipped += 1;
continue;
}
Ok(false) => {}
Err(_) => {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Failed to inspect account", (), request_id),
)
.into_response();
}
}
let status = match parse_import_status(entry.status.as_deref()) {
Ok(status) => status,
Err(message) => {
return AdminResponse::new(
StatusCode::BAD_REQUEST,
AdminEnvelope::new(40001, message, (), request_id),
)
.into_response();
}
};
let account = NewAccount {
id,
email: empty_to_none(entry.email),
account_id: empty_to_none(entry.account_id),
user_id: empty_to_none(entry.user_id),
label: empty_to_none(entry.label),
plan_type: empty_to_none(entry.plan_type),
access_token: SecretString::new(token.to_string().into()),
refresh_token: empty_to_none(entry.refresh_token)
.map(|token| SecretString::new(token.into())),
access_token_expires_at: None,
status,
};
if repo.insert(account).await.is_err() {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Failed to import account", (), request_id),
)
.into_response();
}
imported += 1;
}
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountImportData { imported, skipped }, request_id),
)
.into_response()
}
#[derive(Debug)]
struct AdminUserRow {
id: String,
@@ -429,3 +655,37 @@ async fn require_admin_session(
.into_response()),
}
}
fn normalized_account_id(id: Option<String>) -> String {
id.and_then(|id| empty_to_none(Some(id)))
.unwrap_or_else(|| format!("acct_{}", Uuid::new_v4().simple()))
}
fn empty_to_none(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn parse_import_status(status: Option<&str>) -> Result<AccountStatus, String> {
match status.unwrap_or("active") {
"active" => Ok(AccountStatus::Active),
"expired" => Ok(AccountStatus::Expired),
"quota_exhausted" => Ok(AccountStatus::QuotaExhausted),
"refreshing" => Ok(AccountStatus::Refreshing),
"disabled" => Ok(AccountStatus::Disabled),
"banned" => Ok(AccountStatus::Banned),
other => Err(format!("Unsupported account status: {other}")),
}
}
fn account_status_value(status: AccountStatus) -> &'static str {
match status {
AccountStatus::Active => "active",
AccountStatus::Expired => "expired",
AccountStatus::QuotaExhausted => "quota_exhausted",
AccountStatus::Refreshing => "refreshing",
AccountStatus::Disabled => "disabled",
AccountStatus::Banned => "banned",
}
}
+3 -1
View File
@@ -1,6 +1,7 @@
use codex_proxy_rs::{
app::build_router,
config::AppConfig,
crypto::SecretBox,
logs::rotation::{init_tracing, RotationConfig},
state::AppState,
storage::db::connect_sqlite,
@@ -17,8 +18,9 @@ async fn main() -> anyhow::Result<()> {
))?;
let host = config.server.host.clone();
let port = config.server.port;
let secret_box = SecretBox::load_or_create(&config.security.master_key_file)?;
let pool = connect_sqlite(&config.database.url).await?;
let app = build_router(AppState::with_pool(config, pool));
let app = build_router(AppState::with_pool_and_secret_box(config, pool, secret_box));
let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?;
tracing::info!(host, port, "codex-proxy-rs listening");
axum::serve(listener, app).await?;
+27
View File
@@ -2,7 +2,9 @@ use std::sync::Arc;
use sqlx::SqlitePool;
use crate::accounts::repository::AccountRepository;
use crate::config::AppConfig;
use crate::crypto::SecretBox;
use crate::logs::repository::EventLogRepository;
#[derive(Clone)]
@@ -14,6 +16,7 @@ pub struct AppServices {
pub config: AppConfig,
pub db: Option<SqlitePool>,
pub event_logs: Option<EventLogRepository>,
pub secret_box: Option<SecretBox>,
}
impl AppState {
@@ -23,6 +26,7 @@ impl AppState {
config,
db: None,
event_logs: None,
secret_box: None,
}),
}
}
@@ -33,6 +37,22 @@ impl AppState {
config,
db: Some(pool.clone()),
event_logs: Some(EventLogRepository::new(pool)),
secret_box: None,
}),
}
}
pub fn with_pool_and_secret_box(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
) -> Self {
Self {
services: Arc::new(AppServices {
config,
db: Some(pool.clone()),
event_logs: Some(EventLogRepository::new(pool)),
secret_box: Some(secret_box),
}),
}
}
@@ -48,4 +68,11 @@ impl AppState {
pub fn event_logs(&self) -> Option<&EventLogRepository> {
self.services.event_logs.as_ref()
}
pub fn account_repository(&self) -> Option<AccountRepository> {
Some(AccountRepository::new(
self.db()?.clone(),
self.services.secret_box.clone()?,
))
}
}
+261
View File
@@ -0,0 +1,261 @@
use std::collections::BTreeMap;
use axum::{
body::{to_bytes, Body},
http::{Request, StatusCode},
};
use chrono::Utc;
use serde_json::{json, Value};
use tower::ServiceExt;
use codex_proxy_rs::{
app::build_router,
config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
UsageStatsConfig,
},
crypto::SecretBox,
state::AppState,
storage::db::connect_sqlite,
};
fn test_config(database_url: String) -> AppConfig {
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 0,
},
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
},
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
warning_thresholds: QuotaWarningThresholds {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
security: SecurityConfig {
master_key_file: "data/master.key".to_string(),
api_key_pepper_file: "data/api-key-pepper.key".to_string(),
},
tls: TlsConfig {
force_http11: false,
},
admin: AdminConfig {
session_ttl_minutes: 1440,
},
logging: LoggingConfig {
directory: "logs".to_string(),
max_file_bytes: 10_485_760,
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
#[tokio::test]
async fn admin_accounts_import_should_require_admin_session_cookie() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-accounts.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let app = build_router(AppState::with_pool_and_secret_box(
test_config(url),
pool,
SecretBox::new([11u8; 32]),
));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/accounts/import")
.header("content-type", "application/json")
.header("x-request-id", "req_accounts")
.body(Body::from(r#"{"accounts":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = response_json(response).await;
assert_eq!(body["code"], 40101);
assert_eq!(body["requestId"], "req_accounts");
}
#[tokio::test]
async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_accounts() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-accounts.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let app = build_router(AppState::with_pool_and_secret_box(
test_config(url),
pool.clone(),
SecretBox::new([12u8; 32]),
));
let import_body = json!({
"accounts": [{
"id": "acct_imported",
"email": "user@example.com",
"accountId": "chatgpt-account",
"userId": "chatgpt-user",
"label": "primary",
"planType": "plus",
"token": "access-secret",
"refreshToken": "refresh-secret",
"status": "active"
}]
});
let import_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts")
.body(Body::from(import_body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(import_response.status(), StatusCode::OK);
let body = response_json(import_response).await;
assert_eq!(body["data"]["imported"], 1);
assert_eq!(body["data"]["skipped"], 0);
let stored: (String, String) = sqlx::query_as(
"select access_token_cipher, refresh_token_cipher from accounts where id = ?",
)
.bind("acct_imported")
.fetch_one(&pool)
.await
.unwrap();
assert!(stored.0.starts_with("v1:"));
assert!(!stored.0.contains("access-secret"));
assert!(stored.1.starts_with("v1:"));
assert!(!stored.1.contains("refresh-secret"));
let list_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/accounts?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_list")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(list_response.status(), StatusCode::OK);
let body = response_json(list_response).await;
assert_eq!(body["data"][0]["id"], "acct_imported");
assert_eq!(body["data"][0]["email"], "user@example.com");
assert!(body["data"][0].get("token").is_none());
assert!(body["data"][0].get("refreshToken").is_none());
assert_eq!(body["page"]["limit"], 10);
}
#[tokio::test]
async fn admin_accounts_list_should_not_decrypt_account_tokens() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-accounts.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let now = Utc::now().to_rfc3339();
sqlx::query(
"insert into accounts (id, email, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)",
)
.bind("acct_corrupt")
.bind("user@example.com")
.bind("not-a-secret-box-cipher")
.bind("active")
.bind(&now)
.bind(&now)
.execute(&pool)
.await
.unwrap();
let app = build_router(AppState::with_pool_and_secret_box(
test_config(url),
pool,
SecretBox::new([13u8; 32]),
));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/accounts?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_list")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"][0]["id"], "acct_corrupt");
}
async fn seed_admin_session(pool: &sqlx::SqlitePool, session_id: &str) {
let now = Utc::now().to_rfc3339();
sqlx::query(
"insert into admin_users (id, password_hash, created_at, updated_at) values (?, ?, ?, ?)",
)
.bind("admin_1")
.bind("hash")
.bind(&now)
.bind(&now)
.execute(pool)
.await
.unwrap();
sqlx::query(
"insert into admin_sessions (id, user_id, expires_at, created_at) values (?, ?, ?, ?)",
)
.bind(session_id)
.bind("admin_1")
.bind("2999-01-01T00:00:00Z")
.bind(now)
.execute(pool)
.await
.unwrap();
}
async fn response_json(response: axum::response::Response) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
+15
View File
@@ -14,3 +14,18 @@ fn secret_box_encrypts_and_decrypts_without_storing_plaintext() {
"rt_example_refresh_token"
);
}
#[test]
fn secret_box_load_or_create_should_reuse_existing_master_key_file() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("master.key");
let first_box = SecretBox::load_or_create(&key_path).unwrap();
let ciphertext = first_box
.encrypt(&SecretString::new("persisted-secret".to_string().into()))
.unwrap();
let second_box = SecretBox::load_or_create(&key_path).unwrap();
let decrypted = second_box.decrypt(&ciphertext).unwrap();
assert_eq!(decrypted.expose_secret(), "persisted-secret");
}