diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 29e0054..54c4c62 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -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"), diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index d6a8b6c..e917ce6 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -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 => { diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index e5d71c7..8db1c89 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -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| { diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index f0febf7..a09b689 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -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( diff --git a/src-tauri/src/core_config/commands.rs b/src-tauri/src/core_config/commands.rs index 4820b8d..580fd06 100644 --- a/src-tauri/src/core_config/commands.rs +++ b/src-tauri/src/core_config/commands.rs @@ -5,6 +5,22 @@ pub(crate) fn get_lan_ipv4() -> Option { detect_lan_ipv4().map(|address| address.to_string()) } +#[tauri::command] +pub(crate) fn get_core_tls_settings() -> Result { + current_core_tls_settings() +} + +#[tauri::command] +pub(crate) fn save_core_tls_settings( + cache: tauri::State<'_, AgentConfigStatusCache>, + settings: CoreTlsSettings, +) -> Result { + let settings = normalize_core_tls_settings(settings)?; + patch_core_tls_settings(&settings)?; + cache.clear()?; + Ok(settings) +} + pub(crate) fn detect_lan_ipv4() -> Option { 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 { + 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::().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 { + 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 { + 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>, diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 9af0772..c1e458c 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -163,6 +163,80 @@ pub(crate) fn normalize_optional_config_string( Ok(value) } +pub(crate) fn normalize_core_tls_settings( + settings: CoreTlsSettings, +) -> Result { + 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, @@ -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, 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, 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, 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::() + .is_ok_and(|address| address.is_loopback()) } pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 855d6c1..1964139 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -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, 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, 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 { + 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::(&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 { + 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 { + 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, 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); diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 54b3274..5f80e3e 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -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 { + let host = core_connect_host(listen_host); + let ip = host + .parse::() + .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!( "端口 {} 已被其他程序占用,请更换端口后重试", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b963155..ef9b58f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, management_secret_configured: bool, port: u16, @@ -1556,6 +1590,34 @@ impl GuiConfigState { }) } + fn update_network_endpoint(&self, settings: &GuiConfigFile) -> Result { + 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 { + 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 { + 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 { 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, diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index ea862f3..21173ae 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -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::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 { diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index b264865..c671799 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -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::(&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::(&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::(&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::("host: 127.0.0.1\n").unwrap(); + let configured = serde_norway::from_str::( + "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::(&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"] { diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 83cf406..04a8632 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -273,13 +273,15 @@ export const jaOverrides = { 'config.routing.fillFirst': '優先充填', 'config.tabs.label': '詳細設定ページ', 'config.tabs.general': '基本設定', - 'config.tabs.network': 'ネットワークとルーティング', + 'config.tabs.network': 'ネットワーク設定', + 'config.tabs.routing': 'セッションルーティング', 'config.tabs.software': 'ソフトウェア設定', 'config.error.emptyKey': '認証キーを入力してください', 'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます', 'config.error.duplicateKey': '同じ認証キーがすでに存在します', 'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください', 'config.error.portRange': 'ポートは 1〜65535 の整数で入力してください', + 'config.error.hostRequired': '有効なリッスン IP アドレスを入力してください', 'config.error.retryRange': '再試行設定は 0〜4294967295 の整数で入力してください', 'config.error.networkRestartFailed': '設定は保存されましたが、コアの再起動に失敗しました:{error}', 'config.error.saveFailed': '保存に失敗しました:{error}', @@ -290,6 +292,8 @@ export const jaOverrides = { 'config.notice.keyCopyFailed': '認証キーをコピーできませんでした', 'config.notice.routingUpdated': 'ルーティング方式を更新しました', 'config.notice.networkUpdated': 'ネットワークとセッションルーティング設定を保存しました', + 'config.notice.retryUpdated': '失敗時の再試行設定を保存しました', + 'config.notice.sessionRoutingUpdated': 'セッションルーティング設定を保存しました', 'config.notice.networkRestarted': 'ネットワーク設定を保存し、コアを再起動しました', 'config.notice.networkNextStart': 'ネットワーク設定を保存しました。次回のコア起動時に反映されます', 'config.notice.proxyUpdated': 'プロキシ URL を保存しました', @@ -334,7 +338,7 @@ export const jaOverrides = { 'common.enabled': '有効', 'common.disabled': '無効', 'config.routing.title': 'ルーティング方式', - 'config.network.title': 'ネットワークとセッションルーティング', + 'config.network.title': 'ネットワーク設定', 'config.network.networkSection': 'ネットワーク設定', 'config.network.routingSection': 'セッションルーティング', 'config.network.retrySection': '失敗時の再試行', @@ -344,8 +348,8 @@ export const jaOverrides = { 'config.network.confirmSave': '確認して保存', 'config.network.port': 'ポート', 'config.network.portHint': '範囲は 1〜65535。コア実行中の変更時は自動的に再起動します', - 'config.network.allowLan': 'LAN アクセスを許可', - 'config.network.allowLanHint': 'LAN 内の他のデバイスからこのプロキシへのアクセスを許可します', + 'config.network.listenHost': 'カスタムリッスン IP', + 'config.network.listenHostHint': '例: 127.0.0.1、0.0.0.0、または LAN IP。変更すると実行中のコアが再起動します', 'config.network.proxyUrl': 'プロキシ URL', 'config.network.proxyPlaceholder': '例:socks5://127.0.0.1:7890', 'config.network.proxyHint': 'SOCKS プロトコルと HTTP プロトコルに対応しています', @@ -362,6 +366,24 @@ export const jaOverrides = { 'config.network.streamingBootstrapRetries': 'ストリーミング開始時の再試行回数', 'config.network.streamingBootstrapRetriesHint': '最初の応答を送信する前に許可する安全な再試行回数です。0 で無効になります', 'config.notice.softwareUpdated': 'ソフトウェア設定を保存しました', + 'config.tls.enable': 'TLS を有効にする', + 'config.tls.enableDescription': '有効な PEM 証明書と秘密鍵を使用して API と WebUI を HTTPS で提供します', + 'config.tls.cert': '証明書のパス', + 'config.tls.certPlaceholder': '例: C:\\certs\\server.crt または /path/to/cert.pem', + 'config.tls.certHint': 'コアから読み取り可能な PEM 証明書ファイルのパス', + 'config.tls.key': '秘密鍵のパス', + 'config.tls.keyPlaceholder': '例: C:\\certs\\server.key または /path/to/key.pem', + 'config.tls.keyHint': '証明書と一致する PEM 秘密鍵ファイルのパス', + 'config.tls.browse': 'ファイルを選択', + 'config.tls.selectCertTitle': 'TLS 証明書ファイルを選択', + 'config.tls.selectKeyTitle': 'TLS 秘密鍵ファイルを選択', + 'config.tls.certFile': 'TLS 証明書', + 'config.tls.keyFile': 'TLS 秘密鍵', + 'config.tls.error.selectFileFailed': 'ファイルを選択できませんでした: {error}', + 'config.tls.restartHint': 'TLS 設定を保存すると、実行中のコアは自動的に再起動します。', + 'config.tls.error.pathsRequired': 'TLS を有効にする前に証明書と秘密鍵のパスを入力してください', + 'config.tls.notice.saved': 'TLS 設定を保存しました。次回のコア起動時に反映されます', + 'config.tls.notice.savedAndRestarted': 'TLS 設定を保存し、コアを再起動しました', 'config.software.title': 'ソフトウェア設定', 'config.software.autostart': 'システム起動時に開始', 'config.software.autostartDescription': 'サインイン後に EasyCLIProxyAPI を自動的に起動します', @@ -928,6 +950,9 @@ export const jaOverrides = { 'kernel.access.localUrl': 'ローカル URL', 'kernel.access.localCopied': '{name} のローカル URL をコピーしました', 'kernel.access.copyLocal': '{name} のローカル URL をコピー', + 'kernel.access.apiUrl': 'API URL', + 'kernel.access.apiCopied': '{name} の API URL をコピーしました', + 'kernel.access.copyApi': '{name} の API URL をコピー', 'kernel.access.lanUrl': 'LAN URL', 'kernel.access.detectingIp': 'LAN IP を検出しています', 'kernel.access.noIp': 'LAN IP を検出できません', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f455ed4..bad90cd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -273,13 +273,15 @@ export const en: Record = { 'config.routing.fillFirst': 'Fill First', 'config.tabs.label': 'Advanced settings sections', 'config.tabs.general': 'General Settings', - 'config.tabs.network': 'Network & Routing', + 'config.tabs.network': 'Network Settings', + 'config.tabs.routing': 'Session Routing', 'config.tabs.software': 'Software Settings', 'config.error.emptyKey': 'The authentication key cannot be empty', 'config.error.invalidKey': 'Use visible ASCII characters only, without spaces', 'config.error.duplicateKey': 'This authentication key already exists', 'config.error.remarkTooLong': 'The remark cannot exceed 80 characters', 'config.error.portRange': 'The port must be an integer from 1 to 65535', + 'config.error.hostRequired': 'Enter a valid listen IP address', 'config.error.retryRange': 'Retry settings must be integers from 0 to 4294967295', 'config.error.networkRestartFailed': 'Settings were saved, but the core failed to restart: {error}', 'config.error.saveFailed': 'Failed to save: {error}', @@ -290,6 +292,8 @@ export const en: Record = { 'config.notice.keyCopyFailed': 'Failed to copy the authentication key', 'config.notice.routingUpdated': 'Routing strategy updated', 'config.notice.networkUpdated': 'Network and session routing settings saved', + 'config.notice.retryUpdated': 'Failure retry settings saved', + 'config.notice.sessionRoutingUpdated': 'Session routing settings saved', 'config.notice.networkRestarted': 'Network settings saved and the core restarted', 'config.notice.networkNextStart': 'Network settings saved and will apply on the next core start', 'config.notice.proxyUpdated': 'Proxy URL saved', @@ -297,6 +301,24 @@ export const en: Record = { 'config.notice.sessionAffinityDisabled': 'Session affinity disabled', 'config.notice.sessionTtlUpdated': 'Session affinity TTL saved', 'config.notice.softwareUpdated': 'Software settings saved', + 'config.tls.enable': 'Enable TLS', + 'config.tls.enableDescription': 'Serve the API and WebUI over HTTPS using a valid PEM certificate and private key', + 'config.tls.cert': 'Certificate Path', + 'config.tls.certPlaceholder': 'For example, C:\\certs\\server.crt or /path/to/cert.pem', + 'config.tls.certHint': 'Path to a PEM certificate file readable by the core', + 'config.tls.key': 'Private Key Path', + 'config.tls.keyPlaceholder': 'For example, C:\\certs\\server.key or /path/to/key.pem', + 'config.tls.keyHint': 'Path to the matching PEM private key file', + 'config.tls.browse': 'Choose File', + 'config.tls.selectCertTitle': 'Select TLS certificate file', + 'config.tls.selectKeyTitle': 'Select TLS private key file', + 'config.tls.certFile': 'TLS Certificate', + 'config.tls.keyFile': 'TLS Private Key', + 'config.tls.error.selectFileFailed': 'Failed to select file: {error}', + 'config.tls.restartHint': 'Saving TLS settings automatically restarts a running core.', + 'config.tls.error.pathsRequired': 'Certificate and private key paths are required before enabling TLS', + 'config.tls.notice.saved': 'TLS settings saved and will apply on the next core start', + 'config.tls.notice.savedAndRestarted': 'TLS settings saved and the core restarted', 'config.software.title': 'Software Settings', 'config.software.autostart': 'Launch at startup', 'config.software.autostartDescription': 'Automatically launch EasyCLIProxyAPI when you sign in', @@ -347,7 +369,7 @@ export const en: Record = { 'common.enabled': 'Enabled', 'common.disabled': 'Disabled', 'config.routing.title': 'Routing Strategy', - 'config.network.title': 'Network & Session Routing', + 'config.network.title': 'Network Settings', 'config.network.networkSection': 'Network Settings', 'config.network.routingSection': 'Session Routing', 'config.network.retrySection': 'Failure Retries', @@ -357,8 +379,8 @@ export const en: Record = { 'config.network.confirmSave': 'Confirm & Save', 'config.network.port': 'Port', 'config.network.portHint': 'Range 1–65535; changing it restarts a running core', - 'config.network.allowLan': 'Allow LAN', - 'config.network.allowLanHint': 'Allow other devices on the LAN to access this proxy', + 'config.network.listenHost': 'Custom Listen IP', + 'config.network.listenHostHint': 'For example, 127.0.0.1, 0.0.0.0, or a LAN IP; changing it restarts a running core', 'config.network.proxyUrl': 'Proxy URL', 'config.network.proxyPlaceholder': 'For example, socks5://127.0.0.1:7890', 'config.network.proxyHint': 'Supports SOCKS and HTTP protocols', @@ -928,6 +950,9 @@ export const en: Record = { 'kernel.access.localUrl': 'Local URL', 'kernel.access.localCopied': '{name} local URL copied', 'kernel.access.copyLocal': 'Copy {name} local URL', + 'kernel.access.apiUrl': 'API URL', + 'kernel.access.apiCopied': '{name} API URL copied', + 'kernel.access.copyApi': 'Copy {name} API URL', 'kernel.access.lanUrl': 'LAN URL', 'kernel.access.detectingIp': 'Detecting LAN IP', 'kernel.access.noIp': 'No LAN IP detected', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index f3582f8..f94481a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -271,13 +271,15 @@ export const zhCN = { 'config.routing.fillFirst': '优先填充', 'config.tabs.label': '高级设置子页面', 'config.tabs.general': '基础设置', - 'config.tabs.network': '网络与路由', + 'config.tabs.network': '网络设置', + 'config.tabs.routing': '会话路由', 'config.tabs.software': '软件设置', 'config.error.emptyKey': '鉴权密钥不能为空', 'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格', 'config.error.duplicateKey': '该鉴权密钥已经存在', 'config.error.remarkTooLong': '备注不能超过 80 个字符', 'config.error.portRange': '端口必须是 1–65535 之间的整数', + 'config.error.hostRequired': '请输入有效的监听 IP 地址', 'config.error.retryRange': '重试设置必须是 0–4294967295 之间的整数', 'config.error.networkRestartFailed': '设置已保存,但内核重启失败:{error}', 'config.error.saveFailed': '保存失败:{error}', @@ -288,6 +290,8 @@ export const zhCN = { 'config.notice.keyCopyFailed': '复制鉴权密钥失败', 'config.notice.routingUpdated': '路由策略已更新', 'config.notice.networkUpdated': '网络与会话路由设置已保存', + 'config.notice.retryUpdated': '失败重试设置已保存', + 'config.notice.sessionRoutingUpdated': '会话路由设置已保存', 'config.notice.networkRestarted': '网络设置已保存,内核已重启', 'config.notice.networkNextStart': '网络设置已保存,将在下次启动内核时生效', 'config.notice.proxyUpdated': '代理 URL 已保存', @@ -295,6 +299,24 @@ export const zhCN = { 'config.notice.sessionAffinityDisabled': '会话粘性路由已停用', 'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存', 'config.notice.softwareUpdated': '软件设置已保存', + 'config.tls.enable': '启用 TLS', + 'config.tls.enableDescription': '使用 HTTPS 提供 API 和 WebUI;需要有效的 PEM 证书与私钥', + 'config.tls.cert': '证书路径', + 'config.tls.certPlaceholder': '例如 C:\\certs\\server.crt 或 /path/to/cert.pem', + 'config.tls.certHint': '填写内核可读取的 PEM 证书文件路径', + 'config.tls.key': '私钥路径', + 'config.tls.keyPlaceholder': '例如 C:\\certs\\server.key 或 /path/to/key.pem', + 'config.tls.keyHint': '填写与证书匹配的 PEM 私钥文件路径', + 'config.tls.browse': '选择文件', + 'config.tls.selectCertTitle': '选择 TLS 证书文件', + 'config.tls.selectKeyTitle': '选择 TLS 私钥文件', + 'config.tls.certFile': 'TLS 证书', + 'config.tls.keyFile': 'TLS 私钥', + 'config.tls.error.selectFileFailed': '选择文件失败:{error}', + 'config.tls.restartHint': '保存 TLS 设置后,正在运行的内核会自动重启。', + 'config.tls.error.pathsRequired': '启用 TLS 前必须填写证书和私钥路径', + 'config.tls.notice.saved': 'TLS 设置已保存,将在下次启动内核时生效', + 'config.tls.notice.savedAndRestarted': 'TLS 设置已保存,内核已重启', 'config.software.title': '软件设置', 'config.software.autostart': '开机自启', 'config.software.autostartDescription': '登录系统后自动启动 EasyCLIProxyAPI', @@ -345,7 +367,7 @@ export const zhCN = { 'common.enabled': '已启用', 'common.disabled': '已停用', 'config.routing.title': '路由策略', - 'config.network.title': '网络与会话路由', + 'config.network.title': '网络设置', 'config.network.networkSection': '网络设置', 'config.network.routingSection': '会话路由', 'config.network.retrySection': '失败重试', @@ -355,8 +377,8 @@ export const zhCN = { 'config.network.confirmSave': '确认保存', 'config.network.port': '端口', 'config.network.portHint': '范围 1–65535;内核运行时修改会自动重启', - 'config.network.allowLan': '允许局域网', - 'config.network.allowLanHint': '启用后将监听0.0.0.0', + 'config.network.listenHost': '自定义监听 IP', + 'config.network.listenHostHint': '例如 127.0.0.1、0.0.0.0 或局域网 IP;修改后会重启运行中的内核', 'config.network.proxyUrl': '代理 URL', 'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890', 'config.network.proxyHint': '支持socks协议与http协议', @@ -926,6 +948,9 @@ export const zhCN = { 'kernel.access.localUrl': '本机 URL', 'kernel.access.localCopied': '{name} 本机 URL 已复制', 'kernel.access.copyLocal': '复制 {name} 本机 URL', + 'kernel.access.apiUrl': 'API URL', + 'kernel.access.apiCopied': '{name} API URL 已复制', + 'kernel.access.copyApi': '复制 {name} API URL', 'kernel.access.lanUrl': '局域网 URL', 'kernel.access.detectingIp': '正在检测局域网 IP', 'kernel.access.noIp': '未检测到局域网 IP', diff --git a/src/navigation.ts b/src/navigation.ts index df48fcc..d13d9fc 100644 --- a/src/navigation.ts +++ b/src/navigation.ts @@ -1,4 +1,4 @@ -const alwaysAvailablePages = new Set(['home', 'versions', 'config']); +const alwaysAvailablePages = new Set(['home', 'versions', 'config', 'usage-records']); export function isAlwaysAvailablePage(pageId: string) { return alwaysAvailablePages.has(pageId); diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index 4acf9e2..cf258df 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -1,6 +1,7 @@ import { FormEvent, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; +import { open } from '@tauri-apps/plugin-dialog'; import { AlertCircle, Check, @@ -8,8 +9,10 @@ import { Clock3, Eye, EyeOff, + FolderOpen, KeyRound, Link2, + LockKeyhole, Network, Pencil, Plus, @@ -30,6 +33,7 @@ import { ThinkingAliasesPage } from './ThinkingAliasesPage'; type CoreConfigSettings = { apiKeys: CoreApiKey[]; managementSecretConfigured: boolean; + host: string; port: number; allowLan: boolean; routingStrategy: string; @@ -54,14 +58,16 @@ type ConfigAction = | 'management-secret' | 'routing' | 'network' + | 'retry' + | 'tls' | 'software' | null; type NoticeTone = 'success' | 'error'; -type ConfigSubpage = 'general' | 'network' | 'software' | 'aliases'; +type ConfigSubpage = 'general' | 'network' | 'routing' | 'software' | 'aliases'; type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray'; type NetworkDraftField = | 'port' - | 'allowLan' + | 'host' | 'proxyUrl' | 'sessionAffinity' | 'sessionTtl' @@ -80,9 +86,15 @@ type SoftwareSettings = { silentStartEnabled: boolean; }; +type CoreTlsSettings = { + enabled: boolean; + cert: string; + key: string; +}; + const cleanNetworkDraft = (): NetworkDraftDirty => ({ port: false, - allowLan: false, + host: false, proxyUrl: false, sessionAffinity: false, sessionTtl: false, @@ -108,6 +120,14 @@ export function ConfigPanelPage() { const [softwareStartCoreDraft, setSoftwareStartCoreDraft] = useState(true); const [softwareSilentStartDraft, setSoftwareSilentStartDraft] = useState(false); const [softwareSavedStatusVisible, setSoftwareSavedStatusVisible] = useState(false); + const [tlsSettings, setTlsSettings] = useState(null); + const [tlsSettingsLoading, setTlsSettingsLoading] = useState(true); + const [tlsEnabledDraft, setTlsEnabledDraft] = useState(false); + const [tlsCertDraft, setTlsCertDraft] = useState(''); + const [tlsKeyDraft, setTlsKeyDraft] = useState(''); + const [tlsError, setTlsError] = useState(''); + const [tlsFileSelecting, setTlsFileSelecting] = useState<'cert' | 'key' | null>(null); + const [tlsSavedStatusVisible, setTlsSavedStatusVisible] = useState(false); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [busyAction, setBusyAction] = useState(null); @@ -126,7 +146,7 @@ export function ConfigPanelPage() { const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null); const [activeSubpage, setActiveSubpage] = useState('general'); const [portDraft, setPortDraft] = useState('8317'); - const [allowLanDraft, setAllowLanDraft] = useState(false); + const [hostDraft, setHostDraft] = useState('127.0.0.1'); const [proxyUrlDraft, setProxyUrlDraft] = useState(''); const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false); const [sessionTtlDraft, setSessionTtlDraft] = useState(''); @@ -135,8 +155,8 @@ export function ConfigPanelPage() { const [maxRetryIntervalDraft, setMaxRetryIntervalDraft] = useState('30'); const [streamingBootstrapRetriesDraft, setStreamingBootstrapRetriesDraft] = useState('0'); const [portError, setPortError] = useState(''); + const [hostError, setHostError] = useState(''); const [retryError, setRetryError] = useState(''); - const [savedStatusVisible, setSavedStatusVisible] = useState(false); const networkDraftDirtyRef = useRef(cleanNetworkDraft()); const noticeTimerRef = useRef(null); const copyTimerRef = useRef(null); @@ -146,10 +166,12 @@ export function ConfigPanelPage() { let stop: (() => void) | null = null; void loadSettings(); void loadSoftwareSettings(); + void loadTlsSettings(); void listen('config-files-changed', () => { if (!disposed) { void loadSettings('preserve'); void loadSoftwareSettings(); + void loadTlsSettings(); } }).then((unlisten) => { if (disposed) unlisten(); @@ -183,7 +205,7 @@ export function ConfigPanelPage() { if (mode === 'preserve') { const dirty = networkDraftDirtyRef.current; if (!dirty.port) setPortDraft(String(result.port)); - if (!dirty.allowLan) setAllowLanDraft(result.allowLan); + if (!dirty.host) setHostDraft(result.host); if (!dirty.proxyUrl) setProxyUrlDraft(result.proxyUrl); if (!dirty.sessionAffinity) setSessionAffinityDraft(result.routingSessionAffinity); if (!dirty.sessionTtl) setSessionTtlDraft(result.routingSessionAffinityTtl); @@ -197,7 +219,7 @@ export function ConfigPanelPage() { } networkDraftDirtyRef.current = cleanNetworkDraft(); setPortDraft(String(result.port)); - setAllowLanDraft(result.allowLan); + setHostDraft(result.host); setProxyUrlDraft(result.proxyUrl); setSessionAffinityDraft(result.routingSessionAffinity); setSessionTtlDraft(result.routingSessionAffinityTtl); @@ -206,12 +228,12 @@ export function ConfigPanelPage() { setMaxRetryIntervalDraft(String(result.maxRetryInterval)); setStreamingBootstrapRetriesDraft(String(result.streamingBootstrapRetries)); setPortError(''); + setHostError(''); setRetryError(''); }; const markDraftDirty = (field: NetworkDraftField) => { networkDraftDirtyRef.current[field] = true; - setSavedStatusVisible(false); }; const clearDraftDirty = (field: NetworkDraftField) => { @@ -250,6 +272,23 @@ export function ConfigPanelPage() { } } + async function loadTlsSettings() { + setTlsSettingsLoading(true); + try { + const result = await invoke('get_core_tls_settings'); + setTlsSettings(result); + setTlsEnabledDraft(result.enabled); + setTlsCertDraft(result.cert); + setTlsKeyDraft(result.key); + setTlsError(''); + } catch (error) { + setTlsSettings(null); + setTlsError(String(error)); + } finally { + setTlsSettingsLoading(false); + } + } + const runMutation = async ( action: Exclude, command: string, @@ -428,32 +467,110 @@ export function ConfigPanelPage() { const openWebUi = async () => { try { - const latestSettings = await invoke('get_core_config_settings'); + const [latestSettings, latestTlsSettings] = await Promise.all([ + invoke('get_core_config_settings'), + invoke('get_core_tls_settings'), + ]); applySettings(latestSettings, 'preserve'); + setTlsSettings(latestTlsSettings); + setTlsEnabledDraft(latestTlsSettings.enabled); + setTlsCertDraft(latestTlsSettings.cert); + setTlsKeyDraft(latestTlsSettings.key); await invoke('open_external_url', { - url: webUiManagementUrl(latestSettings.port), + url: webUiManagementUrl(latestSettings.port, latestTlsSettings.enabled, latestSettings.host), }); } catch (error) { showNotice(t('config.webuiKey.error.openFailed', { error: String(error) }), 'error'); } }; + const saveTlsSettings = async () => { + if (tlsSettings === null || busyAction !== null) return; + const cert = tlsCertDraft.trim(); + const key = tlsKeyDraft.trim(); + if (tlsEnabledDraft && (!cert || !key)) { + setTlsError(t('config.tls.error.pathsRequired')); + return; + } + + setBusyAction('tls'); + setTlsError(''); + setTlsSavedStatusVisible(false); + try { + const result = await invoke('save_core_tls_settings', { + settings: { enabled: tlsEnabledDraft, cert, key }, + }); + setTlsSettings(result); + setTlsEnabledDraft(result.enabled); + setTlsCertDraft(result.cert); + setTlsKeyDraft(result.key); + if (coreStatus?.running) { + const status = await invoke('restart_core_process'); + publishStatus(status); + showNotice(t('config.tls.notice.savedAndRestarted'), 'success'); + } else { + showNotice(t('config.tls.notice.saved'), 'success'); + } + setTlsSavedStatusVisible(true); + } catch (error) { + setTlsError(String(error)); + showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); + void refreshStatus(); + void loadTlsSettings(); + } finally { + setBusyAction(null); + } + }; + + const selectTlsFile = async (target: 'cert' | 'key') => { + if (tlsSettings === null || busyAction !== null || tlsFileSelecting !== null) return; + setTlsFileSelecting(target); + setTlsError(''); + try { + const selected = await open({ + multiple: false, + directory: false, + title: target === 'cert' + ? t('config.tls.selectCertTitle') + : t('config.tls.selectKeyTitle'), + filters: [{ + name: target === 'cert' ? t('config.tls.certFile') : t('config.tls.keyFile'), + extensions: target === 'cert' ? ['pem', 'crt', 'cer'] : ['pem', 'key'], + }], + }); + if (typeof selected !== 'string') return; + setTlsSavedStatusVisible(false); + if (target === 'cert') setTlsCertDraft(selected); + else setTlsKeyDraft(selected); + } catch (error) { + const message = t('config.tls.error.selectFileFailed', { error: String(error) }); + setTlsError(message); + showNotice(message, 'error'); + } finally { + setTlsFileSelecting(null); + } + }; + const changeRoutingStrategy = async (strategy: string) => { if (strategy === settings?.routingStrategy) { return; } - setSavedStatusVisible(false); - const saved = await runMutation( + await runMutation( 'routing', 'set_core_routing_strategy', { strategy }, t('config.notice.routingUpdated'), ); - if (saved) setSavedStatusVisible(true); }; - const saveNetworkRoutingSettings = async () => { + const saveNetworkEndpointSettings = async () => { if (!settings || busyAction !== null) return; + const host = hostDraft.trim(); + if (!host) { + setHostError(t('config.error.hostRequired')); + showNotice(t('config.error.hostRequired'), 'error'); + return; + } const port = Number(portDraft); if (!Number.isInteger(port) || port < 1 || port > 65535) { setPortError(t('config.error.portRange')); @@ -462,45 +579,19 @@ export function ConfigPanelPage() { } const proxyUrl = proxyUrlDraft.trim(); - const routingSessionAffinityTtl = sessionTtlDraft.trim(); - const retryDrafts = [ - requestRetryDraft, - maxRetryCredentialsDraft, - maxRetryIntervalDraft, - streamingBootstrapRetriesDraft, - ]; - const retryValues = retryDrafts.map(Number); - if ( - retryDrafts.some((value) => value.length === 0) - || retryValues.some((value) => !Number.isInteger(value) || value < 0 || value > 4294967295) - ) { - setRetryError(t('config.error.retryRange')); - showNotice(t('config.error.retryRange'), 'error'); - return; - } - const [requestRetry, maxRetryCredentials, maxRetryInterval, streamingBootstrapRetries] = retryValues; - const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan; + const networkChanged = port !== settings.port || host !== settings.host; + setHostError(''); setPortError(''); - setRetryError(''); - setSavedStatusVisible(false); setBusyAction('network'); try { - const result = await invoke('save_network_routing_settings', { - settings: { - port, - allowLan: allowLanDraft, - proxyUrl, - routingSessionAffinity: sessionAffinityDraft, - routingSessionAffinityTtl, - requestRetry, - maxRetryCredentials, - maxRetryInterval, - streamingBootstrapRetries, - }, + const result = await invoke('save_network_endpoint_settings', { + settings: { host, port, proxyUrl }, }); - applySettings(result); + clearDraftDirty('host'); + clearDraftDirty('port'); + clearDraftDirty('proxyUrl'); + applySettings(result, 'preserve'); setLoadError(''); - setSavedStatusVisible(true); if (networkChanged && coreStatus?.running) { try { @@ -517,9 +608,76 @@ export function ConfigPanelPage() { showNotice(t('config.notice.networkUpdated'), 'success'); } } catch (error) { - applySettings(settings); showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); - void loadSettings(); + void loadSettings('preserve'); + } finally { + setBusyAction(null); + } + }; + + const saveRetrySettings = async () => { + if (!settings || busyAction !== null) return; + const retryDrafts = [ + requestRetryDraft, + maxRetryCredentialsDraft, + maxRetryIntervalDraft, + streamingBootstrapRetriesDraft, + ]; + const retryValues = retryDrafts.map(Number); + if ( + retryDrafts.some((value) => value.length === 0) + || retryValues.some((value) => !Number.isInteger(value) || value < 0 || value > 4294967295) + ) { + setRetryError(t('config.error.retryRange')); + showNotice(t('config.error.retryRange'), 'error'); + return; + } + const [requestRetry, maxRetryCredentials, maxRetryInterval, streamingBootstrapRetries] = retryValues; + setRetryError(''); + setBusyAction('retry'); + try { + const result = await invoke('save_retry_settings', { + settings: { + requestRetry, + maxRetryCredentials, + maxRetryInterval, + streamingBootstrapRetries, + }, + }); + clearDraftDirty('requestRetry'); + clearDraftDirty('maxRetryCredentials'); + clearDraftDirty('maxRetryInterval'); + clearDraftDirty('streamingBootstrapRetries'); + applySettings(result, 'preserve'); + setLoadError(''); + showNotice(t('config.notice.retryUpdated'), 'success'); + } catch (error) { + showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); + void loadSettings('preserve'); + } finally { + setBusyAction(null); + } + }; + + const saveSessionRoutingSettings = async () => { + if (!settings || busyAction !== null) return; + const routingSessionAffinityTtl = sessionTtlDraft.trim(); + setBusyAction('routing'); + try { + const result = await invoke('save_session_routing_settings', { + settings: { + routingSessionAffinity: sessionAffinityDraft, + routingSessionAffinityTtl, + }, + }); + clearDraftDirty('sessionAffinity'); + clearDraftDirty('sessionTtl'); + applySettings(result, 'preserve'); + setLoadError(''); + showNotice(t('config.notice.sessionRoutingUpdated'), 'success'); + } catch (error) { + showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); + void loadSettings('preserve'); } finally { setBusyAction(null); } @@ -565,28 +723,21 @@ export function ConfigPanelPage() { }; const controlsDisabled = loading || settings === null || busyAction !== null; - const networkRoutingDirty = Boolean(settings) && ( - portDraft !== String(settings?.port) - || allowLanDraft !== settings?.allowLan + const networkSettingsDirty = Boolean(settings) && ( + hostDraft.trim() !== settings?.host + || portDraft !== String(settings?.port) || proxyUrlDraft.trim() !== settings?.proxyUrl - || sessionAffinityDraft !== settings?.routingSessionAffinity + ); + const sessionRoutingDirty = Boolean(settings) && ( + sessionAffinityDraft !== settings?.routingSessionAffinity || sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl - || requestRetryDraft !== String(settings?.requestRetry) + ); + const retrySettingsDirty = Boolean(settings) && ( + requestRetryDraft !== String(settings?.requestRetry) || maxRetryCredentialsDraft !== String(settings?.maxRetryCredentials) || maxRetryIntervalDraft !== String(settings?.maxRetryInterval) || streamingBootstrapRetriesDraft !== String(settings?.streamingBootstrapRetries) ); - const networkStatusLabel = loading - ? t('common.loading') - : settings === null - ? t('common.unavailable') - : busyAction === 'network' || busyAction === 'routing' - ? t('config.network.saving') - : networkRoutingDirty - ? t('config.network.unsaved') - : savedStatusVisible - ? t('config.network.saved') - : ''; const softwareCloseBehaviorDirty = softwareSettings !== null && softwareCloseBehaviorDraft !== softwareSettings.closeBehavior; const softwareAutostartDirty = softwareSettings !== null @@ -612,11 +763,24 @@ export function ConfigPanelPage() { && softwareSettings !== null && !softwareSettingsDirty && softwareSavedStatusVisible; - const networkStatusIsSaved = !loading - && settings !== null - && busyAction === null - && !networkRoutingDirty - && savedStatusVisible; + const tlsSettingsDirty = tlsSettings !== null && ( + tlsEnabledDraft !== tlsSettings.enabled + || tlsCertDraft.trim() !== tlsSettings.cert + || tlsKeyDraft.trim() !== tlsSettings.key + ); + const tlsStatusLabel = tlsSettingsLoading + ? t('common.loading') + : tlsSettings === null + ? t('common.unavailable') + : tlsSettingsDirty + ? t('config.network.unsaved') + : tlsSavedStatusVisible + ? t('config.network.saved') + : ''; + const tlsStatusIsSaved = !tlsSettingsLoading + && tlsSettings !== null + && !tlsSettingsDirty + && tlsSavedStatusVisible; const selectedDeleteKey = deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || ''; const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1; @@ -650,6 +814,18 @@ export function ConfigPanelPage() { > {t('config.tabs.network')} + @@ -904,44 +1080,34 @@ export function ConfigPanelPage() { - ) : activeSubpage === 'network' ? ( + ) : activeSubpage === 'network' || activeSubpage === 'routing' ? (
-
-
-
-
-
- {networkStatusLabel ? ( - - {networkStatusLabel} - - ) : null} - -
-
- +
-
-
-
) : activeSubpage === 'software' ? (
; }; +type CoreTlsSettings = { + enabled: boolean; + cert: string; + key: string; +}; + const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest'; let latestAutoCheckStarted = false; @@ -126,7 +132,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { const [bundledCore, setBundledCore] = useState(null); const [bundledCoreError, setBundledCoreError] = useState(''); const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise)); - const [allowLanAccess, setAllowLanAccess] = useState(false); + const [listenHost, setListenHost] = useState('127.0.0.1'); const [customPort, setCustomPort] = useState('8317'); const [installing, setInstalling] = useState(false); const [processBusy, setProcessBusy] = useState(false); @@ -146,8 +152,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { const [homeApiKey, setHomeApiKey] = useState(undefined); const [homeApiKeyError, setHomeApiKeyError] = useState(false); const [showHomeApiKey, setShowHomeApiKey] = useState(false); - const [lanIpv4, setLanIpv4] = useState(null); - const [lanIpChecked, setLanIpChecked] = useState(false); + const [tlsEnabled, setTlsEnabled] = useState(false); const installDialogRef = useRef(null); const savedPortRef = useRef(8317); const processNoticeTimerRef = useRef(null); @@ -223,6 +228,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { void listen('config-files-changed', () => { if (disposed) return; void loadGuiSettings(); + void loadTlsSettings(); void refreshStatus(); if (view === 'home') void loadHomeApiKey(); }).then((stop) => { @@ -234,6 +240,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { loadBundledCore(); loadInstallTask(); loadGuiSettings(); + void loadTlsSettings(); void getVersion() .then((version) => { if (!disposed) setInstalledAppVersion(version); @@ -286,37 +293,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { }; }, [installDialogOpen]); - useEffect(() => { - let disposed = false; - if (!allowLanAccess) { - setLanIpv4(null); - setLanIpChecked(false); - return; - } - - setLanIpChecked(false); - invoke('get_lan_ipv4') - .then((address) => { - if (!disposed) { - setLanIpv4(address || null); - } - }) - .catch(() => { - if (!disposed) { - setLanIpv4(null); - } - }) - .finally(() => { - if (!disposed) { - setLanIpChecked(true); - } - }); - - return () => { - disposed = true; - }; - }, [allowLanAccess]); - const runCoreProcessCommand = async ( command: CoreProcessCommand, messages?: { success?: string; failure?: string }, @@ -352,7 +328,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { const loadGuiSettings = async () => { try { const settings = await invoke('get_gui_settings'); - setAllowLanAccess(settings.allowLan); + setListenHost(settings.host); setCustomPort(String(settings.port)); savedPortRef.current = settings.port; } catch {} @@ -369,6 +345,15 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } }; + const loadTlsSettings = async () => { + try { + const settings = await invoke('get_core_tls_settings'); + setTlsEnabled(settings.enabled); + } catch { + setTlsEnabled(false); + } + }; + const loadPlatform = async () => { try { const result = await invoke('detect_core_platform'); @@ -699,7 +684,8 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { Number.isInteger(apiPort) && apiPort >= 1 && apiPort <= 65535 ? apiPort : savedPortRef.current, - allowLanAccess ? lanIpv4 : null, + tlsEnabled, + listenHost, ); const apiProfileIcons = { openai: openaiIcon, @@ -1030,7 +1016,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
- {t('kernel.access.localUrl')} + {t('kernel.access.apiUrl')} {profile.baseUrl}
- {allowLanAccess ? ( -
- {t('kernel.access.lanUrl')} - - {!lanIpChecked - ? t('kernel.access.detectingIp') - : profile.lanUrl || t('kernel.access.noIp')} - - {profile.lanUrl ? ( - - ) : ( -
- ) : null}
))} diff --git a/src/services/clientAccess.ts b/src/services/clientAccess.ts index 2973900..84dbd67 100644 --- a/src/services/clientAccess.ts +++ b/src/services/clientAccess.ts @@ -5,20 +5,39 @@ export type ClientApiProfile = { name: string; description: string; baseUrl: string; - lanUrl: string | null; }; const safeLocalPort = (port: number) => Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 8317; -export function webUiManagementUrl(port: number): string { - return `http://127.0.0.1:${safeLocalPort(port)}/management.html#/login`; +const normalizedConnectHost = (listenHost: string) => { + const host = listenHost.trim().replace(/^\[|\]$/g, ''); + if (!host || host === '0.0.0.0') return '127.0.0.1'; + if (host === '::') return '::1'; + return host; +}; + +const urlHost = (host: string) => host.includes(':') ? `[${host}]` : host; + +export function webUiManagementUrl( + port: number, + tlsEnabled = false, + listenHost = '127.0.0.1', +): string { + const scheme = tlsEnabled ? 'https' : 'http'; + const host = urlHost(normalizedConnectHost(listenHost)); + return `${scheme}://${host}:${safeLocalPort(port)}/management.html#/login`; } -export function clientApiProfiles(port: number, lanIpv4?: string | null): ClientApiProfile[] { +export function clientApiProfiles( + port: number, + tlsEnabled = false, + listenHost = '127.0.0.1', +): ClientApiProfile[] { const safePort = safeLocalPort(port); - const origin = `http://127.0.0.1:${safePort}`; - const lanOrigin = lanIpv4?.trim() ? `http://${lanIpv4.trim()}:${safePort}` : null; + const scheme = tlsEnabled ? 'https' : 'http'; + const connectHost = normalizedConnectHost(listenHost); + const origin = `${scheme}://${urlHost(connectHost)}:${safePort}`; return [ { @@ -26,21 +45,18 @@ export function clientApiProfiles(port: number, lanIpv4?: string | null): Client name: 'OpenAI', description: translate(getCurrentLocale(), 'kernel.access.openaiDescription'), baseUrl: `${origin}/v1`, - lanUrl: lanOrigin ? `${lanOrigin}/v1` : null, }, { id: 'claude', name: 'Claude', description: translate(getCurrentLocale(), 'kernel.access.claudeDescription'), baseUrl: origin, - lanUrl: lanOrigin, }, { id: 'gemini', name: 'Gemini', description: translate(getCurrentLocale(), 'kernel.access.geminiDescription'), baseUrl: origin, - lanUrl: lanOrigin, }, ]; } diff --git a/src/styles.css b/src/styles.css index 3822ca1..7516a43 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1769,6 +1769,70 @@ textarea:focus-visible { gap: 18px; } +.config-tls-content { + display: grid; + gap: 16px; +} + +.config-tls-toggle-row { + padding: 4px 0; + border: 0; + background: transparent; +} + +.config-tls-section-heading, +.config-section-heading-with-actions { + justify-content: space-between; +} + +.config-tls-section-title, +.config-network-section-title { + display: flex; + align-items: center; + gap: 8px; +} + +.config-tls-section-title h3, +.config-network-section-title h3 { + margin: 0; + font-size: var(--font-size-section); + line-height: var(--line-height-body); +} + +.config-tls-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.config-tls-fields .config-network-field { + min-height: 142px; + height: auto; +} + +.config-tls-path-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.config-tls-browse-button { + min-width: max-content; + white-space: nowrap; +} + +.config-tls-message { + min-height: 20px; + color: var(--theme-6d6760); + font-size: var(--font-size-label); + line-height: var(--line-height-body); +} + +.config-tls-message.error { + color: var(--theme-9f2d20); +} + .config-network-sections { display: grid; grid-template-columns: minmax(0, 1fr); @@ -1786,6 +1850,10 @@ textarea:focus-visible { background: var(--theme-fffdf8); } +.config-network-section[hidden] { + display: none; +} + .config-network-section-heading { display: flex; align-items: center; @@ -7845,6 +7913,10 @@ textarea:focus-visible { grid-auto-rows: auto; } + .config-tls-fields { + grid-template-columns: 1fr; + } + .config-management-form { grid-template-columns: 1fr; } diff --git a/tests/clientAccess.test.ts b/tests/clientAccess.test.ts index b799bef..f291d7c 100644 --- a/tests/clientAccess.test.ts +++ b/tests/clientAccess.test.ts @@ -3,22 +3,25 @@ import { clientApiProfiles, webUiManagementUrl } from '../src/services/clientAcc describe('客户端 API 接入信息', () => { it('生成 OpenAI、Claude 和 Gemini 三种正确格式', () => { - const profiles = clientApiProfiles(9527, '192.168.1.8'); + const profiles = clientApiProfiles(9527); expect(profiles.map((profile) => profile.id)).toEqual(['openai', 'claude', 'gemini']); expect(profiles[0].baseUrl).toBe('http://127.0.0.1:9527/v1'); - expect(profiles[0].lanUrl).toBe('http://192.168.1.8:9527/v1'); expect(profiles[1].baseUrl).toBe('http://127.0.0.1:9527'); - expect(profiles[1].lanUrl).toBe('http://192.168.1.8:9527'); expect(profiles[2].baseUrl).toBe('http://127.0.0.1:9527'); - expect(profiles[2].lanUrl).toBe('http://192.168.1.8:9527'); }); it('端口无效时回退到 8317,缺少局域网地址时保持为空', () => { const [openai] = clientApiProfiles(0); expect(openai.baseUrl).toBe('http://127.0.0.1:8317/v1'); - expect(openai.lanUrl).toBeNull(); + }); + + it('TLS 开启时生成 HTTPS 客户端地址', () => { + const profiles = clientApiProfiles(9527, true); + + expect(profiles[0].baseUrl).toBe('https://127.0.0.1:9527/v1'); + expect(profiles[1].baseUrl).toBe('https://127.0.0.1:9527'); }); it('使用当前内核端口生成 WebUI 登录地址', () => { @@ -28,5 +31,29 @@ describe('客户端 API 接入信息', () => { expect(webUiManagementUrl(0)).toBe( 'http://127.0.0.1:8317/management.html#/login', ); + expect(webUiManagementUrl(9527, true)).toBe( + 'https://127.0.0.1:9527/management.html#/login', + ); + }); + + it('uses the configured listen IP for API and WebUI URLs', () => { + const [openai] = clientApiProfiles(9527, true, '192.168.1.20'); + + expect(openai.baseUrl).toBe('https://192.168.1.20:9527/v1'); + expect(webUiManagementUrl(9527, true, '192.168.1.20')).toBe( + 'https://192.168.1.20:9527/management.html#/login', + ); + }); + + it('converts wildcard and IPv6 listen IPs to connectable URL hosts', () => { + expect(webUiManagementUrl(8317, false, '0.0.0.0')).toBe( + 'http://127.0.0.1:8317/management.html#/login', + ); + expect(webUiManagementUrl(8317, false, '::')).toBe( + 'http://[::1]:8317/management.html#/login', + ); + expect(clientApiProfiles(8317, false, '2001:db8::1')[0].baseUrl).toBe( + 'http://[2001:db8::1]:8317/v1', + ); }); }); diff --git a/tests/navigationAndUpdate.test.ts b/tests/navigationAndUpdate.test.ts index 4c15c10..b4304e3 100644 --- a/tests/navigationAndUpdate.test.ts +++ b/tests/navigationAndUpdate.test.ts @@ -8,9 +8,11 @@ describe('首页、配置与版本管理导航', () => { expect(isAlwaysAvailablePage('home')).toBe(true); expect(isAlwaysAvailablePage('versions')).toBe(true); expect(isAlwaysAvailablePage('config')).toBe(true); + expect(isAlwaysAvailablePage('usage-records')).toBe(true); expect(canOpenAppPage('home', false)).toBe(true); expect(canOpenAppPage('versions', false)).toBe(true); expect(canOpenAppPage('config', false)).toBe(true); + expect(canOpenAppPage('usage-records', false)).toBe(true); expect(canOpenAppPage('quota', false)).toBe(false); });