diff --git a/docs/implementation-status.md b/docs/implementation-status.md index f6f61e8a0..86f095f7c 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -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` 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` 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::` without plaintext. | diff --git a/src/config.rs b/src/config.rs index c80e4b899..c63862f74 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,9 @@ +use std::path::Path; + use serde::{Deserialize, Serialize}; +pub type ConfigResult = Result; + #[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 { + let _ = dotenvy::dotenv(); + Self::load_from_dir_with_env("config", std::env::vars()) + } + + pub fn load_from_dir_with_env( + config_dir: impl AsRef, + env: I, + ) -> ConfigResult + where + K: AsRef, + V: AsRef, + I: IntoIterator, + { + 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(&mut self, env: I) -> ConfigResult<()> + where + K: AsRef, + V: AsRef, + I: IntoIterator, + { + 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(key: &str, value: &str) -> ConfigResult +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + value + .parse::() + .map_err(|error| config::ConfigError::Message(format!("invalid {key}: {error}"))) +} diff --git a/src/main.rs b/src/main.rs index 6038e52f0..cb7c54fdd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, diff --git a/tests/config_test.rs b/tests/config_test.rs index 1796148c0..a65c84cc2 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -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); +}