From 2985ad2c14278450b2251beb0230810112c63742 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 6 Jun 2026 18:02:41 +0800 Subject: [PATCH] fix(proxy): resolve actual port for ephemeral (port 0) listen config When listen_port is 0 the OS assigns the port at bind time, so the configured value can no longer be trusted for building takeover URLs. - server: read listener.local_addr() after bind and propagate the actual port to the global proxy port, status, and ProxyServerInfo - services: start the proxy before takeover when port is 0 so live configs get the real port instead of :0, and persist the resolved port back to the DB for DB-only URL paths; stop the pre-started server on any takeover failure - claude_desktop: reject an unresolved :0 port instead of emitting a broken gateway URL - build_proxy_urls: prefer the running server's port and error out if the port is still 0 Add tests for takeover with an ephemeral port and the claude_desktop :0 rejection; switch existing codex takeover tests to an ephemeral port for isolation. --- src-tauri/src/claude_desktop_config.rs | 24 ++++ src-tauri/src/proxy/server.rs | 12 +- src-tauri/src/services/proxy.rs | 168 ++++++++++++++++++++++++- 3 files changed, 196 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index de3d0d333..88c68dcf0 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -887,6 +887,11 @@ pub fn proxy_gateway_base_url_from_db(db: &Database) -> Result // get_proxy_config is async-tagged but its body is fully synchronous (rusqlite // under a Mutex), so block_on cannot deadlock the calling thread. let config = futures::executor::block_on(db.get_proxy_config())?; + if config.listen_port == 0 { + return Err(AppError::Config( + "Claude Desktop 代理地址需要真实监听端口;请先启动本地代理或使用固定端口".to_string(), + )); + } Ok(format!( "{}{}", proxy_origin_from_parts(&config.listen_address, config.listen_port), @@ -1304,6 +1309,12 @@ mod tests { Database::memory().expect("memory db") } + fn set_proxy_port(db: &Database, port: u16) { + let mut config = crate::proxy::types::ProxyConfig::default(); + config.listen_port = port; + futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config"); + } + fn direct_provider(id: &str) -> Provider { let mut provider = Provider::with_id( id.to_string(), @@ -1324,6 +1335,19 @@ mod tests { provider } + #[test] + fn proxy_gateway_base_url_rejects_unresolved_ephemeral_port() { + let db = test_db(); + set_proxy_port(&db, 0); + + let err = proxy_gateway_base_url_from_db(&db) + .expect_err("unresolved ephemeral port should not produce a :0 URL"); + assert!( + err.to_string().contains("真实监听端口"), + "unexpected error: {err}" + ); + } + fn official_provider() -> Provider { let mut provider = Provider::with_id( CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID.to_string(), diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index e7b6f601b..6ef02bc81 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -112,11 +112,15 @@ impl ProxyServer { let listener = tokio::net::TcpListener::bind(&addr) .await .map_err(|e| ProxyError::BindFailed(e.to_string()))?; + let local_addr = listener + .local_addr() + .map_err(|e| ProxyError::BindFailed(e.to_string()))?; + let actual_port = local_addr.port(); - log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED); + log::info!("[{}] 代理服务器启动于 {local_addr}", log_srv::STARTED); // 更新全局代理端口,用于系统代理检测 - crate::proxy::http_client::set_proxy_port(self.config.listen_port); + crate::proxy::http_client::set_proxy_port(actual_port); // 保存关闭句柄 *self.shutdown_tx.write().await = Some(shutdown_tx); @@ -125,7 +129,7 @@ impl ProxyServer { let mut status = self.state.status.write().await; status.running = true; status.address = self.config.listen_address.clone(); - status.port = self.config.listen_port; + status.port = actual_port; drop(status); // 记录启动时间 @@ -213,7 +217,7 @@ impl ProxyServer { Ok(ProxyServerInfo { address: self.config.listen_address.clone(), - port: self.config.listen_port, + port: actual_port, started_at: chrono::Utc::now().to_rfc3339(), }) } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 0a58961b2..7918f5b9a 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -438,6 +438,13 @@ impl ProxyService { .start() .await .map_err(|e| format!("启动代理服务器失败: {e}"))?; + if let Err(e) = self + .persist_ephemeral_listen_port_if_needed(&config, info.port) + .await + { + let _ = server.stop().await; + return Err(e); + } // 5. 保存服务器实例 *self.server.write().await = Some(server); @@ -446,6 +453,37 @@ impl ProxyService { Ok(info) } + async fn persist_ephemeral_listen_port_if_needed( + &self, + config: &ProxyConfig, + actual_port: u16, + ) -> Result<(), String> { + if config.listen_port != 0 { + return Ok(()); + } + + let mut resolved_config = config.clone(); + resolved_config.listen_port = actual_port; + self.db + .update_proxy_config(resolved_config) + .await + .map_err(|e| format!("保存动态代理端口失败: {e}")) + } + + async fn start_before_takeover_if_ephemeral_port(&self) -> Result { + let config = self + .db + .get_proxy_config() + .await + .map_err(|e| format!("获取代理配置失败: {e}"))?; + if config.listen_port != 0 || self.is_running().await { + return Ok(false); + } + + self.start().await?; + Ok(true) + } + /// 启动代理服务器(带 Live 配置接管) pub async fn start_with_takeover(&self) -> Result { // 1. 备份各应用的 Live 配置 @@ -460,12 +498,27 @@ impl ProxyService { return Err(e); } + // 端口 0 需要先启动代理拿到 OS 分配的真实端口,否则接管 Live 配置会写出 :0。 + let started_proxy_before_takeover = + match self.start_before_takeover_if_ephemeral_port().await { + Ok(started) => started, + Err(e) => { + if let Err(clean_err) = self.db.delete_all_live_backups().await { + log::warn!("清理 Live 备份失败: {clean_err}"); + } + return Err(e); + } + }; + // 3. 在写入接管配置之前先落盘接管标志: // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。 if let Err(e) = self.db.set_live_takeover_active(true).await { if let Err(clean_err) = self.db.delete_all_live_backups().await { log::warn!("清理 Live 备份失败: {clean_err}"); } + if started_proxy_before_takeover { + let _ = self.stop().await; + } return Err(format!("设置接管状态失败: {e}")); } @@ -482,6 +535,9 @@ impl ProxyService { log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); } } + if started_proxy_before_takeover { + let _ = self.stop().await; + } return Err(e); } @@ -500,6 +556,9 @@ impl ProxyService { log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); } } + if started_proxy_before_takeover { + let _ = self.stop().await; + } Err(e) } } @@ -1183,7 +1242,18 @@ impl ProxyService { connect_host }; - let proxy_origin = format!("http://{}:{}", connect_host_for_url, config.listen_port); + let mut listen_port = config.listen_port; + if let Some(server) = self.server.read().await.as_ref() { + let status = server.get_status().await; + if status.running { + listen_port = status.port; + } + } + if listen_port == 0 { + return Err("代理监听端口为 0,但代理服务器尚未运行,无法生成接管地址".to_string()); + } + + let proxy_origin = format!("http://{}:{}", connect_host_for_url, listen_port); let proxy_url = proxy_origin.clone(); let proxy_codex_base_url = format!("{}/v1", proxy_origin.trim_end_matches('/')); @@ -2488,11 +2558,18 @@ impl ProxyService { } let app_handle = self.app_handle.read().await.clone(); - let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle); - new_server + let new_server = ProxyServer::new(new_config.clone(), self.db.clone(), app_handle); + let info = new_server .start() .await .map_err(|e| format!("重启代理服务器失败: {e}"))?; + if let Err(e) = self + .persist_ephemeral_listen_port_if_needed(&new_config, info.port) + .await + { + let _ = new_server.stop().await; + return Err(e); + } *server_guard = Some(new_server); log::info!("代理配置已更新,服务器已自动重启应用最新配置"); @@ -2647,6 +2724,19 @@ mod tests { assert_eq!(env.get(key).and_then(|value| value.as_str()), expected); } + async fn use_ephemeral_proxy_port(db: &Arc) { + let mut proxy_config = db.get_proxy_config().await.expect("get test proxy config"); + proxy_config.listen_port = 0; + db.update_proxy_config(proxy_config) + .await + .expect("set test proxy config to an ephemeral port"); + } + + async fn running_codex_base_url(service: &ProxyService) -> String { + let status = service.get_status().await.expect("get proxy status"); + format!("http://127.0.0.1:{}/v1", status.port) + } + fn seed_codex_model_template() { let codex_dir = crate::codex_config::get_codex_config_dir(); std::fs::create_dir_all(&codex_dir).expect("create codex dir"); @@ -2875,6 +2965,72 @@ mod tests { ); } + #[tokio::test] + #[serial] + async fn start_with_takeover_ephemeral_port_writes_actual_live_url() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; + let service = ProxyService::new(db.clone()); + + let provider = Provider::with_id( + "p1".to_string(), + "P1".to_string(), + json!({ + "env": { + "ANTHROPIC_API_KEY": "provider-key", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + }), + None, + ); + db.save_provider("claude", &provider) + .expect("save provider"); + db.set_current_provider("claude", "p1") + .expect("set db current provider"); + crate::settings::set_current_provider(&AppType::Claude, Some("p1")) + .expect("set local current provider"); + service + .write_claude_live(&json!({ + "env": { + "ANTHROPIC_API_KEY": "live-key", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .expect("seed claude live config"); + + let info = service + .start_with_takeover() + .await + .expect("start proxy with takeover"); + assert_ne!(info.port, 0, "OS should assign a concrete port"); + + let stored_config = db.get_proxy_config().await.expect("read proxy config"); + assert_eq!( + stored_config.listen_port, info.port, + "resolved dynamic port should be persisted for DB-only proxy URL paths" + ); + + let live = service.read_claude_live().expect("read taken-over live"); + let base_url = live + .get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(|value| value.as_str()) + .expect("taken-over base url"); + assert_eq!(base_url, format!("http://127.0.0.1:{}", info.port)); + assert!( + !base_url.contains(":0"), + "takeover must never write an unresolved :0 port" + ); + + service + .stop_with_restore() + .await + .expect("stop proxy and restore live config"); + } + #[test] #[serial] fn codex_custom_provider_live_write_preserves_oauth_auth_json() { @@ -3145,6 +3301,7 @@ wire_api = "responses" .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; let service = ProxyService::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", @@ -3224,6 +3381,7 @@ wire_api = "responses" .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; let state = crate::store::AppState::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", @@ -3444,6 +3602,7 @@ wire_api = "responses" .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; let service = ProxyService::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", @@ -3532,8 +3691,9 @@ wire_api = "responses" let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); + let expected_base_url = running_codex_base_url(&service).await; assert!( - live_config.contains("http://127.0.0.1:15721/v1"), + live_config.contains(&expected_base_url), "stale enabled takeover must be rebuilt to the current proxy base_url" ); assert!(