From 6802dc5a4c56858c7dfbd7711d33994422a64c7e Mon Sep 17 00:00:00 2001 From: jiesen Date: Sat, 20 Dec 2025 21:04:22 +0700 Subject: [PATCH 01/15] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E4=B8=8E=E7=AE=A1=E7=90=86=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 2 +- src-tauri/build.rs | 7 + src-tauri/src/config/hot_reload.rs | 43 ++++ src-tauri/src/config/import.rs | 31 ++- src-tauri/src/config/mod.rs | 10 +- src-tauri/src/config/tests.rs | 44 ++-- src-tauri/src/config/types.rs | 9 +- src-tauri/src/config/yaml.rs | 4 +- src-tauri/src/database/migration.rs | 39 ++-- src-tauri/src/database/mod.rs | 18 +- src-tauri/src/lib.rs | 81 +++++++- src-tauri/src/logger.rs | 101 +++++++++- src-tauri/src/middleware/management_auth.rs | 95 +++++++++ src-tauri/src/middleware/tests.rs | 52 ++++- src-tauri/src/server.rs | 210 ++++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- 16 files changed, 644 insertions(+), 104 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dfa4639a1..ac7bb9e9f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3367,7 +3367,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.14.5" +version = "0.14.6" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6a..e878ef8d0 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,10 @@ fn main() { + // tauri::generate_context! 在编译期会校验 `frontendDist` 路径是否存在。 + // 开发/CI 场景下可能只跑 `cargo check/test` 而未先构建前端,从而导致宏 panic。 + // 这里提前创建配置中的 `../dist` 目录,避免无关的编译阻塞。 + if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { + let dist_dir = std::path::PathBuf::from(manifest_dir).join("../dist"); + let _ = std::fs::create_dir_all(dist_dir); + } tauri_build::build() } diff --git a/src-tauri/src/config/hot_reload.rs b/src-tauri/src/config/hot_reload.rs index ac0cad709..b65b0a174 100644 --- a/src-tauri/src/config/hot_reload.rs +++ b/src-tauri/src/config/hot_reload.rs @@ -370,6 +370,8 @@ impl HotReloadManager { /// 验证配置 fn validate_config(&self, config: &Config) -> Result<(), HotReloadError> { + let is_localhost = is_localhost_host(&config.server.host); + // 验证端口范围 if config.server.port == 0 { return Err(HotReloadError::ValidationError( @@ -377,6 +379,12 @@ impl HotReloadManager { )); } + if !is_localhost { + return Err(HotReloadError::ValidationError( + "当前版本仅支持本地监听,请使用 127.0.0.1/localhost/::1".to_string(), + )); + } + // 验证重试配置 if config.retry.max_retries > 100 { return Err(HotReloadError::ValidationError( @@ -397,6 +405,32 @@ impl HotReloadManager { )); } + if config.server.api_key.trim().is_empty() { + return Err(HotReloadError::ValidationError( + "API Key 不能为空".to_string(), + )); + } + + if (!is_localhost || config.remote_management.allow_remote) + && crate::config::is_default_api_key(&config.server.api_key) + { + return Err(HotReloadError::ValidationError( + "非本地访问场景下禁止使用默认 API Key,请设置强口令".to_string(), + )); + } + + if config.server.tls.enable { + return Err(HotReloadError::ValidationError( + "当前版本暂不支持 TLS,请关闭 TLS 配置".to_string(), + )); + } + + if config.remote_management.allow_remote { + return Err(HotReloadError::ValidationError( + "当前版本未启用 TLS,禁止开启远程管理".to_string(), + )); + } + Ok(()) } @@ -437,6 +471,15 @@ impl HotReloadManager { } } +fn is_localhost_host(host: &str) -> bool { + if host == "localhost" { + return true; + } + host.parse::() + .map(|addr| addr.is_loopback()) + .unwrap_or(false) +} + /// 热重载状态 #[derive(Debug, Clone, serde::Serialize)] pub struct HotReloadStatus { diff --git a/src-tauri/src/config/import.rs b/src-tauri/src/config/import.rs index 94d866703..e86ccd25e 100644 --- a/src-tauri/src/config/import.rs +++ b/src-tauri/src/config/import.rs @@ -313,7 +313,10 @@ impl ImportService { // 如果是脱敏数据,清理凭证池中的占位符 if bundle.redacted { - Self::clean_redacted_credentials(&mut config); + let server_key_cleared = Self::clean_redacted_credentials(&mut config); + if server_key_cleared { + warnings.push("检测到脱敏的服务器 API Key,已清空,需要手动设置".to_string()); + } } Ok(ImportResult::success_with_warnings(config, warnings)) @@ -469,7 +472,9 @@ impl ImportService { /// 清理脱敏的凭证数据 /// /// 移除凭证池中使用占位符的条目 - fn clean_redacted_credentials(config: &mut Config) { + fn clean_redacted_credentials(config: &mut Config) -> bool { + let mut server_key_cleared = false; + // 清理 OpenAI 凭证池中的脱敏条目 config .credential_pool @@ -490,10 +495,13 @@ impl ImportService { config.providers.claude.api_key = None; } - // 清理服务器 API 密钥(如果是脱敏的,恢复默认值) + // 清理服务器 API 密钥(如果是脱敏的,清空并提示手动设置) if config.server.api_key == REDACTED_PLACEHOLDER { - config.server.api_key = "proxy_cast".to_string(); + config.server.api_key = String::new(); + server_key_cleared = true; } + + server_key_cleared } /// 从文件导入配置 @@ -638,7 +646,7 @@ server: let current = Config::default(); let yaml = r#" server: - host: 0.0.0.0 + host: 127.0.0.1 port: 9000 api_key: new_key "#; @@ -646,7 +654,7 @@ server: let result = ImportService::import_yaml(yaml, ¤t, &options).expect("导入应成功"); assert!(result.success); - assert_eq!(result.config.server.host, "0.0.0.0"); + assert_eq!(result.config.server.host, "127.0.0.1"); assert_eq!(result.config.server.port, 9000); assert_eq!(result.config.server.api_key, "new_key"); } @@ -664,7 +672,7 @@ server: let yaml = r#" server: - host: 0.0.0.0 + host: 127.0.0.1 port: 9000 api_key: new_key credential_pool: @@ -677,7 +685,7 @@ credential_pool: assert!(result.success); // 服务器配置应被更新 - assert_eq!(result.config.server.host, "0.0.0.0"); + assert_eq!(result.config.server.host, "127.0.0.1"); // 凭证池应合并 assert_eq!(result.config.credential_pool.openai.len(), 2); } @@ -757,10 +765,11 @@ credential_pool: proxy_url: None, }); - ImportService::clean_redacted_credentials(&mut config); + let server_key_cleared = ImportService::clean_redacted_credentials(&mut config); - // 服务器 API 密钥应恢复默认值 - assert_eq!(config.server.api_key, "proxy_cast"); + // 服务器 API 密钥应被清空并提示手动设置 + assert!(server_key_cleared); + assert_eq!(config.server.api_key, ""); // Provider API 密钥应被清除 assert!(config.providers.openai.api_key.is_none()); // 凭证池中脱敏的条目应被移除 diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index d17ed8a5f..a8d73a615 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -21,11 +21,11 @@ pub use hot_reload::{ pub use import::{ImportError, ImportOptions, ImportResult, ImportService, ValidationResult}; pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ - AmpConfig, AmpModelMapping, ApiKeyEntry, Config, CredentialEntry, CredentialPoolConfig, - CustomProviderConfig, GeminiApiKeyEntry, IFlowCredentialEntry, InjectionRuleConfig, - InjectionSettings, LoggingConfig, ProviderConfig, ProvidersConfig, QuotaExceededConfig, - RemoteManagementConfig, RetrySettings, RoutingConfig, RoutingRuleConfig, ServerConfig, - TlsConfig, VertexApiKeyEntry, VertexModelAlias, + is_default_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, Config, CredentialEntry, + CredentialPoolConfig, CustomProviderConfig, GeminiApiKeyEntry, IFlowCredentialEntry, + InjectionRuleConfig, InjectionSettings, LoggingConfig, ProviderConfig, ProvidersConfig, + QuotaExceededConfig, RemoteManagementConfig, RetrySettings, RoutingConfig, RoutingRuleConfig, + ServerConfig, TlsConfig, VertexApiKeyEntry, VertexModelAlias, DEFAULT_API_KEY, }; pub use yaml::{ load_config, save_config, save_config_yaml, ConfigError, ConfigManager, YamlService, diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index d60909cce..5d6a6c9ac 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -15,8 +15,8 @@ use tempfile::NamedTempFile; fn arb_host() -> impl Strategy { prop_oneof![ Just("127.0.0.1".to_string()), - Just("0.0.0.0".to_string()), Just("localhost".to_string()), + Just("::1".to_string()), "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}".prop_map(|s| s), ] } @@ -2120,40 +2120,36 @@ proptest! { /// **Validates: Requirements 5.5** #[test] fn prop_export_import_redacted_loses_secrets(config in arb_config_with_secrets()) { - // 导出为 YAML(脱敏) - let yaml = ExportService::export_yaml(&config, true) + // 导出为脱敏 bundle + let options = ExportOptions::redacted(); + let bundle = ExportService::export(&config, &options, "1.0.0") .expect("导出应成功"); - // 导入 YAML + // 导入 bundle(脱敏数据会触发清理) let empty_config = Config::default(); - let options = ImportOptions::replace(); - let result = ImportService::import_yaml(&yaml, &empty_config, &options) + let import_options = ImportOptions::replace(); + let result = ImportService::import( + &bundle, + &empty_config, + &import_options, + &config.auth_dir, + ) .expect("导入应成功"); - // 清理脱敏数据 - let mut imported = result.config; - ImportService::import( - &ExportBundle::new("1.0.0"), - &imported, - &ImportOptions::merge(), - &config.auth_dir, - ).ok(); // 忽略结果,只是为了触发清理 - // 验证脱敏后的配置不包含原始敏感信息 - // 服务器 API 密钥应为脱敏占位符或默认值 - prop_assert!( - imported.server.api_key == REDACTED_PLACEHOLDER || - imported.server.api_key == "proxy_cast", - "脱敏后服务器 API 密钥应为占位符或默认值: {}", - imported.server.api_key + // 服务器 API 密钥应被清空 + prop_assert_eq!( + result.config.server.api_key, + "", + "脱敏后服务器 API 密钥应被清空" ); // 如果原始配置有 OpenAI API 密钥,导入后应为脱敏占位符 if config.providers.openai.api_key.is_some() { prop_assert_eq!( - imported.providers.openai.api_key, - Some(REDACTED_PLACEHOLDER.to_string()), - "脱敏后 OpenAI API 密钥应为占位符" + result.config.providers.openai.api_key, + None, + "脱敏后 OpenAI API 密钥应被清空" ); } } diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index cc4fe08fb..eb4bddc38 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -334,8 +334,15 @@ fn default_port() -> u16 { 8999 } +pub const DEFAULT_API_KEY: &str = "proxy_cast"; + fn default_api_key() -> String { - "proxy_cast".to_string() + DEFAULT_API_KEY.to_string() +} + +/// 是否为默认 API Key +pub fn is_default_api_key(api_key: &str) -> bool { + api_key == DEFAULT_API_KEY } impl Default for ServerConfig { diff --git a/src-tauri/src/config/yaml.rs b/src-tauri/src/config/yaml.rs index b00fa58c8..719d909c5 100644 --- a/src-tauri/src/config/yaml.rs +++ b/src-tauri/src/config/yaml.rs @@ -708,7 +708,7 @@ mod unit_tests { fn test_parse_yaml_minimal() { let yaml = r#" server: - host: "0.0.0.0" + host: "127.0.0.1" port: 9000 api_key: "test-key" providers: @@ -716,7 +716,7 @@ providers: enabled: true "#; let config = ConfigManager::parse_yaml(yaml).unwrap(); - assert_eq!(config.server.host, "0.0.0.0"); + assert_eq!(config.server.host, "127.0.0.1"); assert_eq!(config.server.port, 9000); assert_eq!(config.server.api_key, "test-key"); assert!(config.providers.kiro.enabled); diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs index 2dfbf9a36..abde27d49 100644 --- a/src-tauri/src/database/migration.rs +++ b/src-tauri/src/database/migration.rs @@ -1,11 +1,8 @@ use rusqlite::Connection; -use serde_json::Value; /// 从旧的 JSON 配置迁移数据到 SQLite #[allow(dead_code)] -pub fn migrate_from_json( - conn: &Connection, -) -> Result<(), Box> { +pub fn migrate_from_json(conn: &Connection) -> Result<(), String> { // 检查是否已经迁移过 let migrated: bool = conn .query_row( @@ -20,27 +17,35 @@ pub fn migrate_from_json( return Ok(()); } - // 读取旧配置文件 - let home = dirs::home_dir().ok_or("Cannot find home directory")?; - let config_path = home.join(".proxycast").join("config.json"); + // 读取旧配置文件(兼容历史路径) + let mut legacy_paths = Vec::new(); + if let Some(config_dir) = dirs::config_dir() { + legacy_paths.push(config_dir.join("proxycast").join("config.json")); + } + if let Some(home) = dirs::home_dir() { + legacy_paths.push(home.join(".proxycast").join("config.json")); + } - if config_path.exists() { - let content = std::fs::read_to_string(&config_path)?; - let _config: Value = serde_json::from_str(&content)?; + if let Some(config_path) = legacy_paths.into_iter().find(|path| path.exists()) { + // 备份旧配置,避免误覆盖 + let backup_path = config_path.with_file_name("config.json.backup"); + if !backup_path.exists() { + std::fs::copy(&config_path, &backup_path) + .map_err(|e| format!("备份旧配置失败: {}", e))?; + } - // TODO: 解析旧配置并插入到数据库 - // 这里需要根据实际的旧配置格式来实现 - - // 备份旧配置 - let backup_path = home.join(".proxycast").join("config.json.backup"); - std::fs::copy(&config_path, &backup_path)?; + return Err(format!( + "检测到旧版 config.json({}),当前版本尚未支持自动迁移。请手动导出/重建配置后再启动。", + config_path.display() + )); } // 标记迁移完成 conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_from_json', 'true')", [], - )?; + ) + .map_err(|e| e.to_string())?; Ok(()) } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index f21195d62..b13e4ea0a 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -9,20 +9,22 @@ use std::sync::{Arc, Mutex}; pub type DbConnection = Arc>; /// 获取数据库文件路径 -pub fn get_db_path() -> PathBuf { - let home = dirs::home_dir().expect("Cannot find home directory"); +pub fn get_db_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?; let db_dir = home.join(".proxycast"); - std::fs::create_dir_all(&db_dir).expect("Cannot create .proxycast directory"); - db_dir.join("proxycast.db") + std::fs::create_dir_all(&db_dir) + .map_err(|e| format!("无法创建数据库目录 {:?}: {}", db_dir, e))?; + Ok(db_dir.join("proxycast.db")) } /// 初始化数据库连接 -pub fn init_database() -> Result { - let db_path = get_db_path(); - let conn = Connection::open(&db_path)?; +pub fn init_database() -> Result { + let db_path = get_db_path()?; + let conn = Connection::open(&db_path).map_err(|e| e.to_string())?; // 创建表结构 - schema::create_tables(&conn)?; + schema::create_tables(&conn).map_err(|e| e.to_string())?; + migration::migrate_from_json(&conn)?; Ok(Arc::new(Mutex::new(conn))) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 579e3cee3..907ef2daa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub mod telemetry; pub mod tray; pub mod websocket; +use rand::{distributions::Alphanumeric, Rng}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{Manager, Runtime}; @@ -175,6 +176,15 @@ mod tests { pub type AppState = Arc>; pub type LogState = Arc>; +fn generate_api_key() -> String { + let token: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect(); + format!("pc_{token}") +} + #[tauri::command] async fn start_server( state: tauri::State<'_, AppState>, @@ -1344,12 +1354,57 @@ async fn test_api( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let config = config::load_config().unwrap_or_default(); - let state: AppState = Arc::new(RwLock::new(server::ServerState::new(config))); - let logs: LogState = Arc::new(RwLock::new(logger::LogStore::new())); + let mut config = match config::load_config() { + Ok(cfg) => cfg, + Err(err) => { + tracing::error!("配置加载失败,已中止启动: {}", err); + eprintln!("配置加载失败,已中止启动: {}", err); + return; + } + }; + if config.server.api_key == config::DEFAULT_API_KEY { + let new_key = generate_api_key(); + config.server.api_key = new_key.clone(); + if let Err(err) = config::save_config_yaml(&config) { + tracing::error!("自动生成 API key 失败,无法保存配置,已中止启动: {}", err); + eprintln!("自动生成 API key 失败,无法保存配置,已中止启动: {}", err); + return; + } + tracing::info!("检测到默认 API key,已自动生成并保存新密钥"); + eprintln!("检测到默认 API key,已自动生成并保存新密钥"); + } + if !is_loopback_host(&config.server.host) { + tracing::error!("当前版本仅支持本地监听,请使用 127.0.0.1/localhost/::1。"); + eprintln!("当前版本仅支持本地监听,请使用 127.0.0.1/localhost/::1。"); + return; + } + if config.server.api_key == config::DEFAULT_API_KEY { + tracing::error!("检测到使用默认 API key,已中止启动。请配置强密钥。"); + eprintln!("检测到使用默认 API key,已中止启动。请配置强密钥。"); + return; + } + if config.server.tls.enable { + tracing::error!("检测到 TLS 配置已启用,但当前版本尚未支持 TLS,已中止启动。"); + eprintln!("检测到 TLS 配置已启用,但当前版本尚未支持 TLS,已中止启动。"); + return; + } + if config.remote_management.allow_remote { + tracing::error!("检测到远程管理已开启,但当前版本未启用 TLS,已中止启动。"); + eprintln!("检测到远程管理已开启,但当前版本未启用 TLS,已中止启动。"); + return; + } + let state: AppState = Arc::new(RwLock::new(server::ServerState::new(config.clone()))); + let logs: LogState = Arc::new(RwLock::new(logger::LogStore::with_config(&config.logging))); // Initialize database for Switch functionality - let db = database::init_database().expect("Failed to initialize database"); + let db = match database::init_database() { + Ok(conn) => conn, + Err(err) => { + tracing::error!("数据库初始化失败,已中止启动: {}", err); + eprintln!("数据库初始化失败,已中止启动: {}", err); + return; + } + }; // Initialize SkillService let skill_service = SkillService::new().expect("Failed to initialize SkillService"); @@ -1386,8 +1441,14 @@ pub fn run() { let shared_tokens = Arc::new(parking_lot::RwLock::new( telemetry::TokenTracker::with_defaults(), )); + let log_rotation = telemetry::LogRotationConfig { + max_memory_logs: 10000, + retention_days: config.logging.retention_days, + max_file_size: 10 * 1024 * 1024, + enable_file_logging: config.logging.enabled, + }; let shared_logger = Arc::new( - telemetry::RequestLogger::with_defaults().expect("Failed to create RequestLogger"), + telemetry::RequestLogger::new(log_rotation).expect("Failed to create RequestLogger"), ); // Initialize TelemetryState with shared instances @@ -1749,3 +1810,13 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +fn is_loopback_host(host: &str) -> bool { + if host == "localhost" { + return true; + } + match host.parse::() { + Ok(addr) => addr.is_loopback(), + Err(_) => false, + } +} diff --git a/src-tauri/src/logger.rs b/src-tauri/src/logger.rs index bd86e4e7c..615b89611 100644 --- a/src-tauri/src/logger.rs +++ b/src-tauri/src/logger.rs @@ -1,5 +1,5 @@ //! 日志管理模块 -use chrono::{Local, Utc}; +use chrono::{Duration, Local, Utc}; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::fs::{self, OpenOptions}; @@ -8,6 +8,25 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; +#[derive(Debug, Clone)] +pub struct LogStoreConfig { + pub max_logs: usize, + pub retention_days: u32, + pub max_file_size: u64, + pub enable_file_logging: bool, +} + +impl Default for LogStoreConfig { + fn default() -> Self { + Self { + max_logs: 1000, + retention_days: 7, + max_file_size: 10 * 1024 * 1024, + enable_file_logging: true, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogEntry { pub timestamp: String, @@ -18,6 +37,7 @@ pub struct LogEntry { pub struct LogStore { logs: VecDeque, max_logs: usize, + config: LogStoreConfig, log_file_path: Option, } @@ -34,9 +54,12 @@ impl Default for LogStore { let log_file = log_dir.join("proxycast.log"); + let config = LogStoreConfig::default(); + Self { logs: VecDeque::new(), - max_logs: 1000, + max_logs: config.max_logs, + config, log_file_path: Some(log_file), } } @@ -47,6 +70,14 @@ impl LogStore { Self::default() } + pub fn with_config(logging: &crate::config::LoggingConfig) -> Self { + let mut store = Self::default(); + store.config.retention_days = logging.retention_days; + store.config.enable_file_logging = logging.enabled; + store.max_logs = store.config.max_logs; + store + } + pub fn add(&mut self, level: &str, message: &str) { let now = Utc::now(); let entry = LogEntry { @@ -58,12 +89,16 @@ impl LogStore { self.logs.push_back(entry.clone()); // 写入日志文件 - if let Some(ref path) = self.log_file_path { - let local_time = Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); - let log_line = format!("{} [{}] {}\n", local_time, level.to_uppercase(), message); + if self.config.enable_file_logging { + if let Some(ref path) = self.log_file_path { + self.rotate_log_file_if_needed(path); + let local_time = Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + let log_line = format!("{} [{}] {}\n", local_time, level.to_uppercase(), message); - if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { - let _ = file.write_all(log_line.as_bytes()); + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = file.write_all(log_line.as_bytes()); + } + self.prune_old_logs(path); } } @@ -103,6 +138,58 @@ impl LogStore { .as_ref() .map(|p| p.to_string_lossy().to_string()) } + + fn rotate_log_file_if_needed(&self, path: &PathBuf) { + let Ok(metadata) = fs::metadata(path) else { + return; + }; + + if metadata.len() <= self.config.max_file_size { + return; + } + + let suffix = Local::now().format("%Y%m%d-%H%M%S"); + let rotated = path.with_file_name(format!( + "{}.{}", + path.file_name().unwrap_or_default().to_string_lossy(), + suffix + )); + + let _ = fs::rename(path, &rotated); + self.prune_old_logs(path); + } + + fn prune_old_logs(&self, path: &PathBuf) { + let Some(dir) = path.parent() else { + return; + }; + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let cutoff = Utc::now() - Duration::days(self.config.retention_days as i64); + let prefix = format!( + "{}.", + path.file_name().unwrap_or_default().to_string_lossy() + ); + + for entry in entries.flatten() { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.starts_with(&prefix) { + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + let modified = chrono::DateTime::::from(modified); + if modified < cutoff { + let _ = fs::remove_file(entry.path()); + } + } + } } #[allow(dead_code)] diff --git a/src-tauri/src/middleware/management_auth.rs b/src-tauri/src/middleware/management_auth.rs index 6cf4dd0c5..55c36bc40 100644 --- a/src-tauri/src/middleware/management_auth.rs +++ b/src-tauri/src/middleware/management_auth.rs @@ -20,10 +20,34 @@ use futures::future::BoxFuture; use std::{ net::{IpAddr, SocketAddr}, sync::Arc, + sync::Mutex, task::{Context, Poll}, + time::{Duration, Instant}, }; use tower::{Layer, Service}; +const MAX_AUTH_FAILURES: u32 = 5; +const FAILURE_WINDOW_SECS: u64 = 60; +const BLOCK_SECS: u64 = 300; + +struct FailureState { + count: u32, + window_start: Instant, + blocked_until: Option, +} + +fn failure_map() -> &'static Mutex> { + static FAILURES: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + FAILURES.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +#[cfg(test)] +pub(crate) fn clear_auth_failure_state() { + let mut map = failure_map().lock().unwrap(); + map.clear(); +} + /// Management API 认证层 /// /// 用于包装需要认证的管理端点 @@ -98,6 +122,66 @@ impl ManagementAuthService { .get::>() .map(|ci| ci.0) } + + fn get_client_id(req: &Request) -> String { + if let Some(addr) = Self::get_client_addr(req) { + return addr.ip().to_string(); + } + if let Some(forwarded) = req.headers().get("x-forwarded-for") { + if let Ok(value) = forwarded.to_str() { + if let Some(first) = value.split(',').next() { + return first.trim().to_string(); + } + } + } + "unknown".to_string() + } + + fn check_rate_limit(client_id: &str) -> bool { + let now = Instant::now(); + let mut map = failure_map().lock().unwrap(); + if let Some(state) = map.get_mut(client_id) { + if let Some(blocked_until) = state.blocked_until { + if blocked_until > now { + return false; + } + state.blocked_until = None; + state.count = 0; + state.window_start = now; + } + if now.duration_since(state.window_start).as_secs() > FAILURE_WINDOW_SECS { + state.count = 0; + state.window_start = now; + } + } + true + } + + fn record_failure(client_id: &str) { + let now = Instant::now(); + let mut map = failure_map().lock().unwrap(); + let entry = map.entry(client_id.to_string()).or_insert(FailureState { + count: 0, + window_start: now, + blocked_until: None, + }); + + if now.duration_since(entry.window_start).as_secs() > FAILURE_WINDOW_SECS { + entry.count = 0; + entry.window_start = now; + entry.blocked_until = None; + } + + entry.count += 1; + if entry.count >= MAX_AUTH_FAILURES { + entry.blocked_until = Some(now + Duration::from_secs(BLOCK_SECS)); + } + } + + fn record_success(client_id: &str) { + let mut map = failure_map().lock().unwrap(); + map.remove(client_id); + } } impl Service> for ManagementAuthService @@ -118,6 +202,14 @@ where let mut inner = self.inner.clone(); Box::pin(async move { + let client_id = Self::get_client_id(&req); + if !Self::check_rate_limit(&client_id) { + return Ok(create_error_response( + StatusCode::TOO_MANY_REQUESTS, + "Too many failed authentication attempts", + )); + } + // 1. 检查 secret_key 是否为空(禁用管理 API) let secret_key = match &config.secret_key { Some(key) if !key.is_empty() => key.clone(), @@ -151,6 +243,7 @@ where Some(key) if key == secret_key => { // 认证成功,继续处理请求 tracing::debug!("[MANAGEMENT_AUTH] Auth successful from {:?}", client_addr); + Self::record_success(&client_id); inner.call(req).await } Some(_) => { @@ -158,6 +251,7 @@ where "[MANAGEMENT_AUTH] Invalid secret_key from {:?}", client_addr ); + Self::record_failure(&client_id); Ok(create_error_response( StatusCode::UNAUTHORIZED, "Invalid secret key", @@ -168,6 +262,7 @@ where "[MANAGEMENT_AUTH] Missing secret_key from {:?}", client_addr ); + Self::record_failure(&client_id); Ok(create_error_response( StatusCode::UNAUTHORIZED, "Missing secret key", diff --git a/src-tauri/src/middleware/tests.rs b/src-tauri/src/middleware/tests.rs index 4f9585836..5c817138c 100644 --- a/src-tauri/src/middleware/tests.rs +++ b/src-tauri/src/middleware/tests.rs @@ -3,7 +3,9 @@ //! 使用 proptest 进行属性测试 use crate::config::RemoteManagementConfig; -use crate::middleware::management_auth::{ManagementAuthLayer, ManagementAuthService}; +use crate::middleware::management_auth::{ + clear_auth_failure_state, ManagementAuthLayer, ManagementAuthService, +}; use axum::{ body::Body, http::{Request, Response, StatusCode}, @@ -102,6 +104,49 @@ fn create_request_with_management_key(key: Option<&str>) -> Request { builder.body(Body::empty()).unwrap() } +/// Helper to create a request with X-Management-Key and X-Forwarded-For headers +fn create_request_with_management_key_and_forwarded( + key: Option<&str>, + forwarded_for: Option<&str>, +) -> Request { + let mut builder = Request::builder().uri("/v0/management/status"); + + if let Some(k) = key { + builder = builder.header("x-management-key", k); + } + + if let Some(addr) = forwarded_for { + builder = builder.header("x-forwarded-for", addr); + } + + builder.body(Body::empty()).unwrap() +} + +#[test] +fn test_management_auth_rate_limit_after_failures() { + clear_auth_failure_state(); + let config = RemoteManagementConfig { + allow_remote: true, + secret_key: Some("valid_key".to_string()), + disable_control_panel: false, + }; + let layer = ManagementAuthLayer::new(config); + let mut service = layer.layer(MockService); + let rt = tokio::runtime::Runtime::new().unwrap(); + + let client_ip = "203.0.113.10"; + for _ in 0..5 { + let req = + create_request_with_management_key_and_forwarded(Some("invalid"), Some(client_ip)); + let response = rt.block_on(async { service.call(req).await.unwrap() }); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let req = create_request_with_management_key_and_forwarded(Some("invalid"), Some(client_ip)); + let response = rt.block_on(async { service.call(req).await.unwrap() }); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); +} + proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -112,6 +157,7 @@ proptest! { fn prop_management_auth_rejection_missing_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, @@ -147,6 +193,7 @@ proptest! { fn prop_management_auth_rejection_invalid_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, @@ -183,6 +230,7 @@ proptest! { fn prop_management_auth_acceptance_valid_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, @@ -218,6 +266,7 @@ proptest! { fn prop_management_auth_acceptance_x_management_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, @@ -253,6 +302,7 @@ proptest! { fn prop_management_auth_rejection_invalid_x_management_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 9faa967cd..1c60e3253 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -268,6 +268,28 @@ impl ServerState { let api_key_for_state = api_key.clone(); // 用于保存到 running_api_key let default_provider_ref = self.default_provider_ref.clone(); + if api_key.trim().is_empty() { + return Err("API Key 不能为空".into()); + } + + if !is_localhost_host(&host) { + return Err("当前版本仅支持本地监听,请使用 127.0.0.1/localhost/::1".into()); + } + + if (!is_localhost_host(&host) || self.config.remote_management.allow_remote) + && crate::config::is_default_api_key(&api_key) + { + return Err("非本地访问场景下禁止使用默认 API Key,请设置强口令".into()); + } + + if self.config.server.tls.enable { + return Err("当前版本暂不支持 TLS,请关闭 TLS 配置".into()); + } + + if self.config.remote_management.allow_remote { + return Err("当前版本未启用 TLS,禁止开启远程管理".into()); + } + // 重新加载凭证 let _ = self.kiro_provider.load_credentials().await; let kiro = self.kiro_provider.clone(); @@ -330,6 +352,15 @@ impl ServerState { } } +fn is_localhost_host(host: &str) -> bool { + if host == "localhost" { + return true; + } + host.parse::() + .map(|addr| addr.is_loopback()) + .unwrap_or(false) +} + impl Clone for KiroProvider { fn clone(&self) -> Self { Self { @@ -346,6 +377,9 @@ struct AppState { api_key: String, base_url: String, default_provider: Arc>, + config: Option, + config_manager: Option>>, + start_time: std::time::Instant, kiro: Arc>, logs: Arc>, kiro_refresh_lock: Arc>, @@ -704,6 +738,9 @@ async fn run_server( api_key: api_key.to_string(), base_url, default_provider, + config: config.clone(), + config_manager: config_manager.clone(), + start_time: std::time::Instant::now(), kiro: Arc::new(RwLock::new(kiro)), logs, kiro_refresh_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -809,19 +846,54 @@ async fn run_server( tracing::info!("Server listening on {}", addr); - axum::serve(listener, app) - .with_graceful_shutdown(async move { - let _ = shutdown.await; - }) - .await?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = shutdown.await; + }) + .await?; Ok(()) } -async fn health() -> impl IntoResponse { +async fn health(State(state): State) -> impl IntoResponse { + let (db_ready, pool_stats) = match &state.db { + Some(db) => { + let db_ok = db + .lock() + .map(|conn| conn.query_row::("SELECT 1", [], |row| row.get(0))) + .is_ok(); + let stats = if db_ok { + state.pool_service.get_overview(db).ok().map(|items| { + let mut total = 0usize; + let mut healthy = 0usize; + let mut disabled = 0usize; + for item in items { + total += item.stats.total_count; + healthy += item.stats.healthy_count; + disabled += item.stats.disabled_count; + } + serde_json::json!({ + "total": total, + "healthy": healthy, + "disabled": disabled + }) + }) + } else { + None + }; + (db_ok, stats) + } + None => (false, None), + }; + Json(serde_json::json!({ "status": "healthy", - "version": "0.10.1" + "version": env!("CARGO_PKG_VERSION"), + "db_ready": db_ready, + "pool": pool_stats })) } @@ -4885,21 +4957,32 @@ pub struct UpdateConfigResponse { pub message: String, } +fn snapshot_config(state: &AppState) -> Option { + if let Some(manager) = &state.config_manager { + if let Ok(guard) = manager.read() { + return Some(guard.config().clone()); + } + } + state.config.clone() +} + /// GET /v0/management/status - 获取服务器状态 pub async fn management_status(State(state): State) -> impl IntoResponse { let default_provider = state.default_provider.read().await.clone(); // 获取请求数量 let requests = state.processor.stats.read().len() as u64; + let config = snapshot_config(&state).unwrap_or_default(); + let uptime_secs = state.start_time.elapsed().as_secs(); let response = ManagementStatusResponse { running: true, - host: "0.0.0.0".to_string(), - port: 8999, + host: config.server.host, + port: config.server.port, requests, - uptime_secs: 0, // TODO: Track actual uptime + uptime_secs, version: env!("CARGO_PKG_VERSION").to_string(), - tls_enabled: false, + tls_enabled: config.server.tls.enable, default_provider, }; @@ -5221,26 +5304,32 @@ pub async fn management_get_config(State(state): State) -> impl IntoRe // 获取路由规则数量 let rules_count = state.processor.router.read().await.rules().len(); + let config = snapshot_config(&state).unwrap_or_default(); let response = ManagementConfigResponse { server: ManagementServerConfigInfo { - host: "0.0.0.0".to_string(), - port: 8999, - tls_enabled: false, + host: config.server.host, + port: config.server.port, + tls_enabled: config.server.tls.enable, }, routing: ManagementRoutingConfigInfo { default_provider, rules_count, }, retry: ManagementRetryConfigInfo { - max_retries: 3, - base_delay_ms: 1000, - max_delay_ms: 30000, + max_retries: config.retry.max_retries, + base_delay_ms: config.retry.base_delay_ms, + max_delay_ms: config.retry.max_delay_ms, }, remote_management: ManagementRemoteInfo { - allow_remote: false, - has_secret_key: true, - disable_control_panel: false, + allow_remote: config.remote_management.allow_remote, + has_secret_key: config + .remote_management + .secret_key + .as_ref() + .map(|key| !key.is_empty()) + .unwrap_or(false), + disable_control_panel: config.remote_management.disable_control_panel, }, }; @@ -5253,6 +5342,7 @@ pub async fn management_update_config( Json(request): Json, ) -> impl IntoResponse { let mut updated = false; + let mut needs_restart = false; // 更新默认 Provider if let Some(provider) = request.default_provider { @@ -5261,6 +5351,29 @@ pub async fn management_update_config( let mut dp = state.default_provider.write().await; *dp = provider.clone(); tracing::info!("[MANAGEMENT] Updated default_provider to: {}", provider); + if let Some(manager) = &state.config_manager { + if let Ok(mut guard) = manager.write() { + guard.config_mut().default_provider = provider.clone(); + guard.config_mut().routing.default_provider = provider.clone(); + if let Err(err) = guard.save() { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(UpdateConfigResponse { + success: false, + message: format!("Failed to save config: {}", err), + }), + ); + } + } else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(UpdateConfigResponse { + success: false, + message: "Failed to lock config manager".to_string(), + }), + ); + } + } updated = true; } else { return ( @@ -5273,12 +5386,67 @@ pub async fn management_update_config( } } + // 更新是否允许远程访问(需要重启生效) + if let Some(allow_remote) = request.allow_remote { + if allow_remote { + return ( + StatusCode::BAD_REQUEST, + Json(UpdateConfigResponse { + success: false, + message: "当前版本未启用 TLS,禁止开启远程管理".to_string(), + }), + ); + } + let manager = match &state.config_manager { + Some(manager) => manager, + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(UpdateConfigResponse { + success: false, + message: "Config manager is not available".to_string(), + }), + ); + } + }; + if let Ok(mut guard) = manager.write() { + guard.config_mut().remote_management.allow_remote = allow_remote; + if let Err(err) = guard.save() { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(UpdateConfigResponse { + success: false, + message: format!("Failed to save config: {}", err), + }), + ); + } + } else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(UpdateConfigResponse { + success: false, + message: "Failed to lock config manager".to_string(), + }), + ); + } + tracing::info!( + "[MANAGEMENT] Updated remote_management.allow_remote to: {}", + allow_remote + ); + updated = true; + needs_restart = true; + } + if updated { ( StatusCode::OK, Json(UpdateConfigResponse { success: true, - message: "Configuration updated successfully".to_string(), + message: if needs_restart { + "Configuration updated. Restart required to apply all changes.".to_string() + } else { + "Configuration updated successfully".to_string() + }, }), ) } else { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 23423afee..ddf44545a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:*; frame-src 'none'; object-src 'none'" } }, "bundle": { From 770631be9715da224cec61b826db286f380583bc Mon Sep 17 00:00:00 2001 From: jiesen Date: Sat, 20 Dec 2025 21:05:00 +0700 Subject: [PATCH 02/15] =?UTF-8?q?Codex=20=E6=94=AF=E6=8C=81=20API=20Key=20?= =?UTF-8?q?=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/providers/codex.rs | 204 +++++++++++++++--- .../src/services/provider_pool_service.rs | 53 +++-- 2 files changed, 208 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index 9873d0bfb..2ed901199 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -17,6 +17,7 @@ const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const DEFAULT_CALLBACK_PORT: u16 = 1455; const CODEX_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; +const DEFAULT_API_BASE_URL: &str = "https://api.openai.com"; /// Codex OAuth credentials storage /// @@ -26,6 +27,10 @@ const CODEX_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; /// Supports multiple field name formats: /// - snake_case: `refresh_token`, `access_token`, `id_token`, `account_id`, `last_refresh` /// - camelCase: `refreshToken`, `accessToken`, `idToken`, `accountId`, `lastRefresh` +/// +/// 同时兼容 Codex CLI 的 API Key 登录格式: +/// - `api_key` / `apiKey` +/// - `api_base_url` / `apiBaseUrl` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CodexCredentials { /// JWT ID token containing user claims @@ -45,6 +50,12 @@ pub struct CodexCredentials { alias = "refreshToken" )] pub refresh_token: Option, + /// API Key(Codex CLI 支持通过 API Key 登录) + #[serde(default, skip_serializing_if = "Option::is_none", alias = "apiKey")] + pub api_key: Option, + /// API Base URL(可选) + #[serde(default, skip_serializing_if = "Option::is_none", alias = "apiBaseUrl")] + pub api_base_url: Option, /// OpenAI account identifier #[serde(default, skip_serializing_if = "Option::is_none", alias = "accountId")] pub account_id: Option, @@ -83,6 +94,8 @@ impl Default for CodexCredentials { id_token: None, access_token: None, refresh_token: None, + api_key: None, + api_base_url: None, account_id: None, last_refresh: None, email: None, @@ -428,6 +441,24 @@ impl CodexProvider { CODEX_API_BASE_URL } + /// 获取已配置的 API Key(trim 后的非空值) + fn get_api_key(&self) -> Option<&str> { + self.credentials + .api_key + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + } + + fn build_responses_url(base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + if base.ends_with("/v1") { + format!("{}/responses", base) + } else { + format!("{}/v1/responses", base) + } + } + /// Load credentials from the default path pub async fn load_credentials(&mut self) -> Result<(), Box> { let path = Self::default_creds_path(); @@ -457,9 +488,14 @@ impl CodexProvider { })?; // 检查关键字段 - if creds.refresh_token.is_none() { + let has_api_key = creds + .api_key + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + if creds.refresh_token.is_none() && !has_api_key { tracing::warn!( - "[CODEX] 凭证文件缺少 refresh_token 字段。支持的字段名: refresh_token, refreshToken" + "[CODEX] 凭证文件缺少 refresh_token/api_key 字段。支持的字段名: refresh_token, refreshToken, api_key, apiKey" ); // 打印文件中的顶级字段名,帮助调试 if let Ok(json_value) = serde_json::from_str::(&content) { @@ -471,9 +507,10 @@ impl CodexProvider { } tracing::info!( - "[CODEX] 凭证加载成功: has_access={}, has_refresh={}, email={:?}, path={:?}", + "[CODEX] 凭证加载成功: has_access={}, has_refresh={}, has_api_key={}, email={:?}, path={:?}", creds.access_token.is_some(), creds.refresh_token.is_some(), + has_api_key, creds.email, path ); @@ -505,6 +542,11 @@ impl CodexProvider { /// Check if the access token is expired pub fn is_token_expired(&self) -> bool { + // API Key 模式:不涉及过期概念 + if self.get_api_key().is_some() { + return false; + } + if let Some(expires_str) = &self.credentials.expires_at { if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expires_str) { let now = chrono::Utc::now(); @@ -518,6 +560,9 @@ impl CodexProvider { /// Check if credentials are valid (has access token and not expired) pub fn is_valid(&self) -> bool { + if self.get_api_key().is_some() { + return true; + } self.credentials.access_token.is_some() && !self.is_token_expired() } @@ -608,6 +653,8 @@ impl CodexProvider { id_token, access_token: Some(access_token), refresh_token, + api_key: None, + api_base_url: None, account_id, last_refresh: Some(chrono::Utc::now().to_rfc3339()), email, @@ -627,10 +674,15 @@ impl CodexProvider { /// Refresh the access token using the refresh token pub async fn refresh_token(&mut self) -> Result> { + // API Key 模式无需刷新 + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + let refresh_token = self.credentials.refresh_token.as_ref().ok_or_else(|| { create_config_error( - "没有可用的 refresh_token。请确保凭证文件包含 refresh_token 或 refreshToken 字段,\ - 或使用 OAuth 登录功能重新获取凭证", + "没有可用的 refresh_token 或 api_key。请确保凭证文件包含 refresh_token/refreshToken 或 api_key/apiKey 字段,\ + 或使用 OAuth 登录功能重新获取凭证(需要 refresh_token)", ) })?; @@ -768,6 +820,11 @@ impl CodexProvider { /// Check if token needs refresh (expiring within the specified duration) pub fn needs_refresh(&self, lead_time: chrono::Duration) -> bool { + // API Key 模式无需刷新 + if self.get_api_key().is_some() { + return false; + } + if self.credentials.access_token.is_none() { return true; } @@ -788,6 +845,11 @@ impl CodexProvider { /// This is the recommended method to call before making API requests. /// It will automatically refresh the token if it's expired or about to expire. pub async fn ensure_valid_token(&mut self) -> Result> { + // 兼容 Codex CLI 的 API Key 登录:auth.json 只有 api_key,没有 refresh_token + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + // Refresh if token expires within 5 minutes let lead_time = chrono::Duration::minutes(5); @@ -811,6 +873,11 @@ impl CodexProvider { /// Get the access token, refreshing if necessary pub async fn get_access_token(&mut self) -> Result> { + // API Key 模式直接返回 + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + if self.is_token_expired() { self.refresh_token().await?; } @@ -919,42 +986,68 @@ impl CodexProvider { &self, request: &serde_json::Value, ) -> Result> { - let token = self - .credentials - .access_token - .as_ref() - .ok_or("No access token available")?; + enum AuthMode { + ApiKey, + OAuth, + } + + let (token, mode) = match self.get_api_key() { + Some(api_key) => (api_key, AuthMode::ApiKey), + None => ( + self.credentials + .access_token + .as_deref() + .ok_or("No access token or api_key available")?, + AuthMode::OAuth, + ), + }; // Build the Codex API URL - let url = format!("{}/responses", CODEX_API_BASE_URL); + let url = match mode { + AuthMode::ApiKey => { + let base_url = self + .credentials + .api_base_url + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_API_BASE_URL); + Self::build_responses_url(base_url) + } + AuthMode::OAuth => format!("{}/responses", CODEX_API_BASE_URL), + }; // Transform OpenAI chat completion request to Codex format let codex_request = transform_to_codex_format(request)?; tracing::debug!("[CODEX] Calling API: {}", url); - let resp = self + let mut req = self .client .post(&url) .header("Authorization", format!("Bearer {}", token)) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") - .header("Version", "0.21.0") .header("Openai-Beta", "responses=experimental") - .header( - "User-Agent", - "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464", - ) - .header("Originator", "codex_cli_rs") - .header("Session_id", uuid::Uuid::new_v4().to_string()) - // Add account ID header if available - .header( - "Chatgpt-Account-Id", - self.credentials.account_id.as_deref().unwrap_or(""), - ) - .json(&codex_request) - .send() - .await?; + .json(&codex_request); + + if matches!(mode, AuthMode::OAuth) { + req = req + .header("Version", "0.21.0") + .header( + "User-Agent", + "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464", + ) + .header("Originator", "codex_cli_rs") + .header("Session_id", uuid::Uuid::new_v4().to_string()) + // Add account ID header if available + .header( + "Chatgpt-Account-Id", + self.credentials.account_id.as_deref().unwrap_or(""), + ); + } + + let resp = req.send().await?; Ok(resp) } @@ -1163,6 +1256,7 @@ mod tests { let creds = CodexCredentials::default(); assert!(creds.access_token.is_none()); assert!(creds.refresh_token.is_none()); + assert!(creds.api_key.is_none()); assert_eq!(creds.r#type, "codex"); } @@ -1232,6 +1326,32 @@ mod tests { assert_eq!(creds.expires_at, Some("2024-12-31T23:59:59Z".to_string())); } + #[test] + fn test_codex_credentials_api_key_fields() { + let json = r#"{ + "api_key": "sk-test", + "api_base_url": "https://api.openai.com/v1" + }"#; + + let creds: CodexCredentials = serde_json::from_str(json).unwrap(); + assert_eq!(creds.api_key, Some("sk-test".to_string())); + assert_eq!( + creds.api_base_url, + Some("https://api.openai.com/v1".to_string()) + ); + + let json2 = r#"{ + "apiKey": "sk-test-2", + "apiBaseUrl": "https://example.com/v1" + }"#; + let creds2: CodexCredentials = serde_json::from_str(json2).unwrap(); + assert_eq!(creds2.api_key, Some("sk-test-2".to_string())); + assert_eq!( + creds2.api_base_url, + Some("https://example.com/v1".to_string()) + ); + } + #[test] fn test_codex_credentials_expires_at_alias() { // 测试 expires_at 字段的多种别名 @@ -1264,6 +1384,31 @@ mod tests { assert!(provider.credentials.access_token.is_none()); } + #[test] + fn test_build_responses_url() { + assert_eq!( + CodexProvider::build_responses_url("https://api.openai.com"), + "https://api.openai.com/v1/responses" + ); + assert_eq!( + CodexProvider::build_responses_url("https://api.openai.com/v1"), + "https://api.openai.com/v1/responses" + ); + assert_eq!( + CodexProvider::build_responses_url("https://example.com/v1/"), + "https://example.com/v1/responses" + ); + } + + #[tokio::test] + async fn test_ensure_valid_token_prefers_api_key() { + let mut provider = CodexProvider::new(); + provider.credentials.api_key = Some("sk-test".to_string()); + + let token = provider.ensure_valid_token().await.unwrap(); + assert_eq!(token, "sk-test"); + } + #[test] fn test_generate_auth_url() { let provider = CodexProvider::new(); @@ -1342,7 +1487,12 @@ mod tests { // No expiry - should be considered expired assert!(provider.is_token_expired()); + // API Key 模式 - 不应视为过期 + provider.credentials.api_key = Some("sk-test".to_string()); + assert!(!provider.is_token_expired()); + // Expired token + provider.credentials.api_key = None; provider.credentials.expires_at = Some("2020-01-01T00:00:00Z".to_string()); assert!(provider.is_token_expired()); diff --git a/src-tauri/src/services/provider_pool_service.rs b/src-tauri/src/services/provider_pool_service.rs index 745a004df..b184ab321 100644 --- a/src-tauri/src/services/provider_pool_service.rs +++ b/src-tauri/src/services/provider_pool_service.rs @@ -852,28 +852,15 @@ impl ProviderPoolService { .map_err(|e| format!("获取 Codex Token 失败: {}", e))?; // 使用 OpenAI 兼容 API 进行健康检查 - let url = "https://api.openai.com/v1/chat/completions"; - let request_body = serde_json::json!({ - "model": model, - "messages": [{"role": "user", "content": "Say OK"}], - "max_tokens": 10 - }); + // 兼容 Codex CLI API Key 模式:如果 auth.json 提供 api_base_url,则优先使用 + let base_url = provider + .credentials + .api_base_url + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); - let response = self - .client - .post(url) - .header("Authorization", format!("Bearer {}", token)) - .json(&request_body) - .timeout(self.health_check_timeout) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - if response.status().is_success() { - Ok(()) - } else { - Err(format!("HTTP {}", response.status())) - } + self.check_openai_health(&token, base_url, model).await } // Claude OAuth 健康检查 @@ -1078,12 +1065,20 @@ impl ProviderPoolService { let creds: serde_json::Value = serde_json::from_str(&content).map_err(|e| format!("解析凭证文件失败: {}", e))?; - let has_access_token = creds + let has_api_key = creds + .get("apiKey") + .or_else(|| creds.get("api_key")) + .map(|v| v.as_str().is_some()) + .unwrap_or(false); + + let has_oauth_access_token = creds .get("accessToken") .or_else(|| creds.get("access_token")) .map(|v| v.as_str().is_some()) .unwrap_or(false); + let has_access_token = has_oauth_access_token || has_api_key; + let has_refresh_token = creds .get("refreshToken") .or_else(|| creds.get("refresh_token")) @@ -1114,6 +1109,20 @@ impl ProviderPoolService { (has_access_token, None) } } + "codex" => { + // Codex: 兼容 OAuth token 或 Codex CLI 的 API Key 登录 + if has_api_key { + (true, None) + } else { + let expires_at = creds + .get("expiresAt") + .or_else(|| creds.get("expires_at")) + .or_else(|| creds.get("expired")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + (has_oauth_access_token, expires_at) + } + } _ => (has_access_token, None), }; From f5406f7013ea2168f16310f3caa697220c14d8be Mon Sep 17 00:00:00 2001 From: jiesen Date: Sat, 20 Dec 2025 21:05:20 +0700 Subject: [PATCH 03/15] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E4=B8=8E=E5=AE=89=E5=85=A8=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 22 ++++++++++++++-- src/components/Dashboard.tsx | 2 +- src/components/api-server/ApiServerPage.tsx | 6 ++--- src/components/clients/ProviderForm.tsx | 10 +++---- .../provider-pool/EditCredentialModal.tsx | 2 +- .../provider-pool/ProviderPoolPage.tsx | 2 +- src/components/settings/ProxySettings.tsx | 2 +- .../settings/RemoteManagementSettings.tsx | 16 +++++++++--- src/components/settings/TlsSettings.tsx | 26 ++++++++++++++++--- src/components/switch/ProviderForm.tsx | 8 +++--- 10 files changed, 71 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa6b336ad..3401438c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxycast", - "version": "0.14.2", + "version": "0.14.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.14.2", + "version": "0.14.6", "dependencies": { "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -97,6 +97,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2780,6 +2781,7 @@ "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2797,6 +2799,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2808,6 +2811,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2847,6 +2851,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -3169,6 +3174,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3387,6 +3393,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3734,6 +3741,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4368,6 +4376,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4846,6 +4855,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5068,6 +5078,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5080,6 +5091,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5572,6 +5584,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5647,6 +5660,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5759,6 +5773,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5819,6 +5834,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -6375,6 +6391,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6388,6 +6405,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 9196f9727..534972239 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -153,7 +153,7 @@ export function Dashboard() { const serverUrl = status ? `http://${status.host}:${status.port}` - : "http://localhost:3001"; + : "http://localhost:8999"; const getProviderName = (id: string) => { switch (id) { diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index fdf633e0a..434407f6f 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -148,7 +148,7 @@ export function ApiServerPage() { ...config, server: { ...config.server, - port: parseInt(editPort) || 3001, + port: parseInt(editPort) || 8999, api_key: editApiKey, }, }; @@ -236,8 +236,8 @@ export function ApiServerPage() { const serverUrl = status ? `http://${status.host}:${status.port}` - : "http://localhost:3001"; - const apiKey = config?.server.api_key || "proxycast-key"; + : `http://localhost:${config?.server.port ?? 8999}`; + const apiKey = config?.server.api_key ?? ""; // Test endpoints const testEndpoints = [ diff --git a/src/components/clients/ProviderForm.tsx b/src/components/clients/ProviderForm.tsx index ed67f76a4..e370b6a97 100644 --- a/src/components/clients/ProviderForm.tsx +++ b/src/components/clients/ProviderForm.tsx @@ -142,7 +142,7 @@ const presets: Record = { name: "ProxyCast", category: "proxy", iconColor: "#3b82f6", - defaultBaseUrl: "http://127.0.0.1:3001", + defaultBaseUrl: "http://127.0.0.1:8999", }, // 从凭证池导入 { @@ -192,7 +192,7 @@ model = "gpt-4o" iconColor: "#3b82f6", defaultCodexAuth: { api_key: "proxycast", - api_base_url: "http://127.0.0.1:3001/v1", + api_base_url: "http://127.0.0.1:8999/v1", }, }, // 从凭证池导入 @@ -228,7 +228,7 @@ model = "gpt-4o" iconColor: "#3b82f6", defaultGeminiEnv: { GEMINI_API_KEY: "proxycast", - GOOGLE_GEMINI_BASE_URL: "http://127.0.0.1:3001", + GOOGLE_GEMINI_BASE_URL: "http://127.0.0.1:8999", GEMINI_MODEL: "gemini-2.0-flash", }, }, @@ -562,7 +562,7 @@ export function ProviderForm({ const config = await getConfig(); const proxyApiKey = config.server.api_key || ""; const proxyHost = config.server.host || "127.0.0.1"; - const proxyPort = config.server.port || 3001; + const proxyPort = config.server.port || 8999; const proxyBaseUrl = `http://${proxyHost}:${proxyPort}`; if (appType === "claude") { @@ -681,7 +681,7 @@ export function ProviderForm({ const config = await getConfig(); const proxyApiKey = config.server.api_key || ""; const proxyHost = config.server.host || "127.0.0.1"; - const proxyPort = config.server.port || 3001; + const proxyPort = config.server.port || 8999; const proxyBaseUrl = `http://${proxyHost}:${proxyPort}`; if (appType === "claude") { diff --git a/src/components/provider-pool/EditCredentialModal.tsx b/src/components/provider-pool/EditCredentialModal.tsx index 391975926..18fa230bf 100644 --- a/src/components/provider-pool/EditCredentialModal.tsx +++ b/src/components/provider-pool/EditCredentialModal.tsx @@ -53,7 +53,7 @@ const providerModels: Record = { ], openai: [], // 自定义 API,无预设模型 claude: [], // 自定义 API,无预设模型 - codex: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini", "o3-mini"], // Codex OAuth + codex: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini", "o3-mini"], // Codex(OAuth / API Key) claude_oauth: [ "claude-3-5-sonnet-latest", "claude-3-5-haiku-latest", diff --git a/src/components/provider-pool/ProviderPoolPage.tsx b/src/components/provider-pool/ProviderPoolPage.tsx index 976bc761c..4e984035b 100644 --- a/src/components/provider-pool/ProviderPoolPage.tsx +++ b/src/components/provider-pool/ProviderPoolPage.tsx @@ -56,7 +56,7 @@ const providerLabels: Record = { antigravity: "Antigravity (Gemini 3 Pro)", openai: "OpenAI", claude: "Claude (Anthropic)", - codex: "Codex (OpenAI OAuth)", + codex: "Codex (OAuth / API Key)", claude_oauth: "Claude OAuth", iflow: "iFlow", }; diff --git a/src/components/settings/ProxySettings.tsx b/src/components/settings/ProxySettings.tsx index fee178288..c21d25f9c 100644 --- a/src/components/settings/ProxySettings.tsx +++ b/src/components/settings/ProxySettings.tsx @@ -173,7 +173,7 @@ export function ProxySettings() { ...config, server: { ...config.server, - port: parseInt(e.target.value) || 3001, + port: parseInt(e.target.value) || 8999, }, }) } diff --git a/src/components/settings/RemoteManagementSettings.tsx b/src/components/settings/RemoteManagementSettings.tsx index bb3865cbd..78c9c4c0f 100644 --- a/src/components/settings/RemoteManagementSettings.tsx +++ b/src/components/settings/RemoteManagementSettings.tsx @@ -88,7 +88,8 @@ export function RemoteManagementSettings() { } const rm = config.remote_management; - const isEnabled = rm.secret_key && rm.secret_key.length > 0; + const isEnabled = Boolean(rm.secret_key && rm.secret_key.length > 0); + const remoteAccessSupported = false; return (
@@ -121,6 +122,13 @@ export function RemoteManagementSettings() { )}
+ {!remoteAccessSupported && ( +
+ + 当前版本未启用 TLS,暂不支持远程管理访问,请保持关闭。 +
+ )} + {/* 管理密钥 */}
@@ -179,7 +187,7 @@ export function RemoteManagementSettings() { {/* 允许远程访问 */}