feat: load runtime configuration

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-11 22:14:24 +08:00
co-authored by Codex
parent e56e8b91db
commit e0680bfd96
4 changed files with 142 additions and 37 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ Update this file before each feature commit.
| --- | --- | --- | --- | --- |
| Scaffold | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | Rust crate, lint policy, pinned TLS-sensitive dependencies, `.gitignore`, and minimal entry points are in place. |
| API contract docs | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | `/v1/*` OpenAI-compatible body, `/admin/*` frontend envelope, body codes, camelCase, and request ID policy are documented. |
| Configuration | Completed | `9ed8579` | `cargo test default_config_keeps_only_codex_backend`; full `fmt/test/clippy` before commit | Defines Codex-only config, default YAML, and `Arc<AppServices>` state shell. |
| Configuration | Completed | `9ed8579` + runtime config loader commit | `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, and `Arc<AppServices>` state shell. |
| SQLite storage | Completed | `ffec8b5` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. |
| Admin auth and client API keys | Completed | `24d5799` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; full `fmt/test/clippy` before commit | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper. |
| 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. |
+79
View File
@@ -1,5 +1,9 @@
use std::path::Path;
use serde::{Deserialize, Serialize};
pub type ConfigResult<T> = Result<T, config::ConfigError>;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AppConfig {
pub server: ServerConfig,
@@ -12,6 +16,71 @@ pub struct AppConfig {
pub logging: LoggingConfig,
}
impl AppConfig {
pub fn load() -> ConfigResult<Self> {
let _ = dotenvy::dotenv();
Self::load_from_dir_with_env("config", std::env::vars())
}
pub fn load_from_dir_with_env<K, V, I>(
config_dir: impl AsRef<Path>,
env: I,
) -> ConfigResult<Self>
where
K: AsRef<str>,
V: AsRef<str>,
I: IntoIterator<Item = (K, V)>,
{
let config_dir = config_dir.as_ref();
let mut cfg: Self = config::Config::builder()
.add_source(config::File::from(config_dir.join("default.yaml")).required(true))
.add_source(config::File::from(config_dir.join("local.yaml")).required(false))
.add_source(config::File::from(config_dir.join("local.yml")).required(false))
.build()?
.try_deserialize()?;
cfg.apply_env(env)?;
Ok(cfg)
}
fn apply_env<K, V, I>(&mut self, env: I) -> ConfigResult<()>
where
K: AsRef<str>,
V: AsRef<str>,
I: IntoIterator<Item = (K, V)>,
{
for (key, value) in env {
let key = key.as_ref();
let value = value.as_ref();
match key {
"CPRS_HOST" => self.server.host = value.to_string(),
"CPRS_PORT" => self.server.port = parse_env(key, value)?,
"CPRS_BASE_URL" => self.api.base_url = value.to_string(),
"CPRS_REFRESH_MARGIN_SECONDS" => {
self.auth.refresh_margin_seconds = parse_env(key, value)?;
}
"CPRS_REFRESH_ENABLED" => self.auth.refresh_enabled = parse_env(key, value)?,
"CPRS_REFRESH_CONCURRENCY" => {
self.auth.refresh_concurrency = parse_env(key, value)?;
}
"CPRS_DATABASE_URL" => self.database.url = value.to_string(),
"CPRS_MASTER_KEY_FILE" => self.security.master_key_file = value.to_string(),
"CPRS_API_KEY_PEPPER_FILE" => {
self.security.api_key_pepper_file = value.to_string();
}
"CPRS_FORCE_HTTP11" => self.tls.force_http11 = parse_env(key, value)?,
"CPRS_ADMIN_SESSION_TTL_MINUTES" => {
self.admin.session_ttl_minutes = parse_env(key, value)?;
}
"CPRS_LOG_DIR" => self.logging.directory = value.to_string(),
"CPRS_LOG_MAX_FILE_BYTES" => self.logging.max_file_bytes = parse_env(key, value)?,
"CPRS_LOG_RETENTION_DAYS" => self.logging.retention_days = parse_env(key, value)?,
_ => {}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ServerConfig {
pub host: String,
@@ -57,3 +126,13 @@ pub struct LoggingConfig {
pub max_file_bytes: u64,
pub retention_days: u64,
}
fn parse_env<T>(key: &str, value: &str) -> ConfigResult<T>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
value
.parse::<T>()
.map_err(|error| config::ConfigError::Message(format!("invalid {key}: {error}")))
}
+2 -36
View File
@@ -1,47 +1,13 @@
use codex_proxy_rs::{
app::build_router,
config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig,
SecurityConfig, ServerConfig, TlsConfig,
},
config::AppConfig,
logs::rotation::{init_tracing, RotationConfig},
state::AppState,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,
},
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
refresh_concurrency: 2,
},
database: DatabaseConfig {
url: "sqlite://data/codex-proxy-rs.sqlite".to_string(),
},
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,
},
};
let config = AppConfig::load()?;
let _log_writer = init_tracing(RotationConfig::new(
&config.logging.directory,
+60
View File
@@ -1,4 +1,5 @@
use codex_proxy_rs::config::AppConfig;
use std::fs;
#[test]
fn default_config_keeps_only_codex_backend() {
@@ -32,3 +33,62 @@ logging:
assert_eq!(cfg.database.url, "sqlite://data/codex-proxy-rs.sqlite");
assert_eq!(cfg.security.master_key_file, "data/master.key");
}
#[test]
fn config_loader_merges_default_local_and_cprs_env_overrides() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("default.yaml"),
r#"
server:
host: 127.0.0.1
port: 8080
api:
base_url: https://chatgpt.com/backend-api
auth:
refresh_margin_seconds: 300
refresh_enabled: true
refresh_concurrency: 2
database:
url: sqlite://data/codex-proxy-rs.sqlite
security:
master_key_file: data/master.key
api_key_pepper_file: data/api-key-pepper.key
tls:
force_http11: false
admin:
session_ttl_minutes: 1440
logging:
directory: logs
max_file_bytes: 10485760
retention_days: 14
"#,
)
.unwrap();
fs::write(
dir.path().join("local.yaml"),
r#"
server:
host: 0.0.0.0
logging:
directory: local-logs
"#,
)
.unwrap();
let cfg = AppConfig::load_from_dir_with_env(
dir.path(),
[
("CPRS_PORT", "19090"),
("CPRS_DATABASE_URL", "sqlite://override.sqlite"),
("CPRS_LOG_MAX_FILE_BYTES", "4096"),
],
)
.unwrap();
assert_eq!(cfg.server.host, "0.0.0.0");
assert_eq!(cfg.server.port, 19090);
assert_eq!(cfg.database.url, "sqlite://override.sqlite");
assert_eq!(cfg.logging.directory, "local-logs");
assert_eq!(cfg.logging.max_file_bytes, 4096);
}