mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
feat: load runtime configuration
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user