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:
Jason
2026-06-06 18:02:41 +08:00
parent aa09c9cb62
commit 2985ad2c14
3 changed files with 196 additions and 8 deletions
+24
View File
@@ -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(),
+8 -4
View File
@@ -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(),
})
}
+164 -4
View File
@@ -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!(