* chore(release): stop publishing legacy Windows update packages

* feat(agents): support OpenCode Desktop across platforms

Detect CLI and desktop installations on Windows, macOS, and Linux, and add separate launch and restart controls.

Closes #156

* fix(codex): align model display names and show total session count

- preserve runtime model identity in generated catalogs
- count distinct sessions across database and rollout sources
- update session count labels in all supported languages

* fix(codex): keep config and auth files in sync

- generate auth.json for API key configuration
- preserve and validate OAuth credentials
- include auth.json in transactional updates and rollback
- retain compatibility with legacy Codex configuration states

* fix(cpa-gui): update version to 0.2.59 in Cargo files

* fix(codex): preserve OAuth mode after closing configuration

* feat(config): add TLS management and protocol-aware local URLs

- add TLS controls to advanced settings
- persist certificate and private key paths
- restart the core after TLS changes
- switch WebUI, API and management URLs between HTTP and HTTPS
- synchronize agent configurations and detection with the active protocol

* refactor(config): reorganize network, routing, and TLS settings

- split network settings and session routing into separate tabs
- move TLS configuration into network settings
- preserve TLS restart and HTTPS URL synchronization behavior

* feat(config): add independent network settings and custom listen IP

* fix(navigation): allow access to usage records while core is stopped

* refactor(home): show a single API URL based on the configured listen IP

* feat(config): add file pickers for TLS certificate and private key
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-23 17:56:13 +08:00
1 parent c3cd57bd8e
commit 2b9ceb11fb
21 files changed
+1355 -261

No files matched your search

+6 -2
View File
@@ -582,12 +582,14 @@ pub(crate) async fn fetch_agent_models(
if port == 0 {
return Err("内核端口无效".to_string());
}
let tls_enabled = managed_core_tls_enabled();
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(3))
.timeout(Duration::from_secs(15))
.danger_accept_invalid_certs(tls_enabled)
.build()
.map_err(|error| format!("创建模型列表客户端失败: {error}"))?;
let base_url = format!("http://127.0.0.1:{port}");
let base_url = managed_core_loopback_origin(port);
let endpoints = [
format!("{base_url}/v1/models"),
format!("{base_url}/models"),
@@ -633,12 +635,14 @@ pub(crate) async fn fetch_codex_runtime_models(
if port == 0 {
return Err("内核端口无效".to_string());
}
let tls_enabled = managed_core_tls_enabled();
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(3))
.timeout(Duration::from_secs(15))
.danger_accept_invalid_certs(tls_enabled)
.build()
.map_err(|error| format!("创建 Codex 模型列表客户端失败: {error}"))?;
let base_url = format!("http://127.0.0.1:{port}");
let base_url = managed_core_loopback_origin(port);
let endpoints = [
format!("{base_url}/v1/models"),
format!("{base_url}/models"),
+1 -1
View File
@@ -42,7 +42,7 @@ pub(crate) fn build_agent_updates_with_oauth(
claude_desktop_model_mappings,
} = options;
let paths = agent_config_paths(client, home);
let root_base = format!("http://127.0.0.1:{port}");
let root_base = managed_core_loopback_origin(port);
let openai_base = format!("{root_base}/v1");
match client {
AgentClient::ClaudeCode => {
+37 -13
View File
@@ -356,7 +356,7 @@ pub(crate) fn inspect_pi_provider_status(
let object = root
.as_object()
.ok_or_else(|| "Pi CLIProxyAPI 配置根节点必须是 JSON 对象".to_string())?;
let expected_base_url = format!("http://127.0.0.1:{port}");
let expected_base_url = managed_core_loopback_origin(port);
credentials_match = object
.get("baseUrl")
.and_then(serde_json::Value::as_str)
@@ -491,7 +491,7 @@ pub(crate) fn repair_pi_provider_inner(
} else {
None
};
let base_url = format!("http://127.0.0.1:{port}");
let base_url = managed_core_loopback_origin(port);
let rendered = build_pi_provider_config(existing.as_deref(), &base_url, api_key)?;
if existing.as_deref() != Some(rendered.as_str()) {
write_bytes_atomically(&config_path, rendered.as_bytes())?;
@@ -1365,7 +1365,31 @@ pub(crate) fn agent_has_managed_marker(
pub(crate) fn is_managed_agent_base_url(value: &str) -> bool {
let value = value.trim().to_ascii_lowercase();
value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:")
if value.starts_with("http://127.0.0.1:")
|| value.starts_with("http://localhost:")
|| value.starts_with("https://127.0.0.1:")
|| value.starts_with("https://localhost:")
{
return true;
}
#[cfg(not(test))]
{
let Ok(settings) = read_installed_core_config_settings() else {
return false;
};
let host = core_connect_host(&settings.host);
let host = if host.contains(':') {
format!("[{host}]")
} else {
host
};
value.starts_with(&format!("http://{host}:"))
|| value.starts_with(&format!("https://{host}:"))
}
#[cfg(test)]
{
false
}
}
pub(crate) fn inspect_managed_toml_model_marker(
@@ -2767,7 +2791,7 @@ pub(crate) fn inspect_claude_agent_config(
)
.map_err(|error| format!("解析 Claude Code 配置失败: {error}"))?;
let env = root.get("env").and_then(serde_json::Value::as_object);
let expected_base = format!("http://127.0.0.1:{port}");
let expected_base = managed_core_loopback_origin(port);
let configured = env
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
.and_then(serde_json::Value::as_str)
@@ -3100,7 +3124,7 @@ pub(crate) fn inspect_claude_desktop_agent_config(
let threep = read_agent_json_or_empty(&paths[1], "Claude Desktop 3P 配置")?;
let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop 网关配置")?;
let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?;
let expected_base = format!("http://127.0.0.1:{port}");
let expected_base = managed_core_loopback_origin(port);
let configured = normal
.get("deploymentMode")
.and_then(serde_json::Value::as_str)
@@ -3191,7 +3215,7 @@ pub(crate) fn inspect_codex_agent_config(
&fs::read_to_string(path).map_err(|error| format!("读取 Codex 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 Codex 配置失败: {error}"))?;
let expected_base = format!("http://127.0.0.1:{port}/v1");
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let provider = root
.get("model_providers")
.and_then(toml::Value::as_table)
@@ -3264,7 +3288,7 @@ pub(crate) fn inspect_opencode_agent_config(
let provider = root
.get("provider")
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
let expected_base = format!("http://127.0.0.1:{port}/v1");
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let configured = provider
.and_then(|provider| provider.get("options"))
.and_then(|options| options.get("baseURL"))
@@ -3301,7 +3325,7 @@ pub(crate) fn inspect_zcode_agent_config(
let provider = root
.get("provider")
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
let expected_base = format!("http://127.0.0.1:{port}");
let expected_base = managed_core_loopback_origin(port);
let api_format_matches = provider
.and_then(|provider| provider.get("apiFormat"))
.and_then(serde_json::Value::as_str)
@@ -3363,7 +3387,7 @@ pub(crate) fn inspect_kimi_code_agent_config(
&fs::read_to_string(path).map_err(|error| format!("读取 Kimi Code 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 Kimi Code 配置失败: {error}"))?;
let expected_base = format!("http://127.0.0.1:{port}/v1");
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let provider = root
.get("providers")
.and_then(toml::Value::as_table)
@@ -3420,7 +3444,7 @@ pub(crate) fn inspect_grok_build_agent_config(
&fs::read_to_string(path).map_err(|error| format!("读取 Grok Build 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 Grok Build 配置失败: {error}"))?;
let expected_base = format!("http://127.0.0.1:{port}/v1");
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let selected = root
.get("models")
@@ -3474,7 +3498,7 @@ pub(crate) fn inspect_openclaw_agent_config(
.get("models")
.and_then(|models| models.get("providers"))
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
let expected_base = format!("http://127.0.0.1:{port}/v1");
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let configured = provider
.and_then(|provider| provider.get("baseUrl"))
.and_then(serde_json::Value::as_str)
@@ -3518,7 +3542,7 @@ pub(crate) fn inspect_hermes_agent_config(
== Some(MANAGED_AGENT_PROVIDER_ID)
})
});
let expected_base = format!("http://127.0.0.1:{port}/v1");
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let configured = provider
.and_then(|provider| provider.get("base_url"))
.and_then(serde_yaml::Value::as_str)
@@ -3574,7 +3598,7 @@ pub(crate) fn inspect_deepseek_harness_config(
.map(str::to_string)
})
.flatten();
let expected_base = format!("http://127.0.0.1:{port}/v1");
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let credential = yaml_mapping_value(&credentials, DEEPSEEK_HARNESS_CREDENTIAL)
.and_then(serde_norway::Value::as_str);
let model_is_published = model.as_deref().is_some_and(|selected| {
+1 -1
View File
@@ -689,7 +689,7 @@ pub(crate) fn fresh_agent_contents_with_oauth(
claude_desktop_model_mappings,
..
} = options;
let root_base = format!("http://127.0.0.1:{port}");
let root_base = managed_core_loopback_origin(port);
let openai_base = format!("{root_base}/v1");
match client {
AgentClient::ClaudeCode => Ok(vec![build_claude_agent_config(
+99
View File
@@ -5,6 +5,22 @@ pub(crate) fn get_lan_ipv4() -> Option<String> {
detect_lan_ipv4().map(|address| address.to_string())
}
#[tauri::command]
pub(crate) fn get_core_tls_settings() -> Result<CoreTlsSettings, String> {
current_core_tls_settings()
}
#[tauri::command]
pub(crate) fn save_core_tls_settings(
cache: tauri::State<'_, AgentConfigStatusCache>,
settings: CoreTlsSettings,
) -> Result<CoreTlsSettings, String> {
let settings = normalize_core_tls_settings(settings)?;
patch_core_tls_settings(&settings)?;
cache.clear()?;
Ok(settings)
}
pub(crate) fn detect_lan_ipv4() -> Option<Ipv4Addr> {
for target in ["192.0.2.1:80", "8.8.8.8:80"] {
let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) else {
@@ -127,6 +143,89 @@ pub(crate) fn save_network_routing_settings(
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
pub(crate) fn save_network_endpoint_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
cache: tauri::State<'_, AgentConfigStatusCache>,
settings: GuiNetworkEndpointSettings,
) -> Result<CoreConfigView, String> {
if settings.port == 0 {
return Err("Port must be between 1 and 65535".to_string());
}
let host = normalize_optional_config_string(settings.host, "Listen IP")?;
if host.parse::<IpAddr>().is_err() {
return Err("Listen IP must be a valid IPv4 or IPv6 address".to_string());
}
let proxy_url = normalize_optional_config_string(settings.proxy_url, "Proxy URL")?;
let previous = gui_config_state.snapshot()?;
let mut next = previous.clone();
next.host = host;
next.allow_lan = !is_loopback_host(&next.host);
next.port = settings.port;
next.proxy_url = proxy_url;
validate_gui_config(&next)?;
patch_core_network_endpoint_settings(&next)?;
let config = match gui_config_state.update_network_endpoint(&next) {
Ok(config) => config,
Err(error) => {
let rollback_error = patch_core_network_endpoint_settings(&previous).err();
return Err(config_update_error_with_rollback(error, rollback_error));
}
};
if config.host != previous.host || config.port != previous.port {
if let Err(error) = cache.clear() {
eprintln!("Failed to clear agent configuration status cache: {error}");
}
}
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
pub(crate) fn save_retry_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
settings: GuiRetrySettings,
) -> Result<CoreConfigView, String> {
let previous = gui_config_state.snapshot()?;
let mut next = previous.clone();
next.request_retry = settings.request_retry;
next.max_retry_credentials = settings.max_retry_credentials;
next.max_retry_interval = settings.max_retry_interval;
next.streaming_bootstrap_retries = settings.streaming_bootstrap_retries;
patch_core_retry_settings(&next)?;
let config = match gui_config_state.update_retry_settings(&next) {
Ok(config) => config,
Err(error) => {
let rollback_error = patch_core_retry_settings(&previous).err();
return Err(config_update_error_with_rollback(error, rollback_error));
}
};
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
pub(crate) fn save_session_routing_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
settings: GuiSessionRoutingSettings,
) -> Result<CoreConfigView, String> {
let ttl = normalize_optional_config_string(
settings.routing_session_affinity_ttl,
"Session affinity TTL",
)?;
let previous = gui_config_state.snapshot()?;
let mut next = previous.clone();
next.routing_session_affinity = settings.routing_session_affinity;
next.routing_session_affinity_ttl = ttl;
patch_core_session_routing_settings(&next)?;
let config = match gui_config_state.update_session_routing(&next) {
Ok(config) => config,
Err(error) => {
let rollback_error = patch_core_session_routing_settings(&previous).err();
return Err(config_update_error_with_rollback(error, rollback_error));
}
};
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
pub(crate) fn get_core_config_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
+151 -1
View File
@@ -163,6 +163,80 @@ pub(crate) fn normalize_optional_config_string(
Ok(value)
}
pub(crate) fn normalize_core_tls_settings(
settings: CoreTlsSettings,
) -> Result<CoreTlsSettings, String> {
let cert = normalize_optional_config_string(settings.cert, "TLS certificate path")?;
let key = normalize_optional_config_string(settings.key, "TLS private key path")?;
if settings.enabled && (cert.is_empty() || key.is_empty()) {
return Err(
"TLS is enabled, so both the certificate and private key paths are required"
.to_string(),
);
}
Ok(CoreTlsSettings {
enabled: settings.enabled,
cert,
key,
})
}
#[cfg(test)]
pub(crate) fn core_loopback_origin(port: u16, tls_enabled: bool) -> String {
core_origin("127.0.0.1", port, tls_enabled)
}
pub(crate) fn core_connect_host(listen_host: &str) -> String {
let host = listen_host
.trim()
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or_else(|| listen_host.trim());
match host {
"" | "0.0.0.0" => "127.0.0.1".to_string(),
"::" => "::1".to_string(),
_ => host.to_string(),
}
}
pub(crate) fn core_origin(listen_host: &str, port: u16, tls_enabled: bool) -> String {
let scheme = if tls_enabled { "https" } else { "http" };
let host = core_connect_host(listen_host);
let url_host = if host.contains(':') {
format!("[{host}]")
} else {
host
};
format!("{scheme}://{url_host}:{port}")
}
pub(crate) fn managed_core_tls_enabled() -> bool {
#[cfg(test)]
{
false
}
#[cfg(not(test))]
{
current_core_tls_settings()
.map(|settings| settings.enabled)
.unwrap_or(false)
}
}
pub(crate) fn managed_core_loopback_origin(port: u16) -> String {
#[cfg(test)]
{
core_loopback_origin(port, false)
}
#[cfg(not(test))]
{
let host = read_installed_core_config_settings()
.map(|settings| settings.host)
.unwrap_or_else(|_| "127.0.0.1".to_string());
core_origin(&host, port, managed_core_tls_enabled())
}
}
pub(crate) fn config_update_error_with_rollback(
error: String,
rollback_error: Option<String>,
@@ -370,6 +444,76 @@ pub(crate) fn patch_core_network_routing_yaml(
})
}
pub(crate) fn patch_core_network_endpoint_yaml(
content: &str,
config: &GuiConfigFile,
) -> Result<Option<String>, String> {
patch_core_yaml_document(content, |document| {
let original = document.clone();
apply_network_settings(document, config)?;
set_core_yaml_top_level_value(
document,
"proxy-url",
serde_norway::Value::String(config.proxy_url.clone()),
)?;
Ok(*document != original)
})
}
pub(crate) fn patch_core_retry_yaml(
content: &str,
config: &GuiConfigFile,
) -> Result<Option<String>, String> {
patch_core_yaml_document(content, |document| {
let original = document.clone();
set_core_yaml_top_level_value(
document,
"request-retry",
serde_norway::to_value(config.request_retry).map_err(|err| err.to_string())?,
)?;
set_core_yaml_top_level_value(
document,
"max-retry-credentials",
serde_norway::to_value(config.max_retry_credentials).map_err(|err| err.to_string())?,
)?;
set_core_yaml_top_level_value(
document,
"max-retry-interval",
serde_norway::to_value(config.max_retry_interval).map_err(|err| err.to_string())?,
)?;
set_core_yaml_nested_value(
document,
"streaming",
"bootstrap-retries",
serde_norway::to_value(config.streaming_bootstrap_retries)
.map_err(|err| err.to_string())?,
)?;
Ok(*document != original)
})
}
pub(crate) fn patch_core_session_routing_yaml(
content: &str,
config: &GuiConfigFile,
) -> Result<Option<String>, String> {
patch_core_yaml_document(content, |document| {
let original = document.clone();
set_core_yaml_nested_value(
document,
"routing",
"session-affinity",
serde_norway::Value::Bool(config.routing_session_affinity),
)?;
set_core_yaml_nested_value(
document,
"routing",
"session-affinity-ttl",
serde_norway::Value::String(config.routing_session_affinity_ttl.clone()),
)?;
Ok(*document != original)
})
}
pub(crate) fn merge_yaml_values(base: &mut serde_norway::Value, current: serde_norway::Value) {
match (base, current) {
(
@@ -1316,7 +1460,13 @@ pub(crate) fn persist_main_window_size(app: &tauri::AppHandle) -> Result<(), Str
}
pub(crate) fn is_loopback_host(host: &str) -> bool {
matches!(host.trim(), "127.0.0.1" | "localhost" | "::1")
let host = host.trim();
host.eq_ignore_ascii_case("localhost")
|| host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, String> {
+133
View File
@@ -67,6 +67,38 @@ pub(crate) fn patch_core_network_routing_settings(config: &GuiConfigFile) -> Res
write_yaml_if_changed(&config_path, &updated).map(|_| ())
}
fn patch_installed_core_config_with(
patch: impl FnOnce(&str) -> Result<Option<String>, String>,
) -> Result<(), String> {
let _config_guard = lock_core_config_file()?;
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
if !config_path.is_file() {
return Ok(());
}
let content = fs::read_to_string(&config_path).map_err(|err| {
format!(
"Failed to read core configuration {}: {err}",
path_to_string(&config_path)
)
})?;
let Some(updated) = patch(&content)? else {
return Ok(());
};
write_yaml_if_changed(&config_path, &updated).map(|_| ())
}
pub(crate) fn patch_core_network_endpoint_settings(config: &GuiConfigFile) -> Result<(), String> {
patch_installed_core_config_with(|content| patch_core_network_endpoint_yaml(content, config))
}
pub(crate) fn patch_core_retry_settings(config: &GuiConfigFile) -> Result<(), String> {
patch_installed_core_config_with(|content| patch_core_retry_yaml(content, config))
}
pub(crate) fn patch_core_session_routing_settings(config: &GuiConfigFile) -> Result<(), String> {
patch_installed_core_config_with(|content| patch_core_session_routing_yaml(content, config))
}
pub(crate) fn lock_core_config_file() -> Result<std::sync::MutexGuard<'static, ()>, String> {
CORE_CONFIG_FILE_LOCK
.lock()
@@ -91,6 +123,107 @@ pub(crate) fn current_core_config_settings(
}
}
pub(crate) fn current_core_tls_settings() -> Result<CoreTlsSettings, String> {
let _config_guard = lock_core_config_file()?;
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
if !config_path.is_file() {
return Ok(CoreTlsSettings {
enabled: false,
cert: String::new(),
key: String::new(),
});
}
let content = fs::read_to_string(&config_path).map_err(|err| {
format!(
"Failed to read core configuration {}: {err}",
path_to_string(&config_path)
)
})?;
let document = serde_norway::from_str::<serde_norway::Value>(&content)
.map_err(|err| format!("Failed to parse core configuration: {err}"))?;
core_tls_settings_from_value(&document)
}
pub(crate) fn core_tls_settings_from_value(
document: &serde_norway::Value,
) -> Result<CoreTlsSettings, String> {
let root = document
.as_mapping()
.ok_or_else(|| "Core configuration must be a YAML mapping".to_string())?;
let tls = yaml_mapping_value(root, "tls").and_then(serde_norway::Value::as_mapping);
let enabled = tls
.and_then(|mapping| yaml_mapping_value(mapping, "enable"))
.map(|value| {
value
.as_bool()
.ok_or_else(|| "tls.enable must be a boolean".to_string())
})
.transpose()?
.unwrap_or(false);
let string_value = |key: &str| -> Result<String, String> {
tls.and_then(|mapping| yaml_mapping_value(mapping, key))
.map(|value| {
value
.as_str()
.map(str::to_string)
.ok_or_else(|| format!("tls.{key} must be a string"))
})
.transpose()
.map(Option::unwrap_or_default)
};
Ok(CoreTlsSettings {
enabled,
cert: string_value("cert")?,
key: string_value("key")?,
})
}
pub(crate) fn patch_core_tls_settings_yaml(
content: &str,
settings: &CoreTlsSettings,
) -> Result<Option<String>, String> {
patch_core_yaml_document(content, |document| {
let mut changed = false;
changed |= set_core_yaml_nested_value(
document,
"tls",
"enable",
serde_norway::Value::Bool(settings.enabled),
)?;
changed |= set_core_yaml_nested_value(
document,
"tls",
"cert",
serde_norway::Value::String(settings.cert.clone()),
)?;
changed |= set_core_yaml_nested_value(
document,
"tls",
"key",
serde_norway::Value::String(settings.key.clone()),
)?;
Ok(changed)
})
}
pub(crate) fn patch_core_tls_settings(settings: &CoreTlsSettings) -> Result<(), String> {
let _config_guard = lock_core_config_file()?;
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
if !config_path.is_file() {
return Err("Core configuration has not been generated yet".to_string());
}
let content = fs::read_to_string(&config_path).map_err(|err| {
format!(
"Failed to read core configuration {}: {err}",
path_to_string(&config_path)
)
})?;
let Some(updated) = patch_core_tls_settings_yaml(&content, settings)? else {
return Ok(());
};
write_yaml_if_changed(&config_path, &updated).map(|_| ())
}
pub(crate) fn patch_core_api_keys(api_keys: &[String]) -> Result<(), String> {
let _config_guard = lock_core_config_file()?;
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
+15 -2
View File
@@ -837,10 +837,23 @@ pub(crate) fn current_core_status(
}
pub(crate) fn is_management_port_open(port: u16) -> bool {
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
let listen_host = read_installed_core_config_settings()
.map(|settings| settings.host)
.unwrap_or_else(|_| "127.0.0.1".to_string());
let Ok(address) = core_management_address(&listen_host, port) else {
return false;
};
TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok()
}
fn core_management_address(listen_host: &str, port: u16) -> Result<SocketAddr, String> {
let host = core_connect_host(listen_host);
let ip = host
.parse::<IpAddr>()
.map_err(|_| format!("Invalid core listen IP: {listen_host}"))?;
Ok(SocketAddr::new(ip, port))
}
pub(crate) fn start_core_process_inner(
process_state: &CoreProcessState,
gui_config: &GuiConfigFile,
@@ -857,7 +870,7 @@ pub(crate) fn start_core_process_inner(
if process_state.managed_pid().is_some() || is_core_running(&binary_path) {
return Err("CPA 内核已经在运行".to_string());
}
let management_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), gui_config.port);
let management_address = core_management_address(&gui_config.host, gui_config.port)?;
if TcpStream::connect_timeout(&management_address, Duration::from_millis(250)).is_ok() {
return Err(format!(
"端口 {} 已被其他程序占用,请更换端口后重试",
+69
View File
@@ -733,6 +733,7 @@ struct GuiConfigPresence {
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct GuiSettings {
host: String,
port: u16,
allow_lan: bool,
run_on_startup: bool,
@@ -1120,6 +1121,38 @@ struct GuiNetworkRoutingSettings {
streaming_bootstrap_retries: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GuiNetworkEndpointSettings {
host: String,
port: u16,
proxy_url: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GuiRetrySettings {
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
streaming_bootstrap_retries: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GuiSessionRoutingSettings {
routing_session_affinity: bool,
routing_session_affinity_ttl: String,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreTlsSettings {
enabled: bool,
cert: String,
key: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreConfigSettings {
@@ -1158,6 +1191,7 @@ struct CoreApiKeyView {
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreConfigView {
host: String,
api_keys: Vec<CoreApiKeyView>,
management_secret_configured: bool,
port: u16,
@@ -1556,6 +1590,34 @@ impl GuiConfigState {
})
}
fn update_network_endpoint(&self, settings: &GuiConfigFile) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.host = settings.host.clone();
config.allow_lan = settings.allow_lan;
config.port = settings.port;
config.proxy_url = settings.proxy_url.clone();
Ok(())
})
}
fn update_retry_settings(&self, settings: &GuiConfigFile) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.request_retry = settings.request_retry;
config.max_retry_credentials = settings.max_retry_credentials;
config.max_retry_interval = settings.max_retry_interval;
config.streaming_bootstrap_retries = settings.streaming_bootstrap_retries;
Ok(())
})
}
fn update_session_routing(&self, settings: &GuiConfigFile) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.routing_session_affinity = settings.routing_session_affinity;
config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone();
Ok(())
})
}
fn set_run_on_startup(&self, run_on_startup: bool) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.run_on_startup = run_on_startup;
@@ -1670,6 +1732,7 @@ impl GuiConfigState {
impl From<&GuiConfigFile> for GuiSettings {
fn from(config: &GuiConfigFile) -> Self {
Self {
host: config.host.clone(),
port: config.port,
allow_lan: config.allow_lan,
run_on_startup: config.run_on_startup,
@@ -1704,6 +1767,7 @@ impl From<&GuiConfigFile> for CoreConfigSettings {
impl From<&GuiConfigFile> for CoreConfigView {
fn from(config: &GuiConfigFile) -> Self {
Self {
host: config.host.clone(),
api_keys: config
.api_keys
.iter()
@@ -1994,6 +2058,11 @@ fn main() {
get_lan_ipv4,
save_gui_settings,
save_network_routing_settings,
save_network_endpoint_settings,
save_retry_settings,
save_session_routing_settings,
get_core_tls_settings,
save_core_tls_settings,
get_core_config_settings,
add_core_api_key,
update_core_api_key,
+12 -7
View File
@@ -1,9 +1,9 @@
#[cfg(target_os = "windows")]
use super::windows_explorer_executable;
use super::{
auth_dir_path_for_core, configure_background_command, core_install_dir,
is_hashed_management_secret_key, open_oauth_url_inner, path_to_string, truncate_for_error,
GuiConfigFile, GuiConfigState,
auth_dir_path_for_core, configure_background_command, core_install_dir, core_origin,
current_core_tls_settings, is_hashed_management_secret_key, open_oauth_url_inner,
path_to_string, truncate_for_error, GuiConfigFile, GuiConfigState,
};
use serde::{Deserialize, Serialize};
use std::{
@@ -284,6 +284,9 @@ pub(crate) fn management_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
// Management requests target the configured local listener. This keeps
// self-signed/private-CA certificates usable without weakening upstream clients.
.danger_accept_invalid_certs(true)
.build()
.map_err(|err| format!("创建管理 API 客户端失败: {err}"))
}
@@ -301,10 +304,12 @@ pub(crate) fn management_endpoint(config: &GuiConfigFile, path: &str) -> Result<
return Err("内核端口无效".to_string());
}
let path = path.trim_start_matches('/');
Ok(format!(
"http://127.0.0.1:{}/v0/management/{path}",
config.port
))
let origin = core_origin(
&config.host,
config.port,
current_core_tls_settings()?.enabled,
);
Ok(format!("{origin}/v0/management/{path}"))
}
fn normalize_management_oauth_provider(provider: &str) -> Result<String, String> {
+125
View File
@@ -507,6 +507,51 @@ fn confirmed_network_routing_patch_updates_all_fields_together() {
assert!(updated.contains("# unrelated"));
}
#[test]
fn independent_network_modules_only_patch_their_own_fields() {
let config = GuiConfigFile {
host: "192.168.1.20".to_string(),
port: 9527,
proxy_url: "socks5://127.0.0.1:7890".to_string(),
routing_session_affinity: true,
routing_session_affinity_ttl: "2h".to_string(),
request_retry: 7,
max_retry_credentials: 8,
max_retry_interval: 9,
streaming_bootstrap_retries: 10,
..GuiConfigFile::default()
};
let input = "host: 127.0.0.1\nport: 8317\nproxy-url: \"\"\nrouting:\n session-affinity: false\n session-affinity-ttl: 1h\nrequest-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 3\nstreaming:\n bootstrap-retries: 4\n";
let network = patch_core_network_endpoint_yaml(input, &config)
.unwrap()
.expect("network endpoint should change");
let network = serde_norway::from_str::<serde_norway::Value>(&network).unwrap();
assert_eq!(network["host"], "192.168.1.20");
assert_eq!(network["port"], 9527);
assert_eq!(network["proxy-url"], "socks5://127.0.0.1:7890");
assert_eq!(network["request-retry"], 1);
assert_eq!(network["routing"]["session-affinity"], false);
let retry = patch_core_retry_yaml(input, &config)
.unwrap()
.expect("retry settings should change");
let retry = serde_norway::from_str::<serde_norway::Value>(&retry).unwrap();
assert_eq!(retry["request-retry"], 7);
assert_eq!(retry["max-retry-credentials"], 8);
assert_eq!(retry["max-retry-interval"], 9);
assert_eq!(retry["streaming"]["bootstrap-retries"], 10);
assert_eq!(retry["host"], "127.0.0.1");
let routing = patch_core_session_routing_yaml(input, &config)
.unwrap()
.expect("session routing should change");
let routing = serde_norway::from_str::<serde_norway::Value>(&routing).unwrap();
assert_eq!(routing["routing"]["session-affinity"], true);
assert_eq!(routing["routing"]["session-affinity-ttl"], "2h");
assert_eq!(routing["request-retry"], 1);
}
#[test]
fn core_config_controls_preserve_comments_and_unrelated_values() {
let input = "# Client authentication\napi-keys:\n - old-key\n\n# Plugin runtime\nplugins:\n enabled: false # global switch\n dir: plugins\n\n# Credential routing\nrouting:\n strategy: round-robin # current strategy\n session-affinity: true\n\ndebug: true # untouched\n";
@@ -846,6 +891,86 @@ fn optional_core_strings_are_trimmed_and_reject_control_characters() {
assert!(normalize_optional_config_string("bad\nvalue".to_string(), "代理 URL").is_err());
}
#[test]
fn core_tls_settings_read_defaults_and_configured_values() {
let defaults = serde_norway::from_str::<serde_norway::Value>("host: 127.0.0.1\n").unwrap();
let configured = serde_norway::from_str::<serde_norway::Value>(
"tls:\n enable: true\n cert: C:/certs/server.crt\n key: C:/certs/server.key\n",
)
.unwrap();
let defaults = core_tls_settings_from_value(&defaults).unwrap();
assert!(!defaults.enabled);
assert!(defaults.cert.is_empty());
assert!(defaults.key.is_empty());
let configured = core_tls_settings_from_value(&configured).unwrap();
assert!(configured.enabled);
assert_eq!(configured.cert, "C:/certs/server.crt");
assert_eq!(configured.key, "C:/certs/server.key");
}
#[test]
fn tls_patch_preserves_unrelated_yaml_and_paths_when_disabled() {
let input = "# server\nhost: 127.0.0.1\ntls:\n enable: true\n cert: old.crt\n key: old.key\ncustom:\n keep: true\n";
let settings = CoreTlsSettings {
enabled: false,
cert: "new.crt".to_string(),
key: "new.key".to_string(),
};
let patched = patch_core_tls_settings_yaml(input, &settings)
.unwrap()
.expect("TLS settings should change");
let document = serde_norway::from_str::<serde_norway::Value>(&patched).unwrap();
assert!(patched.contains("# server"));
assert_eq!(document["tls"]["enable"], false);
assert_eq!(document["tls"]["cert"], "new.crt");
assert_eq!(document["tls"]["key"], "new.key");
assert_eq!(document["custom"]["keep"], true);
}
#[test]
fn enabled_tls_requires_both_paths() {
let missing_key = CoreTlsSettings {
enabled: true,
cert: "server.crt".to_string(),
key: String::new(),
};
assert!(normalize_core_tls_settings(missing_key).is_err());
let disabled = CoreTlsSettings {
enabled: false,
cert: " server.crt ".to_string(),
key: String::new(),
};
let disabled = normalize_core_tls_settings(disabled).unwrap();
assert_eq!(disabled.cert, "server.crt");
assert!(disabled.key.is_empty());
}
#[test]
fn core_loopback_origin_uses_the_selected_transport() {
assert_eq!(core_loopback_origin(8317, false), "http://127.0.0.1:8317");
assert_eq!(core_loopback_origin(9527, true), "https://127.0.0.1:9527");
assert!(is_managed_agent_base_url("https://127.0.0.1:9527/v1"));
assert!(is_managed_agent_base_url("https://localhost:9527"));
}
#[test]
fn core_origin_uses_connectable_custom_and_ipv6_hosts() {
assert_eq!(
core_origin("192.168.1.20", 9527, true),
"https://192.168.1.20:9527"
);
assert_eq!(core_origin("0.0.0.0", 8317, false), "http://127.0.0.1:8317");
assert_eq!(core_origin("::", 8317, false), "http://[::1]:8317");
assert_eq!(
core_origin("2001:db8::1", 8317, true),
"https://[2001:db8::1]:8317"
);
}
#[test]
fn configured_proxy_supports_http_and_socks5_urls() {
for proxy_url in ["http://127.0.0.1:8080", "socks5://127.0.0.1:7890"] {