mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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.
This commit is contained in:
@@ -887,6 +887,11 @@ pub fn proxy_gateway_base_url_from_db(db: &Database) -> Result<String, AppError>
|
||||
// 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(),
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<bool, String> {
|
||||
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<ProxyServerInfo, String> {
|
||||
// 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<Database>) {
|
||||
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!(
|
||||
|
||||
Reference in New Issue
Block a user