diff --git a/Cargo.lock b/Cargo.lock index 02fd1d1..33ec032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,6 +1153,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "file-rotate" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8e2fa049328a1f3295991407a88585805d126dfaadf74b9fe8c194c730aafc" +dependencies = [ + "chrono", + "flate2", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2564,6 +2574,41 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.1", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -5393,6 +5438,8 @@ dependencies = [ "serde_json", "tokio", "wizard-core", + "wizard-proxy", + "wizard-proxy-daemon", "wizard-settings", ] @@ -5430,7 +5477,6 @@ name = "wizard-gui" version = "0.2.10" dependencies = [ "anyhow", - "libc", "log", "serde", "serde_json", @@ -5443,7 +5489,9 @@ dependencies = [ "tokio", "wizard-core", "wizard-proxy", + "wizard-proxy-daemon", "wizard-settings", + "wizard-system-proxy", ] [[package]] @@ -5456,10 +5504,32 @@ dependencies = [ "hyper-util", "log", "rustls-native-certs", + "serde", + "serde_json", "socket2", "tokio", "tokio-rustls", "url", + "wizard-settings", +] + +[[package]] +name = "wizard-proxy-daemon" +version = "0.2.10" +dependencies = [ + "block2", + "chrono", + "file-rotate", + "libc", + "log", + "objc2", + "objc2-foundation", + "objc2-service-management", + "tempfile", + "tokio", + "windows-sys 0.61.2", + "wizard-proxy", + "wizard-settings", ] [[package]] @@ -5473,6 +5543,18 @@ dependencies = [ "toml_edit 0.25.15+spec-1.1.0", ] +[[package]] +name = "wizard-system-proxy" +version = "0.2.10" +dependencies = [ + "anyhow", + "objc2-core-foundation", + "objc2-security", + "objc2-system-configuration", + "windows", + "wizard-proxy", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 563655b..43d1b52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,11 +37,14 @@ missing_panics_doc = "allow" [workspace.dependencies] wizard-core = { path = "crates/wizard-core" } wizard-proxy = { path = "crates/wizard-proxy" } +wizard-proxy-daemon = { path = "crates/wizard-proxy-daemon" } wizard-settings = { path = "crates/wizard-settings" } +wizard-system-proxy = { path = "crates/wizard-system-proxy" } anyhow = "1.0.104" chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock"] } dirs = "6" +file-rotate = "0.8.0" jsonc-parser = { version = "0.34.0", features = ["cst", "serde_json"] } libc = "0.2.189" base64 = "0.22.1" @@ -52,8 +55,15 @@ http-body-util = "0.1.5" hyper = "1.11.1" hyper-util = "0.1.20" minisign-verify = "0.2.5" +block2 = "0.6.2" +objc2 = "0.6.4" +objc2-service-management = { version = "0.3.2", default-features = false, features = ["std", "SMAppService", "objc2", "objc2-foundation", "block2"] } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSRunningApplication"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] } +objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] } +objc2-security = { version = "0.3.2", default-features = false, features = ["std", "Authorization"] } +objc2-system-configuration = { version = "0.3.2", default-features = false, features = ["std", "SCNetworkConfiguration", "SCPreferences", "SCSchemaDefinitions", "objc2-security"] } +windows = "0.61.3" plist = "1.10.1" regex = "1.13.1" reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider"] } @@ -70,6 +80,7 @@ tauri-plugin-log = "2.8.0" tauri-plugin-single-instance = "2.4.0" tauri-plugin-updater = { version = "2.11.0", default-features = false, features = ["rustls-tls"] } tempfile = "3.27.0" +windows-sys = "0.61.2" url = "2.5.7" uuid = { version = "1.26.0", features = ["v4"] } toml_edit = "0.25.15" diff --git a/crates/wizard-cli/Cargo.toml b/crates/wizard-cli/Cargo.toml index 586d4b9..415ec73 100644 --- a/crates/wizard-cli/Cargo.toml +++ b/crates/wizard-cli/Cargo.toml @@ -17,4 +17,6 @@ clap = { workspace = true, features = ["derive"] } serde_json.workspace = true tokio.workspace = true wizard-core.workspace = true +wizard-proxy-daemon.workspace = true +wizard-proxy.workspace = true wizard-settings.workspace = true diff --git a/crates/wizard-cli/src/main.rs b/crates/wizard-cli/src/main.rs index 7240169..513dc11 100644 --- a/crates/wizard-cli/src/main.rs +++ b/crates/wizard-cli/src/main.rs @@ -11,7 +11,7 @@ use wizard_core::hub; #[derive(Parser)] #[command( version, - about = "CoreInfra Wizard backend CLI. Close the GUI before changing settings.", + about = "CoreInfra Wizard backend CLI. Close the GUI before running.", disable_help_subcommand = true )] struct Cli { @@ -41,6 +41,12 @@ enum Command { } fn main() -> ExitCode { + if let Some(arg1) = std::env::args_os().nth(1) + && arg1 == "--proxy" + { + return wizard_proxy_daemon::main(); + } + let cli = Cli::parse(); if let Err(error) = run(cli) { eprintln!("wizard-cli: {error:#}"); diff --git a/crates/wizard-gui/Cargo.toml b/crates/wizard-gui/Cargo.toml index b082788..26f1815 100644 --- a/crates/wizard-gui/Cargo.toml +++ b/crates/wizard-gui/Cargo.toml @@ -34,10 +34,9 @@ tauri-plugin-updater.workspace = true tokio.workspace = true wizard-core.workspace = true wizard-proxy.workspace = true +wizard-proxy-daemon.workspace = true wizard-settings.workspace = true - -[target.'cfg(unix)'.dependencies] -libc.workspace = true +wizard-system-proxy.workspace = true [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] tauri = { workspace = true, features = ["tray-icon"] } diff --git a/crates/wizard-gui/ai.coreinfra.wizard.proxy.plist b/crates/wizard-gui/ai.coreinfra.wizard.proxy.plist new file mode 100644 index 0000000..df7ac64 --- /dev/null +++ b/crates/wizard-gui/ai.coreinfra.wizard.proxy.plist @@ -0,0 +1,22 @@ + + + + + Label + ai.coreinfra.wizard.proxy + BundleProgram + Contents/MacOS/wizard + ProgramArguments + + wizard + --proxy + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + + diff --git a/crates/wizard-gui/src/agents.rs b/crates/wizard-gui/src/agents.rs index da878eb..306284c 100644 --- a/crates/wizard-gui/src/agents.rs +++ b/crates/wizard-gui/src/agents.rs @@ -191,8 +191,9 @@ pub(crate) async fn apply_agent_plan( let proxy_app = app.clone(); let result = tauri::async_runtime::spawn_blocking(move || { - // Sync even when the setting is unchanged: «Применить» restarts a stopped listener. - // Also sync after a failed save: tray or autostart can fail after the setting is saved. + // Sync even when the setting is unchanged: «Применить» starts a stopped daemon. + // Reload checks both slots; unchanged tokens preserve active connections. + // Even if saving fails, synchronize with the settings actually on disk. plan.apply_with_proxies( |token| { tauri::async_runtime::block_on(async { @@ -200,7 +201,7 @@ pub(crate) async fn apply_agent_plan( settings.claude_proxy_enabled = token; }) .await; - let synced = proxy::sync_one(&proxy_app, proxy::App::ClaudeDesktop).await; + let synced = proxy::sync(&proxy_app).await; saved.and(synced) }) }, @@ -210,7 +211,7 @@ pub(crate) async fn apply_agent_plan( settings.codex_proxy_enabled = token; }) .await; - let synced = proxy::sync_one(&proxy_app, proxy::App::ChatGpt).await; + let synced = proxy::sync(&proxy_app).await; saved.and(synced) }) }, diff --git a/crates/wizard-gui/src/lib.rs b/crates/wizard-gui/src/lib.rs index c305465..080fa54 100644 --- a/crates/wizard-gui/src/lib.rs +++ b/crates/wizard-gui/src/lib.rs @@ -17,6 +17,7 @@ mod logs; mod proxy; mod revision_signal; mod settings; +mod system_proxy; #[cfg(any(target_os = "macos", target_os = "windows"))] mod tray; mod updater; @@ -72,7 +73,9 @@ pub fn run_application() -> Result<()> { .plugin(tauri_plugin_updater::Builder::new().build()) .manage(Arc::new(claude_desktop_install::InstallManager::default())) .manage(proxy::ProxyState::default()) - .manage(revision_signal::RevisionSignal::new(proxy::Proxy::default())) + .manage(revision_signal::RevisionSignal::new( + system_proxy::State::default(), + )) .manage(Arc::new(revision_signal::RevisionSignal::new( updater::UpdateState::Checking, ))) @@ -87,6 +90,7 @@ pub fn run_application() -> Result<()> { hub_tokens::HubTokensManager::initialize(app.handle())?; #[cfg(any(target_os = "macos", target_os = "windows"))] let tray_enabled = { + app.manage(tray::TrayState::default()); let preferences = settings::get_state(app.handle())?.application; autostart::initialize(app.handle())?; let autostart = autostart::set_enabled(app.handle(), preferences.autostart_enabled); @@ -99,12 +103,15 @@ pub fn run_application() -> Result<()> { #[cfg(target_os = "linux")] let tray_enabled = false; - #[cfg(unix)] - proxy::raise_open_file_limit(); - if let Err(error) = tauri::async_runtime::block_on(proxy::sync(app.handle())) { + if let Err(error) = tauri::async_runtime::block_on(async { + wizard_proxy_daemon::create_daemon().await?; + proxy::sync(app.handle()).await + }) { log::error!("proxy startup failed: {error:#}"); } + proxy::start_observer(app.handle()); + system_proxy::start(app.handle()); if !minimized || !tray_enabled { request_main_window(app.handle()); } @@ -117,6 +124,8 @@ pub fn run_application() -> Result<()> { claude_desktop_install::check_claude_desktop_release, claude_desktop_install::download_claude_desktop_release, claude_desktop_install::install_claude_desktop_release, + system_proxy::get_system_proxy_state, + system_proxy::set_system_proxy, settings::get_application_settings, settings::set_application_setting, logs::open_logs, @@ -154,9 +163,8 @@ pub fn run_application() -> Result<()> { #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { .. } => request_main_window(app), tauri::RunEvent::Exit => { - if let Err(error) = tauri::async_runtime::block_on(proxy::stop(app)) { - log::error!("proxy shutdown failed: {error:#}"); - } + system_proxy::stop(app); + proxy::stop_observer(app); } _ => {} }); @@ -164,16 +172,28 @@ pub fn run_application() -> Result<()> { } fn request_main_window(app: &tauri::AppHandle) { + request_window(app, false); +} + +#[cfg_attr( + not(any(target_os = "macos", target_os = "windows")), + expect(dead_code) +)] +fn request_settings_window(app: &tauri::AppHandle) { + request_window(app, true); +} + +fn request_window(app: &tauri::AppHandle, show_settings: bool) { let app = app.clone(); // WebView2 creation must not block a synchronous event callback on Windows. tauri::async_runtime::spawn_blocking(move || { - if let Err(error) = open_main_window(&app) { + if let Err(error) = open_main_window(&app, show_settings) { log::error!("failed to open main window: {error:#}"); } }); } -fn open_main_window(app: &tauri::AppHandle) -> Result<()> { +fn open_main_window(app: &tauri::AppHandle, show_settings: bool) -> Result<()> { let _guard = MAIN_WINDOW_LOCK .lock() .map_err(|_| anyhow!("main window lock poisoned"))?; @@ -184,9 +204,20 @@ fn open_main_window(app: &tauri::AppHandle) -> Result<()> { .iter() .find(|window| window.label == MAIN_WINDOW_NAME) .context("main window configuration not found")?; - let window = match app.get_webview_window(MAIN_WINDOW_NAME) { - Some(window) => window, - None => WebviewWindowBuilder::from_config(app, config)?.build()?, + let window = if let Some(window) = app.get_webview_window(MAIN_WINDOW_NAME) { + if show_settings { + // Static trusted-local navigation, never interpolate command data. + window.eval("window.location.hash = '/settings';")?; + } + window + } else { + let mut builder = WebviewWindowBuilder::from_config(app, config)?; + if show_settings { + // Runs before React reads the initial hash, including on a newly + // recreated WebView. No event can be lost before UI subscription. + builder = builder.initialization_script("window.location.hash = '/settings';"); + } + builder.build()? }; window.set_title(&format!("{} v{}", config.title, app.package_info().version))?; diff --git a/crates/wizard-gui/src/logs.rs b/crates/wizard-gui/src/logs.rs index e44f99c..c15627d 100644 --- a/crates/wizard-gui/src/logs.rs +++ b/crates/wizard-gui/src/logs.rs @@ -1,4 +1,4 @@ -//! Read-only viewer for the current GUI log file. +//! Read-only viewer for the current GUI and proxy log files. use anyhow::{Context as _, Result}; use std::{ @@ -11,6 +11,13 @@ use tauri::Manager as _; const WINDOW_LABEL: &str = "logs"; const MAX_LOG_BYTES: u64 = 256 * 1024; +#[derive(serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LogSource { + Wizard, + Proxy, +} + #[tauri::command] pub(crate) async fn open_logs(app: tauri::AppHandle) -> Result<(), String> { let result = (|| -> Result<()> { @@ -41,10 +48,13 @@ pub(crate) async fn open_logs(app: tauri::AppHandle) -> Result<(), String> { } #[tauri::command] -pub(crate) async fn read_logs() -> Result { +pub(crate) async fn read_logs(source: LogSource) -> Result { let path = wizard_settings::paths::logs_dir() .map_err(|error| error.to_string())? - .join("Wizard.log"); + .join(match source { + LogSource::Wizard => "Wizard.log", + LogSource::Proxy => "Proxy.log", + }); tauri::async_runtime::spawn_blocking(move || read_tail(&path)) .await .map_err(|error| format!("log reader task failed: {error}"))? diff --git a/crates/wizard-gui/src/main.rs b/crates/wizard-gui/src/main.rs index 13a8da3..9a85bdf 100644 --- a/crates/wizard-gui/src/main.rs +++ b/crates/wizard-gui/src/main.rs @@ -1,9 +1,31 @@ -// Prevents an additional console window on Windows in release builds. -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +// Prevents an additional console window on Windows. +#![cfg_attr(windows, windows_subsystem = "windows")] use std::process::ExitCode; fn main() -> ExitCode { + if let Some(arg1) = std::env::args_os().nth(1) + && arg1 == "--proxy" + { + return wizard_proxy_daemon::main(); + } + + if std::env::args_os() + .nth(1) + .is_some_and(|arg| arg == "--uninstall-proxy") + { + return wizard_proxy_daemon::uninstall(); + } + + // Shared across GUI instances; uninstall requires exclusive access. + let _uninstall_guard = match wizard_proxy_daemon::gui_guard() { + Ok(guard) => guard, + Err(error) => { + eprintln!("cannot start Wizard during proxy cleanup: {error}"); + return ExitCode::FAILURE; + } + }; + if let Err(error) = wizard_gui::run_application() { eprintln!("application failed: {error:#}"); return ExitCode::FAILURE; diff --git a/crates/wizard-gui/src/proxy.rs b/crates/wizard-gui/src/proxy.rs index 66e5352..47061b9 100644 --- a/crates/wizard-gui/src/proxy.rs +++ b/crates/wizard-gui/src/proxy.rs @@ -1,244 +1,110 @@ -use crate::{ - revision_signal::{RevisionSignal, RevisionSnapshot}, - settings, -}; -use anyhow::{Context as _, Result, anyhow}; +use crate::revision_signal::{RevisionSignal, RevisionSnapshot}; +use anyhow::{Context as _, Result}; +use core::time::Duration; use serde::Serialize; +use std::{io, sync::OnceLock}; use tauri::Manager as _; use tokio::sync::Mutex; -use wizard_settings::Settings; -/// Actual state of each desktop app's listener. +/// Runtime proxy enablement reported by the daemon, not saved demand. #[derive(Clone, Default, PartialEq, Eq, Serialize)] pub(crate) struct Proxy { claude_desktop: bool, chatgpt: bool, } -#[derive(Clone, Copy)] -pub(crate) enum App { - ClaudeDesktop, - ChatGpt, +pub(crate) fn claude_desktop_running(app: &tauri::AppHandle) -> bool { + // Report the observed daemon state, not saved demand or a GUI-owned listener. + // Unknown status must not be presented as a working proxy. + app.state::() + .signal + .current() + .state + .is_ok_and(|proxy| proxy.claude_desktop) } -impl App { - const ALL: [Self; 2] = [Self::ClaudeDesktop, Self::ChatGpt]; - - fn name(self) -> &'static str { - match self { - Self::ClaudeDesktop => "Claude Desktop", - Self::ChatGpt => "ChatGPT", - } - } - - fn address(self) -> &'static str { - match self { - Self::ClaudeDesktop => wizard_proxy::CLAUDE_DESKTOP_ADDRESS, - Self::ChatGpt => wizard_proxy::CHATGPT_ADDRESS, - } - } - - fn token(self, settings: &Settings) -> Option<&str> { - match self { - Self::ClaudeDesktop => settings.claude_proxy_enabled.as_deref(), - Self::ChatGpt => settings.codex_proxy_enabled.as_deref(), - } - } -} - -// No Debug: holds the listener's startup token. -struct RunningProxy { - handle: wizard_proxy::Proxy, - observer: tauri::async_runtime::JoinHandle<()>, - token: String, +pub(crate) struct ProxyState { + // Serialize observations with sync so an earlier status cannot overwrite a + // status fetched after Reload. This does not own the daemon's lifecycle. + operation: Mutex<()>, + signal: RevisionSignal>, + observer: OnceLock>, } -#[derive(Default)] -struct Listeners { - claude_desktop: Option, - chatgpt: Option, -} - -impl Listeners { - fn get(&mut self, app: App) -> &mut Option { - match app { - App::ClaudeDesktop => &mut self.claude_desktop, - App::ChatGpt => &mut self.chatgpt, +impl Default for ProxyState { + fn default() -> Self { + Self { + operation: Mutex::new(()), + signal: RevisionSignal::new(Err("proxy status is not loaded".to_owned())), + observer: OnceLock::new(), } } } -#[derive(Default)] -pub(crate) struct ProxyState(Mutex); - #[tauri::command] pub(crate) async fn get_proxy_state( last_revision: Option, - signal: tauri::State<'_, RevisionSignal>, + state: tauri::State<'_, ProxyState>, ) -> Result, String> { - signal + let snapshot = state + .signal .wait(last_revision) .await - .map_err(|error| format!("{error:#}")) -} - -// Call while holding ProxyState's mutex so lifecycle events remain ordered. -fn publish(app: &tauri::AppHandle, proxy_app: App, running: bool) { - app.state::>().update(|current| { - let mut next = current.clone(); - match proxy_app { - App::ClaudeDesktop => next.claude_desktop = running, - App::ChatGpt => next.chatgpt = running, - } - (next != *current).then_some(next) - }); -} - -/// macOS starts GUI apps with a soft limit of 256 descriptors, and each tunnel holds two; -/// as the system proxy, the listeners would run out. Never lowers the limit. -#[cfg(unix)] -#[expect( - unsafe_code, - reason = "std has no safe bindings for getrlimit and setrlimit" -)] -pub(crate) fn raise_open_file_limit() { - // macOS OPEN_MAX: setrlimit(2) there recommends min(OPEN_MAX, rlim_max) for RLIMIT_NOFILE. - const TARGET: libc::rlim_t = 10240; - - let mut limit = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: `limit` is a valid, writable rlimit. - if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) } != 0 { - let error = std::io::Error::last_os_error(); - log::warn!("failed to read open file limit: {error}"); - return; - } - let previous = limit.rlim_cur; - limit.rlim_cur = TARGET.min(limit.rlim_max); - if limit.rlim_cur <= previous { - return; - } - // SAFETY: `limit` is a valid rlimit whose soft limit does not exceed the hard one. - if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &raw const limit) } != 0 { - let error = std::io::Error::last_os_error(); - log::warn!("failed to raise open file limit from {previous}: {error}"); - return; - } - log::info!( - "open file limit raised from {previous} to {}", - limit.rlim_cur - ); + .map_err(|error| format!("{error:#}"))?; + Ok(RevisionSnapshot { + revision: snapshot.revision, + state: snapshot.state?, + }) } -/// Called at startup for both apps; one failure does not skip the other. +/// Called at startup and after proxy settings have been saved and published. pub(crate) async fn sync(app: &tauri::AppHandle) -> Result<()> { - let mut results = Vec::new(); - for proxy_app in App::ALL { - results.push(sync_one(app, proxy_app).await); - } - combine(results) -} - -/// Called after applying the app's proxy setting. Its listener runs while the app has a token and -/// is restarted when that token changes; a stopped or failed listener is started again. -pub(crate) async fn sync_one(app: &tauri::AppHandle, proxy_app: App) -> Result<()> { let state = app.state::(); - let mut listeners = state.0.lock().await; - let settings = settings::get_state(app)?; - sync_app( - app, - listeners.get(proxy_app), - proxy_app, - proxy_app.token(&settings), - ) - .await + let _guard = state.operation.lock().await; + let result = wizard_proxy_daemon::sync_proxy() + .await + .context("failed to synchronize proxy daemon"); + // Reload can partially apply or fail; observe runtime instead of assuming + // it matches the saved settings. Observations never launch or reload anything. + refresh(&state).await; + result } -async fn sync_app( - app: &tauri::AppHandle, - slot: &mut Option, - proxy_app: App, - token: Option<&str>, -) -> Result<()> { - if slot.as_ref().map(|running| running.token.as_str()) == token { - return Ok(()); - } - stop_proxy(app, slot, proxy_app).await?; - let Some(token) = token else { - return Ok(()); +async fn refresh(state: &ProxyState) { + let next = match wizard_proxy::control::status().await { + Ok(status) => Ok(Proxy { + claude_desktop: status.claude_desktop_enabled, + chatgpt: status.chatgpt_enabled, + }), + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => Ok(Proxy::default()), + Err(_) => Err("failed to read proxy daemon status".to_owned()), }; - - // TODO: Retry failed starts without requiring a toggle or application restart. - let handle = wizard_proxy::start(proxy_app.address(), token) - .await - .with_context(|| format!("failed to start {} local proxy", proxy_app.name()))?; - let stopped = handle.stopped(); - let observer_app = app.clone(); - let observer = tauri::async_runtime::spawn(async move { - stopped.await; - - let state = observer_app.state::(); - let mut listeners = state.0.lock().await; - if let Some(running) = listeners.get(proxy_app).take() { - // The task already exited. Join it to report its error and release the handle. - let result = running.handle.stop().await; - publish(&observer_app, proxy_app, false); - match result { - Ok(()) => log::warn!("{} local proxy stopped unexpectedly", proxy_app.name()), - Err(error) => log::error!( - "{} local proxy stopped unexpectedly: {error:#}", - proxy_app.name() - ), - } - } - }); - *slot = Some(RunningProxy { - handle, - observer, - token: token.to_owned(), - }); - publish(app, proxy_app, true); - log::info!( - "{} local proxy started on {}", - proxy_app.name(), - proxy_app.address() - ); - Ok(()) + state + .signal + .update(|current| (current != &next).then_some(next)); } -pub(crate) async fn stop(app: &tauri::AppHandle) -> Result<()> { +/// Observe the independent process even if it stops outside a settings update. +pub(crate) fn start_observer(app: &tauri::AppHandle) { let state = app.state::(); - let mut listeners = state.0.lock().await; - - let mut results = Vec::new(); - for proxy_app in App::ALL { - results.push(stop_proxy(app, listeners.get(proxy_app), proxy_app).await); - } - combine(results) + let app = app.clone(); + state.observer.get_or_init(|| { + tauri::async_runtime::spawn(async move { + loop { + { + let state = app.state::(); + let _guard = state.operation.lock().await; + refresh(&state).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + }); } -async fn stop_proxy( - app: &tauri::AppHandle, - slot: &mut Option, - proxy_app: App, -) -> Result<()> { - if let Some(running) = slot.take() { - // Cancel this instance's observer before a replacement can acquire the mutex. - running.observer.abort(); - let result = running.handle.stop().await; - publish(app, proxy_app, false); - result.with_context(|| format!("failed to stop {} local proxy", proxy_app.name()))?; - log::info!("{} local proxy stopped", proxy_app.name()); +/// GUI exit stops only its observer, never the independent proxy process. +pub(crate) fn stop_observer(app: &tauri::AppHandle) { + if let Some(observer) = app.state::().observer.get() { + observer.abort(); } - Ok(()) -} - -fn combine(results: Vec>) -> Result<()> { - let mut errors = results.into_iter().filter_map(Result::err); - let Some(first) = errors.next() else { - return Ok(()); - }; - Err(errors.fold(first, |all, error| anyhow!("{all:#}; {error:#}"))) } diff --git a/crates/wizard-gui/src/system_proxy.rs b/crates/wizard-gui/src/system_proxy.rs new file mode 100644 index 0000000..0de90d3 --- /dev/null +++ b/crates/wizard-gui/src/system_proxy.rs @@ -0,0 +1,236 @@ +//! OS-backed state shared by settings and tray. No saved flag or automatic writes. + +use crate::revision_signal::{RevisionSignal, RevisionSnapshot}; +use anyhow::Result; +use core::time::Duration; +use serde::Serialize; +use std::sync::Mutex; +use tauri::Manager as _; +use wizard_system_proxy::{self as os, ProxyState, SystemProxyStatus}; + +static OPERATION: Mutex<()> = Mutex::new(()); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Status { + #[default] + Unknown, + Off, + On, + Partial, + Unavailable, + Unsupported, +} + +#[derive(Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct State { + pub(crate) status: Status, + pub(crate) busy: bool, + pub(crate) conflict: bool, + pub(crate) listener_running: bool, + read_error: Option, + operation_error: Option, +} + +#[cfg_attr( + not(any(target_os = "macos", target_os = "windows", test)), + expect(dead_code) +)] +impl State { + pub(crate) fn has_ours(&self) -> bool { + matches!(self.status, Status::On | Status::Partial) + } + + pub(crate) fn can_toggle(&self) -> bool { + !self.busy && (self.has_ours() || (self.status == Status::Off && !self.conflict)) + } +} + +pub(crate) fn current(app: &tauri::AppHandle) -> State { + app.state::>().current().state +} + +fn update(signal: &RevisionSignal, edit: impl FnOnce(&mut State)) { + signal.update(|current| { + let mut next = current.clone(); + edit(&mut next); + (next != *current).then_some(next) + }); +} + +fn begin(signal: &RevisionSignal) -> Result<(), String> { + signal + .update(|current| { + (!current.busy).then(|| State { + busy: true, + operation_error: None, + ..current.clone() + }) + }) + .map(|_| ()) + .ok_or_else(|| "Изменение системного прокси уже выполняется".into()) +} + +fn observe(state: &mut State, result: Result, listener_running: bool) { + state.listener_running = listener_running; + state.conflict = false; + state.read_error = None; + match result { + Ok(snapshot) => { + state.conflict = snapshot.services.iter().any(|s| s.conflict); + let any = snapshot + .services + .iter() + .any(|s| s.http == ProxyState::Wizard || s.https == ProxyState::Wizard); + let all = snapshot + .services + .iter() + .all(|s| s.http == ProxyState::Wizard && s.https == ProxyState::Wizard); + state.status = if snapshot.services.is_empty() { + Status::Unavailable + } else if all && !state.conflict { + Status::On + } else if any { + Status::Partial + } else { + Status::Off + }; + } + Err(error) => { + state.status = Status::Unknown; + state.read_error = Some(format!("Не удалось прочитать системный прокси: {error:#}")); + } + } +} + +fn sync_tray(app: &tauri::AppHandle) { + #[cfg(any(target_os = "macos", target_os = "windows"))] + if crate::tray::sync_system_proxy(app).is_err() { + log::error!("failed to update system proxy tray item"); + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let _ = app; +} + +async fn run(app: tauri::AppHandle, enabled: Option) -> Result<(), String> { + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + let signal = worker_app.state::>(); + if enabled.is_some() { + begin(&signal)?; + sync_tray(&worker_app); + } + let result = (|| { + // Mutations wait for a preceding read. Observations skip busy work. + let _guard = if enabled.is_some() { + OPERATION.lock().map_err(|_| "Операции системного прокси недоступны; перезапустите Wizard".to_owned())? + } else { + let Ok(guard) = OPERATION.try_lock() else { return Ok(()); }; + if signal.current().state.busy { return Ok(()); } + guard + }; + let write = enabled.map(|enabled| if enabled { os::enable() } else { os::disable() }); + let status = os::status(); // Always reread, even after a failed write. + update(&signal, |state| { + observe(state, status, crate::proxy::claude_desktop_running(&worker_app)); + if let Some(write) = write { + state.operation_error = write.err().map(|error| format!("Не удалось изменить системный прокси: {error:#}. Настройки могли примениться частично.")); + } + }); + let state = current(&worker_app); + state.operation_error.or(state.read_error).map_or(Ok(()), Err) + })(); + if enabled.is_some() { + update(&signal, |state| { + state.busy = false; + if let Err(error) = &result + && state.operation_error.is_none() && state.read_error.is_none() { + state.operation_error = Some(error.clone()); + } + }); + } + sync_tray(&worker_app); + result + }).await; + // Publication stays inside the worker, even if the IPC caller disappears. + result.unwrap_or_else(|_| { + let message = "Операция системного прокси прервана. Состояние ОС неизвестно."; + update(&app.state::>(), |state| { + if enabled.is_some() { + state.busy = false; + } + state.status = Status::Unknown; + state.read_error = Some(message.into()); + }); + sync_tray(&app); + Err(message.into()) + }) +} + +struct Observer(tauri::async_runtime::JoinHandle<()>); +impl Drop for Observer { + fn drop(&mut self) { + self.0.abort(); + } +} + +pub(crate) fn start(app: &tauri::AppHandle) { + if !cfg!(any(target_os = "macos", target_os = "windows")) { + update(&app.state::>(), |state| { + state.status = Status::Unsupported; + }); + return; + } + let worker_app = app.clone(); + app.manage(Observer(tauri::async_runtime::spawn(async move { + loop { + let _ = run(worker_app.clone(), None).await; + tokio::time::sleep(Duration::from_secs(2)).await; + } + }))); +} + +pub(crate) fn stop(app: &tauri::AppHandle) { + if let Some(observer) = app.try_state::() { + observer.0.abort(); + } +} + +#[tauri::command] +pub(crate) async fn get_system_proxy_state( + last_revision: Option, + signal: tauri::State<'_, RevisionSignal>, +) -> Result, String> { + signal + .wait(last_revision) + .await + .map_err(|_| "Не удалось получить состояние системного прокси".into()) +} + +#[tauri::command] +pub(crate) async fn set_system_proxy(app: tauri::AppHandle, enabled: bool) -> Result<(), String> { + if !cfg!(any(target_os = "macos", target_os = "windows")) { + return Err("Системный прокси пока доступен только на macOS и Windows".into()); + } + run(app, Some(enabled)).await +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(crate) fn toggle_from_tray(app: &tauri::AppHandle) { + let state = current(app); + if !state.can_toggle() { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if set_system_proxy(app.clone(), !state.has_ours()) + .await + .is_err() + { + crate::request_settings_window(&app); + } + }); +} + +#[cfg(test)] +mod tests; diff --git a/crates/wizard-gui/src/system_proxy/tests.rs b/crates/wizard-gui/src/system_proxy/tests.rs new file mode 100644 index 0000000..ee4a8f2 --- /dev/null +++ b/crates/wizard-gui/src/system_proxy/tests.rs @@ -0,0 +1,122 @@ +use super::*; +use alloc::sync::Arc; +use std::sync::Barrier; +use wizard_system_proxy::ServiceStatus; + +fn snapshot(http: ProxyState, https: ProxyState, conflict: bool) -> SystemProxyStatus { + SystemProxyStatus { + services: vec![ServiceStatus { + id: "fixture".into(), + http, + https, + conflict, + }], + } +} + +#[test] +fn aggregates_os_settings_without_treating_a_stopped_listener_as_a_conflict() { + use ProxyState::{Disabled, Foreign, Wizard}; + for (http, https, conflict, status, can_toggle) in [ + (Disabled, Disabled, false, Status::Off, true), + (Foreign, Foreign, true, Status::Off, false), + (Wizard, Wizard, false, Status::On, true), + (Wizard, Wizard, true, Status::Partial, true), + (Wizard, Foreign, true, Status::Partial, true), + (Wizard, Disabled, false, Status::Partial, true), + ] { + let mut state = State::default(); + observe(&mut state, Ok(snapshot(http, https, conflict)), false); + assert_eq!(state.status, status); + assert_eq!(state.can_toggle(), can_toggle); + } + let mut state = State::default(); + let mut mixed = snapshot(Wizard, Wizard, false); + mixed + .services + .extend(snapshot(Disabled, Disabled, false).services); + observe(&mut state, Ok(mixed), true); + assert_eq!(state.status, Status::Partial); + observe(&mut state, Ok(SystemProxyStatus { services: vec![] }), true); + assert_eq!(state.status, Status::Unavailable); + assert!(!state.can_toggle()); +} + +#[test] +fn failed_read_is_unknown_and_polling_preserves_operation_errors() { + let mut state = State { + operation_error: Some("fixture operation failure".into()), + ..State::default() + }; + observe( + &mut state, + Err(anyhow::anyhow!("fixture read failure")), + true, + ); + assert_eq!(state.status, Status::Unknown); + assert!(state.read_error.is_some()); + assert!(!state.can_toggle()); + observe( + &mut state, + Ok(snapshot(ProxyState::Wizard, ProxyState::Wizard, false)), + true, + ); + assert_eq!(state.status, Status::On); + assert!(state.read_error.is_none()); + assert!(state.operation_error.is_some()); + let signal = RevisionSignal::new(state); + assert!(begin(&signal).is_ok()); + assert!(signal.current().state.operation_error.is_none()); +} + +#[test] +fn late_observation_cannot_clear_busy_or_admit_another_mutation() { + let signal = RevisionSignal::new(State::default()); + let read_before_mutation = snapshot(ProxyState::Disabled, ProxyState::Disabled, false); + assert!(begin(&signal).is_ok()); + update(&signal, |state| { + observe(state, Ok(read_before_mutation), true); + }); + assert!(signal.current().state.busy); + assert!(!signal.current().state.can_toggle()); + assert!(begin(&signal).is_err()); + update(&signal, |state| state.busy = false); + assert!(begin(&signal).is_ok()); +} + +#[test] +fn simultaneous_clicks_admit_only_one_mutation() { + let signal = Arc::new(RevisionSignal::new(State::default())); + let barrier = Arc::new(Barrier::new(4)); + let handles: Vec<_> = (0..4) + .map(|_| { + let signal = Arc::clone(&signal); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + begin(&signal).is_ok() + }) + }) + .collect(); + let accepted = handles + .into_iter() + .map(|h| h.join().expect("admission worker")) + .filter(|&ok| ok) + .count(); + assert_eq!(accepted, 1); +} + +#[test] +fn unchanged_observations_do_not_publish() { + let signal = RevisionSignal::new(State::default()); + for _ in 0..2 { + update(&signal, |state| { + observe( + state, + Ok(snapshot(ProxyState::Wizard, ProxyState::Wizard, false)), + true, + ); + }); + } + assert_eq!(signal.current().revision, 1); +} diff --git a/crates/wizard-gui/src/tray.rs b/crates/wizard-gui/src/tray.rs index 3d668cc..c9b10f5 100644 --- a/crates/wizard-gui/src/tray.rs +++ b/crates/wizard-gui/src/tray.rs @@ -1,15 +1,29 @@ use anyhow::{Context as _, Result}; +use std::sync::Mutex; +use tauri::Manager as _; use tauri::menu::{Menu, MenuEvent, MenuItem}; use tauri::tray::TrayIconBuilder; const TRAY_ID: &str = "main"; const OPEN_ID: &str = "wizard-open"; const EXIT_ID: &str = "wizard-exit"; +const SYSTEM_PROXY_ID: &str = "wizard-system-proxy"; +const PROXY_WARNING_ID: &str = "wizard-system-proxy-warning"; + +struct ProxyMenu { + menu: Menu, + action: MenuItem, + warning: MenuItem, +} + +#[derive(Default)] +pub(crate) struct TrayState(Mutex>); pub(crate) fn on_menu_event(app: &tauri::AppHandle, event: &MenuEvent) { match event.id().as_ref() { OPEN_ID => crate::request_main_window(app), EXIT_ID => app.exit(0), + SYSTEM_PROXY_ID => crate::system_proxy::toggle_from_tray(app), _ => {} } } @@ -34,6 +48,11 @@ fn set_enabled_on_main_thread(app: &tauri::AppHandle, enabled: bool) -> Result<( if !enabled { // The settings writer decides whether to exit after all OS effects finish. app.remove_tray_by_id(TRAY_ID); + app.state::() + .0 + .lock() + .map_err(|_| anyhow::anyhow!("tray state lock poisoned"))? + .take(); return Ok(()); } if is_enabled(app) { @@ -42,16 +61,105 @@ fn set_enabled_on_main_thread(app: &tauri::AppHandle, enabled: bool) -> Result<( let open = MenuItem::with_id(app, OPEN_ID, "Открыть Wizard", true, None::<&str>)?; let exit = MenuItem::with_id(app, EXIT_ID, "Выход", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&open, &exit])?; + let state = crate::system_proxy::current(app); + let system_proxy = MenuItem::with_id( + app, + SYSTEM_PROXY_ID, + system_proxy_label(&state), + state.can_toggle(), + None::<&str>, + )?; + let warning = MenuItem::with_id( + app, + PROXY_WARNING_ID, + "Локальный прокси Claude Desktop остановлен", + false, + None::<&str>, + )?; + let menu = Menu::with_items(app, &[&open, &system_proxy, &exit])?; + let items = ProxyMenu { + menu, + action: system_proxy, + warning, + }; + update_proxy_menu(&items, &state)?; let icon = app .default_window_icon() .context("application icon is missing")?; TrayIconBuilder::with_id(TRAY_ID) .icon(icon.clone()) - .menu(&menu) + .menu(&items.menu) .show_menu_on_left_click(true) .build(app) .context("failed to create Wizard tray icon")?; + *app.state::() + .0 + .lock() + .map_err(|_| anyhow::anyhow!("tray state lock poisoned"))? = Some(items); Ok(()) } + +fn system_proxy_label(state: &crate::system_proxy::State) -> &'static str { + use crate::system_proxy::Status; + if state.busy { + return "Системный прокси — применение…"; + } + match state.status { + Status::Unknown => "Системный прокси — состояние неизвестно", + Status::Unavailable => "Системный прокси — нет сетевых сервисов", + Status::Unsupported => "Системный прокси — недоступен", + Status::On | Status::Partial => "Выключить системный прокси", + Status::Off => "Включить системный прокси", + } +} + +fn update_proxy_menu(items: &ProxyMenu, state: &crate::system_proxy::State) -> Result<()> { + let label = system_proxy_label(state); + if items.action.text()? != label { + items.action.set_text(label)?; + } + if items.action.is_enabled()? != state.can_toggle() { + items.action.set_enabled(state.can_toggle())?; + } + let warning_visible = items.menu.get(PROXY_WARNING_ID).is_some(); + let show_warning = !state.listener_running + && matches!( + state.status, + crate::system_proxy::Status::Off + | crate::system_proxy::Status::On + | crate::system_proxy::Status::Partial + ); + if show_warning && !warning_visible { + items.menu.insert(&items.warning, 2)?; + } else if !show_warning && warning_visible { + items.menu.remove(&items.warning)?; + } + Ok(()) +} + +/// Always read the newest backend state on the main thread, not a captured +/// snapshot that could arrive after a newer operation or tray recreation. +pub(crate) fn sync_system_proxy(app: &tauri::AppHandle) -> Result<()> { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let handle = app.clone(); + app.run_on_main_thread(move || { + let result = (|| { + let state = crate::system_proxy::current(&handle); + let tray = handle.state::(); + let item = tray + .0 + .lock() + .map_err(|_| anyhow::anyhow!("tray state lock poisoned"))?; + if let Some(items) = item.as_ref() { + update_proxy_menu(items, &state)?; + } + Ok(()) + })(); + let _ = sender.send(result); + }) + .context("failed to dispatch system proxy tray update")?; + receiver + .recv() + .context("system proxy tray update did not complete")? +} diff --git a/crates/wizard-gui/src/updater.rs b/crates/wizard-gui/src/updater.rs index 5be8ed3..0148087 100644 --- a/crates/wizard-gui/src/updater.rs +++ b/crates/wizard-gui/src/updater.rs @@ -112,11 +112,34 @@ async fn check_and_install_update( .await .context("Не удалось скачать обновление")?; - tauri::async_runtime::spawn_blocking(move || update.install(bytes)) + // download() verifies the update signature before service removal begins. + let update_guard = wizard_proxy_daemon::prepare_update() .await - .context("Не удалось выполнить задачу установки обновления")? - .context("Не удалось установить обновление")?; + .context("Не удалось подготовить прокси к обновлению; установка отменена")?; + + let (update_guard, installation) = tauri::async_runtime::spawn_blocking(move || { + // Keep lifecycle locked even if the awaiting task is cancelled while the + // blocking installer runs. Catch installer panics so recovery retains the guard. + let installation = + std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| update.install(bytes))) + .map_err(|_| anyhow::anyhow!("Задача установки обновления завершилась с panic")) + .and_then(|result| result.context("Не удалось установить обновление")); + (update_guard, installation) + }) + .await + .context("Не удалось выполнить задачу установки обновления")?; + + if let Err(error) = installation { + if let Err(recovery) = update_guard.restore_after_failure().await { + return Err(error.context(format!( + "Также не удалось восстановить сервис прокси: {recovery}" + ))); + } + return Err(error); + } + // Keep update_guard until process restart; queued lifecycle calls must not + // start a daemon from the new bundle while the old GUI is still running. log::info!("application update installed; restarting"); app.restart(); } diff --git a/crates/wizard-gui/tauri.conf.json b/crates/wizard-gui/tauri.conf.json index 4331e67..bce71dc 100644 --- a/crates/wizard-gui/tauri.conf.json +++ b/crates/wizard-gui/tauri.conf.json @@ -56,8 +56,17 @@ "active": true, "targets": "all", "createUpdaterArtifacts": false, + "windows": { + "nsis": { + "installMode": "currentUser", + "installerHooks": "./windows/hooks.nsh" + } + }, "macOS": { - "signingIdentity": "-" + "signingIdentity": "-", + "files": { + "Library/LaunchAgents/ai.coreinfra.wizard.proxy.plist": "ai.coreinfra.wizard.proxy.plist" + } }, "icon": [ "icons/32x32.png", diff --git a/crates/wizard-gui/windows/README.md b/crates/wizard-gui/windows/README.md new file mode 100644 index 0000000..1a64b0b --- /dev/null +++ b/crates/wizard-gui/windows/README.md @@ -0,0 +1,48 @@ +# Windows uninstall + +The NSIS pre-uninstall hook invokes the installed executable with +`--uninstall-proxy` before Tauri checks running processes or deletes files. +Exit Wizard from the tray first: a shared GUI lock prevents cleanup while any +current GUI instance is running. Cleanup holds the exclusive lock until it exits. + +Cleanup validates the current user's task action against the installed EXE, +disables the task, requests HTTP shutdown and waits for process exit, then deletes +the task. It never force-kills the daemon. A refused connection is not proof of +exit: cleanup also acquires the daemon instance lock and checks task instances. +Without a registered task, an occupied instance lock causes cleanup to fail +rather than sending shutdown to an unidentified server. + +Windows PowerShell (the OS-provided WindowsPowerShell executable) accesses Task +Scheduler through COM. Task names and paths are passed in the child environment, +not interpolated into script source. No additional package is required. Policy +restrictions on PowerShell/COM cause uninstall to fail safely. + +Errors abort before file removal, also in silent/passive mode. A failure after +disabling the task leaves it disabled; retry uninstall after resolving the error. +Starting Wizard again normally restores registration when no daemon is listening. +If the EXE is missing, repair/reinstall before retrying uninstall. + +Settings and logs are preserved. The hook clears Tauri 2.11.4's +`DeleteAppDataCheckboxState`, even if its stock checkbox was selected. +The GUI/uninstall coordination file `proxy-uninstall.lock` is intentionally retained +alongside the existing daemon lock; unlinking live lock files is unsafe. + +## Verification + +Windows `cargo x ci` includes a test using a uniquely named temporary scheduler +task. It verifies missing-task handling, repeated cleanup, disable/delete, and +rejection of a different executable or arguments. It does not touch the real +Wizard task or settings. + +Before release, also test the **built NSIS installer**: +- Running proxy, closed GUI: uninstall stops the process and removes its task. +- Open GUI: uninstall aborts without removing files; exit via tray and retry. +- Proxy starting or unresponsive: cleanup either waits or aborts without force-kill. +- Already absent task and stopped proxy: uninstall succeeds. +- Normal, silent (`/S`), and passive (`/P`) uninstall preserve `config.toml`, + including when the stock delete-data checkbox is selected. +- Upgrade/reinstall: settings survive and the next GUI start registers the task. +- Paths with spaces and non-ASCII characters. + +Recheck the hook position, `MAINBINARYNAME`, `PassiveMode`, and +`DeleteAppDataCheckboxState` when updating Tauri's NSIS template. diff --git a/crates/wizard-gui/windows/hooks.nsh b/crates/wizard-gui/windows/hooks.nsh new file mode 100644 index 0000000..000d3e1 --- /dev/null +++ b/crates/wizard-gui/windows/hooks.nsh @@ -0,0 +1,20 @@ +; Verified against the Tauri CLI 2.11.4 NSIS template. +!macro NSIS_HOOK_PREUNINSTALL + ; Settings must survive even if Tauri's "Delete app data" box was checked. + StrCpy $DeleteAppDataCheckboxState 0 + + ClearErrors + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --uninstall-proxy' $0 + ${If} ${Errors} + StrCpy $0 2 + ${EndIf} + ${If} $0 != 0 + DetailPrint "Proxy cleanup failed. Close Wizard from the tray and retry. Settings have been preserved." + ${IfNot} ${Silent} + ${AndIf} $PassiveMode != 1 + MessageBox MB_ICONSTOP|MB_OK "Cannot remove the proxy task. Close Wizard from the tray and retry. No application files have been removed." + ${EndIf} + SetErrorLevel 2 + Abort + ${EndIf} +!macroend diff --git a/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx b/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx index 7d11a9c..ae1fbf5 100644 --- a/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx +++ b/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx @@ -5,39 +5,47 @@ import { Heading } from "../components/catalyst/heading"; import { Text } from "../components/catalyst/text"; import { ErrorText } from "../components/ErrorText"; +type LogSource = "wizard" | "proxy"; + export function LogWindow() { - const [text, setText] = useState(); - const [error, setError] = useState(); + const [source, setSource] = useState("wizard"); + const [result, setResult] = useState<{ source: LogSource; text?: string; error?: string }>(); + const text = result?.source === source ? result.text : undefined; + const error = result?.source === source ? result.error : undefined; + const fileName = source === "wizard" ? "Wizard.log" : "Proxy.log"; const [paused, setPaused] = useState(false); const [follow, setFollow] = useState(true); const output = useRef(null); useEffect(() => { - if (paused) return; let disposed = false; let timer: ReturnType | undefined; async function refresh() { try { - const next = await invoke("read_logs"); - if (!disposed) { - setText(next); - setError(undefined); - } + const next = await invoke("read_logs", { source }); + if (!disposed) setResult({ source, text: next }); } catch (cause: unknown) { // Do not log polling failures back into the file being displayed. - if (!disposed) setError(`Не удалось прочитать журнал: ${String(cause)}`); + if (!disposed) { + setResult((previous) => ({ + source, + text: previous?.source === source ? previous.text : undefined, + error: `Не удалось прочитать журнал: ${String(cause)}`, + })); + } } finally { - if (!disposed) timer = setTimeout(() => void refresh(), 1000); + if (!disposed && !paused) timer = setTimeout(() => void refresh(), 1000); } } + // Always read the selected file once; pause only stops periodic refreshes. void refresh(); return () => { disposed = true; clearTimeout(timer); }; - }, [paused]); + }, [source, paused]); useEffect(() => { if (follow && output.current) output.current.scrollTop = output.current.scrollHeight; @@ -46,7 +54,23 @@ export function LogWindow() { return (
- Журнал +
+ Журнал +
+ {(["wizard", "proxy"] as const).map((value) => ( + + ))} +
+
- Последние 256 КиБ текущего файла Wizard.log. {paused ? "Обновление приостановлено." : "Обновляется раз в секунду."} + Последние 256 КиБ текущего файла {fileName}. {paused ? "Автообновление приостановлено." : "Обновляется раз в секунду."} {error && {error}}
         {text === undefined ? "Загрузка…" : text || "Журнал пока пуст."}
diff --git a/crates/wizard-gui/wizard-ui/src/settings/SettingsPage.tsx b/crates/wizard-gui/wizard-ui/src/settings/SettingsPage.tsx
index 1202753..56443cc 100644
--- a/crates/wizard-gui/wizard-ui/src/settings/SettingsPage.tsx
+++ b/crates/wizard-gui/wizard-ui/src/settings/SettingsPage.tsx
@@ -7,6 +7,7 @@ import { Switch, SwitchField, SwitchGroup } from "../components/catalyst/switch"
 import { Text } from "../components/catalyst/text";
 import { ErrorText } from "../components/ErrorText";
 import { type ApplicationSetting, setApplicationSetting, useApplicationSettings } from "./application_settings";
+import { setSystemProxy, useSystemProxy } from "./system_proxy";
 
 export function SettingsPage() {
   const { settings, error: loadError } = useApplicationSettings();
@@ -62,6 +63,60 @@ export function SettingsPage() {
           
         )}
       
+      
     
   );
 }
+
+function SystemProxySettings() {
+  const { state, error: loadError } = useSystemProxy();
+  const inFlight = useRef(false);
+  const [submitting, setSubmitting] = useState(false);
+  const [commandError, setCommandError] = useState();
+  const hasOurs = state?.status === "on" || state?.status === "partial";
+  const known = hasOurs || state?.status === "off";
+  const busy = submitting || state?.busy === true;
+  const disabled = busy || Boolean(loadError) || !known || (!hasOurs && state?.conflict === true);
+
+  async function change(enabled: boolean) {
+    if (inFlight.current) return;
+    inFlight.current = true;
+    setSubmitting(true);
+    setCommandError(undefined);
+    try {
+      await setSystemProxy(enabled);
+    } catch (cause: unknown) {
+      setCommandError(typeof cause === "string" ? cause : "Не удалось изменить системный прокси");
+    } finally {
+      inFlight.current = false;
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    
+ + + Wizard будет проксировать сайты OpenAI и Claude, остальной интернет будет работать как раньше. + { void change(enabled); }} /> + +
+ {busy && Применение системных настроек… На macOS может потребоваться авторизация.} + {(!state || (state.status === "unknown" && !state.read_error)) && !loadError && Определение состояния…} + {state?.status === "unsupported" && Системный прокси пока доступен только на macOS и Windows.} + {state?.status === "unavailable" && Нет поддерживаемых сетевых сервисов.} + {state?.status === "partial" && ( + Частично включён: настройки Wizard действуют не для всех протоколов или сетевых сервисов либо конфликтуют с другими настройками. Выключение уберёт только настройки Wizard. + )} + {state?.conflict && Обнаружены другие настройки прокси, PAC или автоопределение. Wizard не перезаписывает их; сначала отключите их в настройках ОС.} + {known && !state?.listener_running && ( + Локальный прокси Claude Desktop остановлен. Включение разрешено, но приложения с системным прокси могут потерять доступ к сети. + )} + {hasOurs && После выхода из Wizard приложения с системным прокси могут остаться без сети до запуска локального прокси или отключения этой настройки.} + {loadError && {loadError}} + {state?.read_error && {state.read_error}} + {(state?.operation_error || commandError) && {state?.operation_error || commandError}} +
+
+ ); +} diff --git a/crates/wizard-gui/wizard-ui/src/settings/system_proxy.ts b/crates/wizard-gui/wizard-ui/src/settings/system_proxy.ts new file mode 100644 index 0000000..c39c73a --- /dev/null +++ b/crates/wizard-gui/wizard-ui/src/settings/system_proxy.ts @@ -0,0 +1,48 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; + +type SystemProxyState = { + status: "unknown" | "off" | "on" | "partial" | "unavailable" | "unsupported"; + busy: boolean; + conflict: boolean; + listener_running: boolean; + read_error: string | null; + operation_error: string | null; +}; +type SystemProxySnapshot = SystemProxyState & { revision: number }; + +// Only observes the shared backend state. No saved flag, automatic mutation or +// UI-owned process state; tray actions remain visible after reopening the window. +export function useSystemProxy() { + const [state, setState] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + let stopped = false; + async function observe() { + let lastRevision: number | null = null; + while (!stopped) { + try { + const next: SystemProxySnapshot = await invoke("get_system_proxy_state", { lastRevision }); + if (stopped) return; + setState(next); + setError(undefined); + lastRevision = next.revision; + } catch { + if (stopped) return; + setError("Не удалось получить состояние системного прокси"); + lastRevision = null; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + } + void observe(); + return () => { stopped = true; }; + }, []); + + return { state, error }; +} + +export function setSystemProxy(enabled: boolean): Promise { + return invoke("set_system_proxy", { enabled }); +} diff --git a/crates/wizard-proxy-daemon/Cargo.toml b/crates/wizard-proxy-daemon/Cargo.toml new file mode 100644 index 0000000..aa85288 --- /dev/null +++ b/crates/wizard-proxy-daemon/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "wizard-proxy-daemon" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +file-rotate.workspace = true +log.workspace = true +tokio = { workspace = true, features = ["rt", "macros", "signal"] } +wizard-proxy.workspace = true +wizard-settings.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true + +[target.'cfg(windows)'.dependencies] +tempfile.workspace = true +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_SystemInformation", "Win32_System_Threading"] } + +[target.'cfg(target_os = "macos")'.dependencies] +block2.workspace = true +objc2.workspace = true +objc2-foundation.workspace = true +objc2-service-management.workspace = true diff --git a/crates/wizard-proxy-daemon/src/lib.rs b/crates/wizard-proxy-daemon/src/lib.rs new file mode 100644 index 0000000..1afbbb4 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/lib.rs @@ -0,0 +1,396 @@ +use core::time::Duration; +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::io; +use std::process::ExitCode; +use tokio::sync::Mutex; +use wizard_proxy::diagnostics; + +mod logging; + +#[cfg(unix)] +mod limits; +#[cfg(unix)] +mod unix; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as platform; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +use windows as platform; + +#[cfg(any(windows, test))] +mod windows_task; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +use linux as platform; + +/// Prepare the service once at GUI startup, before synchronizing settings. +/// In release builds, a running daemon with the same workspace crate version is +/// left untouched. Debug builds ignore version matches to pick up local rebuilds. +/// Otherwise a valid Status or refused connection triggers platform registration. +/// Other HTTP errors leave registration unchanged. +/// Await this operation to completion: cancellation can leave native registration +/// running after the synchronization lock is released. +pub async fn create_daemon() -> io::Result<()> { + let _guard = SYNC_LOCK.lock().await; + match wizard_proxy::control::status().await { + Ok(status) if !cfg!(debug_assertions) && status.version == env!("CARGO_PKG_VERSION") => { + log::info!("proxy daemon version matches; keeping current service"); + return Ok(()); + } + Ok(_) => log::info!("preparing proxy service registration: version differs or debug build"), + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { + log::info!("preparing proxy service registration: daemon is not listening"); + } + Err(error) => { + diagnostics::error("proxy version lookup before registration", &error); + return Err(error); + } + } + + let result = platform::create_daemon().await; + match &result { + Ok(()) => log::info!("proxy service registration ready"), + Err(error) => diagnostics::error("proxy service registration", error), + } + result +} + +static SYNC_LOCK: Mutex<()> = Mutex::const_new(()); + +/// Exclusive access to service lifecycle operations while replacing the application. +/// Keep this guard until restart, or consume it to recover after an installation error. +/// Unlike CLI uninstall, this is used inside a GUI holding the shared GUI lifetime lock. +#[must_use = "keep the guard alive throughout installation and until application restart"] +pub struct UpdateGuard { + _lock: tokio::sync::MutexGuard<'static, ()>, + restore_service: bool, +} + +/// Stop and unregister the service after downloading and verifying an update. +/// Failure must abort installation. Await completion: native work may outlive cancellation. +pub async fn prepare_update() -> io::Result { + prepare_update_with(platform::uninstall()).await +} + +async fn prepare_update_with( + uninstall: impl Future>, +) -> io::Result { + let lock = SYNC_LOCK.lock().await; + log::info!("preparing proxy service for application update"); + let restore_service = uninstall + .await + .inspect_err(|error| diagnostics::error("proxy update preparation", error))?; + log::info!("proxy update preparation completed: registration_removed={restore_service}"); + Ok(UpdateGuard { + _lock: lock, + restore_service, + }) +} + +impl UpdateGuard { + /// Best-effort restoration from the bundle left by a failed installer. + /// Never reacquire `SYNC_LOCK` here: this guard already owns it. + pub async fn restore_after_failure(self) -> io::Result<()> { + self.restore_with(async { + platform::create_daemon().await?; + sync_with_settings().await + }) + .await + } + + async fn restore_with(self, restore: impl Future>) -> io::Result<()> { + if !self.restore_service { + log::info!("proxy recovery skipped: no platform service was removed"); + return Ok(()); + } + log::info!("restoring proxy service after failed application update"); + let result = restore.await; + match &result { + Ok(()) => log::info!("proxy service restored after failed application update"), + Err(error) => { + diagnostics::error("proxy recovery after failed application update", error); + } + } + result + } +} + +/// Synchronize the independent proxy process with saved settings. +/// Only a refused connection permits starting a process: a timeout or failed +/// Reload may already have applied changes and must not trigger a second launch. +/// A single platform start request is followed by bounded Status polling. +/// This never registers the service; call `create_daemon` at GUI startup first. +/// Neither a launch nor a mutating Reload is automatically retried. +pub async fn sync_proxy() -> io::Result<()> { + let _guard = SYNC_LOCK.lock().await; + log::info!("synchronizing proxy daemon with saved settings"); + let result = sync_with_settings().await; + if let Err(error) = &result { + diagnostics::error("proxy settings synchronization", error); + } + result +} + +async fn sync_with_settings() -> io::Result<()> { + match wizard_proxy::control::reload().await { + Ok(response) => { + if let Some(error) = response.error { + Err(io::Error::other(error)) + } else { + log::info!("proxy daemon settings synchronized"); + Ok(()) + } + } + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { + let enabled = wizard_settings::load_from_file() + .inspect_err(|error| { + diagnostics::error("proxy startup demand load", error.as_ref()); + }) + .map_err(|_| io::Error::other("failed to load Wizard settings"))? + .local_proxy_enabled(); + if enabled { + log::info!("requesting proxy service start"); + platform::start_daemon() + .await + .inspect_err(|error| diagnostics::error("proxy service start", error))?; + log::info!("waiting for proxy daemon readiness"); + wait_for_proxy() + .await + .inspect_err(|error| diagnostics::error("proxy readiness wait", error))?; + } else { + log::info!("proxy service start skipped: both proxies are disabled"); + } + Ok(()) + } + Err(error) => Err(error), + } +} + +async fn wait_for_proxy() -> io::Result<()> { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + match wizard_proxy::control::status().await { + Ok(_) => { + log::info!("proxy daemon HTTP management is ready"); + return Ok(()); + } + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { + // Settings may have been disabled while startup + // was pending. In that case a clean daemon exit is expected. + if !wizard_settings::load_from_file() + .inspect_err(|error| { + diagnostics::error("proxy readiness demand load", error.as_ref()); + }) + .map_err(|_| io::Error::other("failed to load Wizard settings"))? + .local_proxy_enabled() + { + log::info!("proxy readiness wait ended: both proxies are now disabled"); + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(error) => return Err(error), + } + } + }) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy daemon did not become ready"))? +} + +/// Hold for the GUI lifetime so uninstall cannot race GUI synchronization. +pub fn gui_guard() -> io::Result { + let file = uninstall_lock_file()?; + file.try_lock_shared().map_err(io::Error::from)?; + Ok(file) +} + +fn uninstall_lock_file() -> io::Result { + let directory = wizard_settings::paths::config_dir() + .map_err(|_| io::Error::other("failed to resolve uninstall lock directory"))?; + fs::create_dir_all(&directory)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + options.open(directory.join("proxy-uninstall.lock")) +} + +/// Remove the current installation's proxy service without changing settings. +/// On macOS, run from the application bundle that registered the service. +/// On macOS and Windows, close GUI instances first. Linux is a successful no-op. +#[must_use] +pub fn uninstall() -> ExitCode { + let result = (|| -> io::Result<()> { + #[cfg(any(target_os = "macos", windows))] + let _guard = { + let guard = uninstall_lock_file()?; + guard.try_lock().map_err(io::Error::from)?; + guard + }; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(platform::uninstall()) + .map(|_| ()) + })(); + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("proxy uninstall failed: {error}"); + ExitCode::FAILURE + } + } +} + +/// Run the proxy process without initializing the GUI. +/// Call once at process startup, before any other logger or Tokio runtime. +#[must_use] +pub fn main() -> ExitCode { + let result = (|| { + let _instance = acquire_instance_lock()?; + logging::initialize()?; + #[cfg(unix)] + limits::raise_open_file_limit(); + log::info!( + "proxy daemon starting: version={}, pid={}", + env!("CARGO_PKG_VERSION"), + std::process::id(), + ); + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .inspect_err(|error| diagnostics::error("proxy runtime creation", error)) + .map_err(|_| "failed to create proxy runtime") + .and_then(|runtime| runtime.block_on(run())); + match &result { + Ok(()) => log::info!("proxy daemon stopped"), + Err(operation) => log::error!("{operation}"), + } + log::logger().flush(); + result + })(); + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(operation) => { + // Only static diagnostics: never print settings, tokens or raw errors. + eprintln!("{operation}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), &'static str> { + // Register handlers before starting listeners so startup-time events remain pending. + let shutdown = platform::shutdown_signal()?; + wizard_proxy::run_managed(shutdown) + .await + .inspect_err(|error| diagnostics::error("proxy server", error)) + .map_err(|_| "proxy server failed") +} + +/// Keep the returned file open until shutdown. Never unlink the lock file: another +/// process could otherwise lock a different file at the same path. +fn acquire_instance_lock() -> Result { + let directory = wizard_settings::paths::config_dir() + .map_err(|_| "failed to resolve proxy lock directory")?; + fs::create_dir_all(&directory) + .inspect_err(|error| logging::report_stderr("proxy lock directory creation", error)) + .map_err(|_| "failed to create proxy lock directory")?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let file = options + .open(directory.join("proxy-daemon.lock")) + .inspect_err(|error| logging::report_stderr("proxy lock file open", error)) + .map_err(|_| "failed to open proxy lock file")?; + file.try_lock().map_err(|error| match error { + TryLockError::WouldBlock => "proxy daemon is already running", + TryLockError::Error(error) => { + logging::report_stderr("proxy instance lock", &error); + "failed to lock proxy instance" + } + })?; + Ok(file) +} + +#[cfg(test)] +mod update_tests { + use super::{SYNC_LOCK, prepare_update_with}; + use std::io; + + #[tokio::test] + async fn update_guard_serializes_preparation_installation_and_recovery() { + // Fake removal: no registration, process, or user settings are touched. + let guard = prepare_update_with(async { + assert!(SYNC_LOCK.try_lock().is_err(), "lock must precede removal"); + Ok(false) + }) + .await + .expect("prepare update"); + assert!( + SYNC_LOCK.try_lock().is_err(), + "installation must remain exclusive" + ); + // The guard can be owned by a blocking installer without unlocking lifecycle operations. + let guard = tokio::task::spawn_blocking(move || { + assert!(SYNC_LOCK.try_lock().is_err()); + guard + }) + .await + .expect("fake installer"); + assert!(SYNC_LOCK.try_lock().is_err()); + // No service was removed: recovery must not call platform registration. + guard + .restore_with(async { panic!("must not create an absent service") }) + .await + .expect("no-op recovery"); + assert!(SYNC_LOCK.try_lock().is_ok()); + + let guard = prepare_update_with(async { Ok(true) }) + .await + .expect("fake service removal"); + let error = guard + .restore_with(async { + assert!( + SYNC_LOCK.try_lock().is_err(), + "recovery must remain exclusive" + ); + tokio::task::yield_now().await; + assert!(SYNC_LOCK.try_lock().is_err()); + Err(io::Error::from(io::ErrorKind::ConnectionRefused)) + }) + .await + .expect_err("recovery failure must be reported"); + assert_eq!(error.kind(), io::ErrorKind::ConnectionRefused); + assert!(SYNC_LOCK.try_lock().is_ok()); + + let error = prepare_update_with(async { + assert!(SYNC_LOCK.try_lock().is_err()); + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + }) + .await + .err() + .expect("preparation must fail"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!( + SYNC_LOCK.try_lock().is_ok(), + "failed preparation must release the lock" + ); + } +} diff --git a/crates/wizard-proxy-daemon/src/limits.rs b/crates/wizard-proxy-daemon/src/limits.rs new file mode 100644 index 0000000..a406417 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/limits.rs @@ -0,0 +1,38 @@ +//! Process-local descriptor budget for the daemon, which owns the proxy sockets. + +/// Raise the soft limit without lowering it or exceeding the hard limit. +#[expect( + unsafe_code, + reason = "std has no safe bindings for getrlimit and setrlimit" +)] +pub(super) fn raise_open_file_limit() { + // macOS setrlimit(2) recommends min(OPEN_MAX, rlim_max) for RLIMIT_NOFILE. + const TARGET: libc::rlim_t = 10240; + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: limit is a valid, writable rlimit. + if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) } != 0 { + let error = std::io::Error::last_os_error(); + log::error!("failed to read daemon open file limit: {error}"); + return; + } + + let previous = limit.rlim_cur; + limit.rlim_cur = TARGET.min(limit.rlim_max); + if limit.rlim_cur <= previous { + return; + } + + // SAFETY: limit is valid and its soft limit does not exceed its hard limit. + if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &raw const limit) } != 0 { + let error = std::io::Error::last_os_error(); + log::error!("failed to raise daemon open file limit from {previous}: {error}"); + return; + } + log::info!( + "daemon open file limit raised from {previous} to {}", + limit.rlim_cur + ); +} diff --git a/crates/wizard-proxy-daemon/src/linux.rs b/crates/wizard-proxy-daemon/src/linux.rs new file mode 100644 index 0000000..cb14eed --- /dev/null +++ b/crates/wizard-proxy-daemon/src/linux.rs @@ -0,0 +1,27 @@ +#![expect( + clippy::unused_async, + reason = "Linux stubs retain the async contract shared with Windows and macOS" +)] + +use std::io; + +pub(super) use super::unix::shutdown_signal; + +// No platform service is installed on Linux yet. Cleanup is a successful no-op. +pub(super) async fn uninstall() -> io::Result { + Ok(false) +} + +pub(super) async fn create_daemon() -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "proxy service registration is not implemented on this platform", + )) +} + +pub(super) async fn start_daemon() -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "proxy service startup is not implemented on this platform", + )) +} diff --git a/crates/wizard-proxy-daemon/src/logging.rs b/crates/wizard-proxy-daemon/src/logging.rs new file mode 100644 index 0000000..bb39e17 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/logging.rs @@ -0,0 +1,103 @@ +use file_rotate::{ContentLimit, FileRotate, compression::Compression, suffix::AppendCount}; +use log::{LevelFilter, Log, Metadata, Record}; +use std::{ + fs::{self, OpenOptions}, + io::Write as _, + sync::{Mutex, OnceLock}, +}; + +static LOGGER: OnceLock = OnceLock::new(); + +struct Logger { + output: Mutex>, +} + +pub(crate) fn initialize() -> Result<(), &'static str> { + let level = match std::env::var("WIZARD_LOG") { + Ok(value) => value.parse().map_err(|_| "invalid WIZARD_LOG level")?, + Err(std::env::VarError::NotPresent) => LevelFilter::Info, + Err(std::env::VarError::NotUnicode(_)) => return Err("invalid WIZARD_LOG encoding"), + }; + let directory = + wizard_settings::paths::logs_dir().map_err(|_| "failed to resolve proxy log directory")?; + fs::create_dir_all(&directory) + .inspect_err(|error| report_stderr("proxy log directory creation", error)) + .map_err(|_| "failed to create proxy log directory")?; + let mut options = OpenOptions::new(); + options.read(true).create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let path = directory.join("Proxy.log"); + // FileRotate suppresses open errors; report initialization failures before using it. + drop( + options + .open(&path) + .inspect_err(|error| report_stderr("proxy log file open", error)) + .map_err(|_| "failed to open proxy log file")?, + ); + let output = FileRotate::new( + path, + AppendCount::new(5), + ContentLimit::BytesSurpassed(1024 * 1024), + Compression::None, + Some(options), + ); + let logger = LOGGER.get_or_init(|| Logger { + output: Mutex::new(output), + }); + log::set_logger(logger).map_err(|_| "proxy logger already initialized")?; + log::set_max_level(level); + Ok(()) +} + +pub(crate) fn report_stderr(operation: &'static str, error: &(dyn core::error::Error + 'static)) { + // If stderr also fails there is no remaining diagnostic sink; do not recurse. + let _ = writeln!( + std::io::stderr().lock(), + "{operation} failed:{}", + wizard_proxy::diagnostics::summary(error) + ); +} + +impl Log for Logger { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.level() <= log::max_level() && metadata.target().starts_with("wizard_proxy") + } + + fn log(&self, record: &Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let mut output = self + .output + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Match Wizard.log's default UTC timestamps and field layout. + // One buffer per record prevents size-based rotation from splitting a line. + let line = format!( + "[{}][{}][{}] {}\n", + chrono::Utc::now().format("%Y-%m-%d][%H:%M:%S"), + record.target(), + record.level(), + record.args(), + ); + if let Err(error) = output.write_all(line.as_bytes()) { + report_stderr("proxy log write", &error); + // Never recurse through the logger when its output is failing. + let _ = std::io::stderr().lock().write_all(line.as_bytes()); + } + } + + fn flush(&self) { + let mut output = self + .output + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Err(error) = output.flush() { + report_stderr("proxy log flush", &error); + } + } +} diff --git a/crates/wizard-proxy-daemon/src/macos.rs b/crates/wizard-proxy-daemon/src/macos.rs new file mode 100644 index 0000000..f272d43 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/macos.rs @@ -0,0 +1,224 @@ +use block2::RcBlock; +use core::time::Duration; +use objc2::rc::autoreleasepool; +use objc2_foundation::{NSError, NSString}; +use objc2_service_management::{SMAppService, SMAppServiceStatus}; +use std::{io, process::Stdio, sync::mpsc}; +use tokio::process::Command; + +pub(super) use super::unix::shutdown_signal; + +const SERVICE_LABEL: &str = "ai.coreinfra.wizard.proxy"; +const PLIST_NAME: &str = "ai.coreinfra.wizard.proxy.plist"; + +pub async fn start_daemon() -> io::Result<()> { + // Do not trust a UID environment variable or infer identity from file owners. + let output = tokio::time::timeout( + Duration::from_secs(5), + Command::new("/usr/bin/id") + .arg("-u") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "current user lookup timed out"))??; + if !output.status.success() { + return Err(io::Error::other("failed to determine current user")); + } + let uid = core::str::from_utf8(&output.stdout) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| io::Error::other("invalid current user identifier"))?; + + // Registration may already have launched the agent. Without -k, kickstart + // does not kill an existing instance. Never bypass approval via enable or + // launch the executable directly as a fallback. + // launchd's default throttle can defer a rapid restart for 10 seconds. + // kickstart waits for that launch; allow the throttle plus scheduling slack. + let status = tokio::time::timeout( + Duration::from_secs(15), + Command::new("/bin/launchctl") + .arg("kickstart") + .arg(format!("gui/{uid}/{SERVICE_LABEL}")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status(), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "proxy service start request timed out", + ) + })??; + if !status.success() { + return Err(io::Error::other( + "launchd rejected the proxy service start request", + )); + } + Ok(()) +} + +// Returns whether a registration was removed, for update-failure recovery. +pub(super) async fn uninstall() -> io::Result { + tokio::task::spawn_blocking(unregister_daemon) + .await + .inspect_err(|error| wizard_proxy::diagnostics::error("proxy unregistration worker", error)) + .map_err(|_| io::Error::other("proxy service unregistration worker failed"))? +} + +#[expect( + unsafe_code, + reason = "SMAppService bindings require checked native calls" +)] +fn unregister_daemon() -> io::Result { + if !objc2::available!(macos = 13.0) { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "proxy service removal requires macOS 13 or later", + )); + } + + autoreleasepool(|_| { + let name = NSString::from_str(PLIST_NAME); + // SAFETY: macOS availability is checked; the fixed plist belongs to this bundle. + let service = unsafe { SMAppService::agentServiceWithPlistName(&name) }; + // SAFETY: service is retained and valid for this call. + let status = unsafe { service.status() }; + if status != SMAppServiceStatus::NotRegistered { + // Includes pending approval. Do not treat NotFound as successful removal: + // ask SMAppService for the authoritative unregistration result. + unregister_service(&service)?; + } + // Confirm process cleanup without shutting down an unrelated loopback listener. + let _instance = super::acquire_instance_lock().map_err(io::Error::other)?; + Ok(status != SMAppServiceStatus::NotRegistered) + }) +} + +#[expect( + unsafe_code, + reason = "unregister a retained SMAppService and read its completion error" +)] +fn unregister_service(service: &SMAppService) -> io::Result<()> { + log::info!("unregistering proxy service and waiting for termination"); + let (finished, completion) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |error: *mut NSError| { + // SAFETY: SMAppService supplies null or an NSError valid during this callback. + // Only the numeric code is reported; native descriptions may contain private data. + let result = unsafe { error.as_ref() }.map_or(Ok(()), |error| { + log::error!( + "proxy service unregistration failed: native_code={}", + error.code() + ); + Err(io::Error::other(format!( + "proxy service unregistration failed (code {})", + error.code(), + ))) + }); + if finished.try_send(result).is_err() { + log::error!("proxy unregistration completion could not reach its caller"); + } + }); + // SAFETY: callers hold a live service on macOS 13+. SMAppService retains the + // escaping block and invokes it on a dispatch queue after termination or error. + unsafe { service.unregisterWithCompletionHandler(&handler) }; + completion + .recv_timeout(Duration::from_secs(10)) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => io::Error::new( + io::ErrorKind::TimedOut, + "proxy service unregistration timed out", + ), + mpsc::RecvTimeoutError::Disconnected => { + io::Error::other("proxy service unregistration callback was lost") + } + })??; + log::info!("proxy service unregistration completed"); + Ok(()) +} + +pub async fn create_daemon() -> io::Result<()> { + tokio::task::spawn_blocking(register_daemon) + .await + .inspect_err(|error| wizard_proxy::diagnostics::error("proxy registration worker", error)) + .map_err(|_| io::Error::other("proxy service registration worker failed"))? +} + +#[expect( + unsafe_code, + reason = "SMAppService bindings require checked native calls" +)] +fn register_daemon() -> io::Result<()> { + if !objc2::available!(macos = 13.0) { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "proxy service registration requires macOS 13 or later", + )); + } + + autoreleasepool(|_| { + let name = NSString::from_str(PLIST_NAME); + // SAFETY: macOS availability is checked above. The fixed name identifies + // our bundled LaunchAgent, not arbitrary caller-supplied service data. + // The returned service is retained for every subsequent native call. + let service = unsafe { SMAppService::agentServiceWithPlistName(&name) }; + // SAFETY: service is a live SMAppService on a supported OS. + let mut status = unsafe { service.status() }; + log::info!("proxy service status before registration: {}", status.0); + if status == SMAppServiceStatus::Enabled { + // Never register before successful completion, including on timeout: + // the native unregistration may still finish after we return an error. + unregister_service(&service)?; + status = SMAppServiceStatus::NotRegistered; + } + if status == SMAppServiceStatus::NotRegistered || status == SMAppServiceStatus::NotFound { + // NotFound may mean the background task record is missing. Attempt + // registration so its result, rather than the initial status, decides. + log::info!("registering bundled proxy LaunchAgent"); + // SAFETY: the service and its fixed plist name remain valid. This is + // the registration step, before requesting launch through launchd. + let registration = unsafe { service.registerAndReturnError() }; + // SAFETY: service remains alive after the registration attempt. + status = unsafe { service.status() }; + log::info!("proxy service status after registration: {}", status.0); + if status != SMAppServiceStatus::Enabled + && status != SMAppServiceStatus::RequiresApproval + { + // Keep diagnostics useful without exposing NSError descriptions. + registration.map_err(|error| { + log::error!( + "proxy service registration failed: native_code={}", + error.code() + ); + io::Error::other(format!( + "proxy service registration failed (code {})", + error.code(), + )) + })?; + } + } + + match status { + SMAppServiceStatus::Enabled => Ok(()), + SMAppServiceStatus::RequiresApproval => Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "proxy service requires approval in System Settings > General > Login Items", + )), + SMAppServiceStatus::NotFound => Err(io::Error::new( + io::ErrorKind::NotFound, + "proxy LaunchAgent was not found by SMAppService", + )), + SMAppServiceStatus::NotRegistered => { + Err(io::Error::other("proxy service is not registered")) + } + _ => Err(io::Error::other( + "unknown proxy service registration status", + )), + } + }) +} diff --git a/crates/wizard-proxy-daemon/src/unix.rs b/crates/wizard-proxy-daemon/src/unix.rs new file mode 100644 index 0000000..ca1ee84 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/unix.rs @@ -0,0 +1,19 @@ +use tokio::signal::unix::{SignalKind, signal}; + +// Install handlers now, before run_managed starts loading settings and binding. +pub(super) fn shutdown_signal() -> Result, &'static str> { + let mut interrupt = signal(SignalKind::interrupt()) + .inspect_err(|error| wizard_proxy::diagnostics::error("SIGINT handler registration", error)) + .map_err(|_| "failed to register SIGINT handler")?; + let mut terminate = signal(SignalKind::terminate()) + .inspect_err(|error| { + wizard_proxy::diagnostics::error("SIGTERM handler registration", error); + }) + .map_err(|_| "failed to register SIGTERM handler")?; + Ok(async move { + tokio::select! { + _ = interrupt.recv() => log::info!("proxy daemon received SIGINT"), + _ = terminate.recv() => log::info!("proxy daemon received SIGTERM"), + } + }) +} diff --git a/crates/wizard-proxy-daemon/src/windows-task.xml b/crates/wizard-proxy-daemon/src/windows-task.xml new file mode 100644 index 0000000..7c0b08f --- /dev/null +++ b/crates/wizard-proxy-daemon/src/windows-task.xml @@ -0,0 +1,34 @@ + + + + + true + {sid} + + + + + {sid} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + false + PT0S + true + + PT1M + 3 + + + + + {executable} + --proxy + + + diff --git a/crates/wizard-proxy-daemon/src/windows-uninstall.ps1 b/crates/wizard-proxy-daemon/src/windows-uninstall.ps1 new file mode 100644 index 0000000..88d0353 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/windows-uninstall.ps1 @@ -0,0 +1,46 @@ +# Invoked by Rust with -NoProfile -NonInteractive. Values travel through the +# child environment, never through PowerShell source interpolation. +$ErrorActionPreference = 'Stop' +try { + $scheduler = New-Object -ComObject Schedule.Service + $scheduler.Connect() + $folder = $scheduler.GetFolder('\') + try { + $task = $folder.GetTask($env:WIZARD_UNINSTALL_TASK) + } catch { + # HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND), not access denied. + if ($_.Exception.HResult -eq -2147024894 -or + $_.Exception.InnerException.HResult -eq -2147024894) { + exit 10 + } + throw + } + + $actions = $task.Definition.Actions + if ($actions.Count -ne 1) { throw 'Unexpected task actions' } + $action = $actions.Item(1) + if ($action.Type -ne 0 -or $action.Arguments -cne '--proxy' -or + -not [string]::Equals( + [IO.Path]::GetFullPath($action.Path), + [IO.Path]::GetFullPath($env:WIZARD_UNINSTALL_EXE), + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Task belongs to another installation' + } + + switch ($env:WIZARD_UNINSTALL_OPERATION) { + 'disable' { $task.Enabled = $false } + 'delete' { + # No forced termination: Rust already waited for graceful shutdown. + if ($task.GetInstances(0).Count -ne 0) { + throw 'Proxy task still has running instances; retry uninstall' + } + $folder.DeleteTask($env:WIZARD_UNINSTALL_TASK, 0) + } + default { throw 'Invalid cleanup operation' } + } + exit 0 +} catch { + # Do not emit task XML, settings, or environment values. + [Console]::Error.WriteLine('Task Scheduler cleanup failed.') + exit 1 +} diff --git a/crates/wizard-proxy-daemon/src/windows.rs b/crates/wizard-proxy-daemon/src/windows.rs new file mode 100644 index 0000000..f7a1105 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/windows.rs @@ -0,0 +1,317 @@ +use core::{ptr, time::Duration}; +use std::{ + ffi::OsString, + io::{self, Write as _}, + os::windows::{ + ffi::OsStringExt as _, + io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}, + }, + path::PathBuf, + process::Stdio, +}; +use tokio::process::Command; +use windows_sys::Win32::{ + Foundation::{ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::{ + SystemInformation::GetSystemDirectoryW, + Threading::{CREATE_NO_WINDOW, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}, + }, +}; + +pub(super) async fn create_daemon() -> io::Result<()> { + log::info!("requesting graceful proxy shutdown before task update"); + match wizard_proxy::control::shutdown().await { + Ok(response) => { + tokio::task::spawn_blocking(move || wait_for_exit(response.pid)) + .await + .inspect_err(|error| { + wizard_proxy::diagnostics::error("proxy process exit worker", error); + }) + .map_err(|_| io::Error::other("proxy process exit worker failed"))??; + } + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => {} + Err(error) => return Err(error), + } + + let executable = std::env::current_exe()?; + // The caller must be the executable supporting --proxy, even if renamed. + let executable = executable.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "proxy executable path is not valid Unicode", + ) + })?; + let sid = current_user_sid().await?; + let xml = super::windows_task::definition(&sid, executable)?; + // UTF-16LE with BOM makes the file encoding explicit to schtasks. + let bytes: Vec = core::iter::once(0xfeff) + .chain(xml.encode_utf16()) + .flat_map(u16::to_le_bytes) + .collect(); + let mut file = tempfile::Builder::new() + .prefix("wizard-proxy-") + .suffix(".xml") + .tempfile()?; + file.write_all(&bytes)?; + // schtasks opens XML without write sharing. Close the write handle while + // retaining the path guard so the temporary file is removed after import. + let path = file.into_temp_path(); + + let mut command = Command::new(system_executable("schtasks.exe")?); + command + .args(["/Create", "/TN", &task_name(&sid), "/XML"]) + .arg(&path) + .arg("/F"); + log::info!("registering current user's proxy task"); + run_schtasks(&mut command, "registration").await +} + +// Returns whether this installation had a task, for update-failure recovery. +pub(super) async fn uninstall() -> io::Result { + let sid = current_user_sid().await?; + let executable = std::env::current_exe()?; + if !update_uninstall_task(&sid, &executable, "disable").await? { + // Without a task we cannot establish installation ownership. Do not + // send shutdown to an arbitrary process on the management port. + let _instance = super::acquire_instance_lock().map_err(io::Error::other)?; + return Ok(false); + } + + match wizard_proxy::control::status().await { + Ok(_) => { + let response = wizard_proxy::control::shutdown().await?; + tokio::task::spawn_blocking(move || wait_for_exit(response.pid)) + .await + .map_err(|_| io::Error::other("proxy exit worker failed"))??; + } + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => {} + Err(error) => return Err(error), + } + + let _instance = super::acquire_instance_lock().map_err(io::Error::other)?; + update_uninstall_task(&sid, &executable, "delete").await?; + Ok(true) +} + +async fn update_uninstall_task( + sid: &str, + executable: &std::path::Path, + operation: &str, +) -> io::Result { + let status = tokio::time::timeout( + Duration::from_secs(15), + Command::new(system_executable(r"WindowsPowerShell\v1.0\powershell.exe")?) + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]) + .arg(include_str!("windows-uninstall.ps1")) + .env("WIZARD_UNINSTALL_TASK", task_name(sid)) + .env("WIZARD_UNINSTALL_EXE", executable) + .env("WIZARD_UNINSTALL_OPERATION", operation) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status(), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy task cleanup timed out"))??; + match status.code() { + Some(0) => Ok(true), + Some(10) => Ok(false), + _ => Err(io::Error::other("proxy task cleanup failed")), + } +} + +pub(super) async fn start_daemon() -> io::Result<()> { + let sid = current_user_sid().await?; + let mut command = Command::new(system_executable("schtasks.exe")?); + command.args(["/Run", "/TN", &task_name(&sid)]); + run_schtasks(&mut command, "start").await +} + +pub(super) fn shutdown_signal() -> Result, &'static str> { + use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close}; + + let mut interrupt = ctrl_c() + .inspect_err(|error| wizard_proxy::diagnostics::error("Ctrl+C handler registration", error)) + .map_err(|_| "failed to register Ctrl+C handler")?; + let mut stop = ctrl_break() + .inspect_err(|error| { + wizard_proxy::diagnostics::error("Ctrl+Break handler registration", error); + }) + .map_err(|_| "failed to register Ctrl+Break handler")?; + let mut close = ctrl_close() + .inspect_err(|error| { + wizard_proxy::diagnostics::error("console-close handler registration", error); + }) + .map_err(|_| "failed to register console-close handler")?; + Ok(async move { + tokio::select! { + _ = interrupt.recv() => log::info!("proxy daemon received Ctrl+C"), + _ = stop.recv() => log::info!("proxy daemon received Ctrl+Break"), + _ = close.recv() => log::info!("proxy daemon received console-close"), + } + }) +} + +fn task_name(sid: &str) -> String { + format!("ai.coreinfra.wizard.proxy.{sid}") +} + +async fn run_schtasks(command: &mut Command, operation: &'static str) -> io::Result<()> { + // Dropping the timed-out future terminates the utility, but cannot roll back + // a registration already accepted by Task Scheduler. Never retry automatically. + let status = tokio::time::timeout( + Duration::from_secs(5), + command + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status(), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + format!("proxy task {operation} timed out"), + ) + })??; + if !status.success() { + log::error!( + "proxy task {operation} failed: exit_code={:?}", + status.code() + ); + return Err(io::Error::other(format!( + "proxy task {operation} failed (exit code {:?})", + status.code(), + ))); + } + Ok(()) +} + +#[expect( + unsafe_code, + reason = "wait on an owned Windows process handle without terminating it" +)] +fn wait_for_exit(pid: u32) -> io::Result<()> { + if pid == 0 || pid == std::process::id() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid proxy shutdown PID", + )); + } + // SAFETY: OpenProcess validates the PID. Request only synchronization access. + let raw = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; + if raw.is_null() { + let error = io::Error::last_os_error(); + if error.raw_os_error() == i32::try_from(ERROR_INVALID_PARAMETER).ok() { + log::info!("proxy daemon already exited"); + return Ok(()); + } + return Err(error); + } + // SAFETY: OpenProcess returned a new valid handle; transfer ownership once. + let process = unsafe { OwnedHandle::from_raw_handle(raw) }; + log::info!("waiting for proxy daemon process exit"); + // SAFETY: the owned process handle remains alive throughout this bounded wait. + match unsafe { WaitForSingleObject(process.as_raw_handle(), 10_000) } { + WAIT_OBJECT_0 => { + log::info!("proxy daemon process exited"); + Ok(()) + } + WAIT_TIMEOUT => Err(io::Error::new( + io::ErrorKind::TimedOut, + "proxy daemon did not exit", + )), + _ => Err(io::Error::last_os_error()), + } +} + +#[expect( + unsafe_code, + reason = "resolve the system directory without trusting PATH or environment variables" +)] +fn system_executable(name: &str) -> io::Result { + // SAFETY: a null buffer with zero capacity queries the required size. + let required = unsafe { GetSystemDirectoryW(ptr::null_mut(), 0) }; + if required == 0 { + return Err(io::Error::last_os_error()); + } + let mut buffer = vec![0_u16; required as usize]; + // SAFETY: buffer has capacity for required UTF-16 units. + let written = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), required) }; + if written == 0 { + return Err(io::Error::last_os_error()); + } + if written >= required { + return Err(io::Error::other( + "system directory exceeded its reported size", + )); + } + Ok(PathBuf::from(OsString::from_wide(&buffer[..written as usize])).join(name)) +} + +async fn current_user_sid() -> io::Result { + let output = tokio::time::timeout( + Duration::from_secs(5), + Command::new(system_executable("whoami.exe")?) + .args(["/user", "/fo", "csv", "/nh"]) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "current user SID lookup timed out"))??; + if !output.status.success() { + log::error!( + "current user SID lookup failed: exit_code={:?}", + output.status.code() + ); + return Err(io::Error::other(format!( + "current user SID lookup failed (exit code {:?})", + output.status.code(), + ))); + } + super::windows_task::sid_from_whoami(&output.stdout) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn uninstall_task_ownership_and_idempotency() -> io::Result<()> { + let output = tokio::time::timeout( + Duration::from_secs(60), + Command::new(system_executable(r"WindowsPowerShell\v1.0\powershell.exe")?) + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + ]) + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/windows-uninstall.ps1" + )) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "task cleanup test timed out"))??; + assert!( + output.status.success(), + "task cleanup test failed: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + Ok(()) + } +} diff --git a/crates/wizard-proxy-daemon/src/windows_task.rs b/crates/wizard-proxy-daemon/src/windows_task.rs new file mode 100644 index 0000000..72cc230 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/windows_task.rs @@ -0,0 +1,97 @@ +use std::io; + +// Kept platform-independent so XML escaping is tested without registering tasks. +pub(super) fn definition(sid: &str, executable: &str) -> io::Result { + Ok(format!( + include_str!("windows-task.xml"), + sid = xml_text(sid)?, + executable = xml_text(executable)?, + )) +} + +pub(super) fn sid_from_whoami(output: &[u8]) -> io::Result { + // whoami /user /fo csv /nh ends its row with a quoted ASCII SID. Ignore + // the account name: it may contain commas, escaped quotes or non-UTF-8 bytes. + let output = String::from_utf8_lossy(output); + let sid = output + .trim() + .rsplit_once(',') + .and_then(|(_, value)| value.strip_prefix('"')) + .and_then(|value| value.strip_suffix('"')) + .filter(|sid| { + sid.strip_prefix("S-1-").is_some_and(|rest| { + rest.split('-') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + }) + }) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid current user SID response", + ) + })?; + Ok(sid.to_owned()) +} + +fn xml_text(value: &str) -> io::Result { + let mut escaped = String::new(); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + '\t' + | '\n' + | '\r' + | '\u{20}'..='\u{d7ff}' + | '\u{e000}'..='\u{fffd}' + | '\u{10000}'..='\u{10ffff}' => escaped.push(character), + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid XML character in proxy task definition", + )); + } + } + } + Ok(escaped) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn whoami_sid_does_not_depend_on_account_name_encoding_or_csv_escaping() { + let output = b"\"domain\\na\xffme, \"\"quoted\"\"\",\"S-1-5-21-123-456-789-1001\"\r\n"; + assert_eq!( + sid_from_whoami(output).expect("SID"), + "S-1-5-21-123-456-789-1001" + ); + for invalid in [ + &b""[..], + &b"\"name\",\"not-a-sid\""[..], + &b"\"name\",\"S-1-5-\""[..], + &b"\"name\",\"S-1-5\"extra"[..], + ] { + assert!(sid_from_whoami(invalid).is_err()); + } + } + + #[test] + fn executable_is_xml_text_not_markup() { + let xml = definition("S-1-5-21-123", r#"C:\工具 & \Wizard "test'.exe"#) + .expect("task definition"); + assert!( + xml.contains( + r"C:\工具 & <dev>\Wizard "test'.exe", + ) + ); + let injection = + definition("S-1-5-21-123", "injected").expect("escaped markup"); + assert_eq!(injection.matches("").count(), 1); + assert!(definition("S-1-5-21-123", "invalid\0path").is_err()); + } +} diff --git a/crates/wizard-proxy-daemon/tests/windows-uninstall.ps1 b/crates/wizard-proxy-daemon/tests/windows-uninstall.ps1 new file mode 100644 index 0000000..63d0611 --- /dev/null +++ b/crates/wizard-proxy-daemon/tests/windows-uninstall.ps1 @@ -0,0 +1,55 @@ +# Run on Windows: powershell -NoProfile -ExecutionPolicy Bypass -File +# Only creates/deletes a uniquely named test task; never touches Wizard settings. +$ErrorActionPreference = 'Stop' +$script = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '../src/windows-uninstall.ps1')) +$scheduler = New-Object -ComObject Schedule.Service +$scheduler.Connect() +$folder = $scheduler.GetFolder('\') +$name = 'wizard-uninstall-test-' + [Guid]::NewGuid().ToString('N') +$env:WIZARD_UNINSTALL_TASK = $name +$env:WIZARD_UNINSTALL_EXE = "$env:SystemRoot\System32\cmd.exe" + +function Invoke-Cleanup($operation, $expected) { + $env:WIZARD_UNINSTALL_OPERATION = $operation + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -Command $script + if ($LASTEXITCODE -ne $expected) { throw "Unexpected exit code for $operation : $LASTEXITCODE" } +} + +try { + Invoke-Cleanup disable 10 + Invoke-Cleanup delete 10 + + $definition = $scheduler.NewTask(0) + $definition.Principal.UserId = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $definition.Principal.LogonType = 3 + $definition.Settings.ExecutionTimeLimit = 'PT1M' + $action = $definition.Actions.Create(0) + $action.Path = $env:WIZARD_UNINSTALL_EXE + $action.Arguments = '--proxy' + $task = $folder.RegisterTaskDefinition($name, $definition, 6, $null, $null, 3) + + $env:WIZARD_UNINSTALL_EXE = "$env:SystemRoot\System32\notepad.exe" + Invoke-Cleanup disable 1 + Invoke-Cleanup delete 1 + if (-not $folder.GetTask($name).Enabled) { throw 'Foreign task was disabled' } + + $env:WIZARD_UNINSTALL_EXE = $action.Path + Invoke-Cleanup disable 0 + if ($folder.GetTask($name).Enabled) { throw 'Task was not disabled' } + Invoke-Cleanup disable 0 + Invoke-Cleanup delete 0 + Invoke-Cleanup delete 10 + + # Wrong arguments must not be treated as our proxy, even for the same EXE. + $action.Arguments = '/c exit 0' + $task = $folder.RegisterTaskDefinition($name, $definition, 6, $null, $null, 3) + Invoke-Cleanup disable 1 + Invoke-Cleanup delete 1 + Write-Host 'Windows uninstall task tests passed.' +} finally { + try { $folder.DeleteTask($name, 0) } catch { + if ($_.Exception.HResult -ne -2147024894 -and + $_.Exception.InnerException.HResult -ne -2147024894) { throw } + } + Remove-Item Env:WIZARD_UNINSTALL_TASK, Env:WIZARD_UNINSTALL_EXE, Env:WIZARD_UNINSTALL_OPERATION -ErrorAction SilentlyContinue +} diff --git a/crates/wizard-proxy/Cargo.toml b/crates/wizard-proxy/Cargo.toml index 4dae19e..a818bed 100644 --- a/crates/wizard-proxy/Cargo.toml +++ b/crates/wizard-proxy/Cargo.toml @@ -11,13 +11,16 @@ publish.workspace = true workspace = true [dependencies] +log.workspace = true base64.workspace = true http-body-util.workspace = true hyper = { workspace = true, features = ["client", "server", "http1"] } hyper-util = { workspace = true, features = ["tokio"] } -log.workspace = true rustls-native-certs.workspace = true socket2.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true tokio = { workspace = true, features = ["rt", "net", "io-util", "macros"] } tokio-rustls.workspace = true url.workspace = true +wizard-settings.workspace = true diff --git a/crates/wizard-proxy/src/control.rs b/crates/wizard-proxy/src/control.rs new file mode 100644 index 0000000..d6220ab --- /dev/null +++ b/crates/wizard-proxy/src/control.rs @@ -0,0 +1,151 @@ +//! Unauthenticated loopback HTTP management of the standalone server. +//! These endpoints are not provided by GUI-owned standalone listeners. + +use core::time::Duration; +use http_body_util::{BodyExt as _, Empty, Limited}; +use hyper::{Request, body::Bytes}; +use hyper_util::rt::TokioIo; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::io; +use tokio::net::TcpStream; + +pub(crate) const TIMEOUT: Duration = Duration::from_secs(2); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +const MAX_RESPONSE: usize = 1024; + +/// Current runtime permission to proxy, not upstream health or token validity. +/// Both ports remain bound while the managed server is running. +#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Status { + /// Empty for older responses without a version; current servers report their crate version. + #[serde(default)] + pub version: String, + pub claude_desktop_enabled: bool, + pub chatgpt_enabled: bool, +} + +impl Status { + pub(crate) fn enabled(&self) -> bool { + self.claude_desktop_enabled || self.chatgpt_enabled + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReloadResponse { + pub status: Status, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShutdownResponse { + pub pid: u32, +} + +#[derive(Clone, Copy)] +pub(crate) enum Command { + Status, + Reload, + Shutdown, +} + +/// An unreachable server is an error, never a disabled-proxy report. +pub async fn status() -> io::Result { + request(crate::CLAUDE_DESKTOP_ADDRESS, Command::Status).await +} + +/// Once accepted, Reload completes even if this exchange times out or disconnects. +/// Do not automatically retry: after a timeout the result may be unknown. +pub async fn reload() -> io::Result { + request(crate::CLAUDE_DESKTOP_ADDRESS, Command::Reload).await +} + +/// Request graceful exit without changing saved settings. The reply precedes +/// process exit; callers must wait for the returned PID separately. Do not retry +/// automatically after an ambiguous exchange failure. +pub async fn shutdown() -> io::Result { + request(crate::CLAUDE_DESKTOP_ADDRESS, Command::Shutdown).await +} + +pub(crate) async fn request(address: &str, command: Command) -> io::Result { + tokio::time::timeout(REQUEST_TIMEOUT, async { + // Connect directly, without HTTP_PROXY, redirects, or retries. + let stream = TcpStream::connect(address).await?; + let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) + .await + .inspect_err(|error| crate::diagnostics::error("management HTTP handshake", error)) + .map_err(io::Error::other)?; + let (method, path) = match command { + Command::Status => ("GET", "/status"), + Command::Reload => ("POST", "/reload"), + Command::Shutdown => ("POST", "/shutdown"), + }; + let request = Request::builder() + .method(method) + .uri(path) + .header("Host", address) + .header("Connection", "close") + .header("Content-Length", "0") + .body(Empty::::new()) + .map_err(|_| io::Error::other("cannot construct management request"))?; + let exchange = async { + let response = sender + .send_request(request) + .await + .inspect_err(|error| crate::diagnostics::error("management HTTP request", error)) + .map_err(io::Error::other)?; + if response.status() != hyper::StatusCode::OK { + log::error!( + "management HTTP request rejected: status={}", + response.status().as_u16() + ); + return Err(io::Error::other("management HTTP request rejected")); + } + let bytes = Limited::new(response.into_body(), MAX_RESPONSE) + .collect() + .await + .inspect_err(|error| { + crate::diagnostics::error("management response body", error.as_ref()); + }) + .map_err(io::Error::other)? + .to_bytes(); + serde_json::from_slice(&bytes).map_err(|_| { + log::error!("invalid management JSON response"); + io::Error::other("invalid management JSON response") + }) + }; + let (connection, result) = tokio::join!(connection, exchange); + if let Err(error) = connection { + crate::diagnostics::error("management HTTP connection", &error); + } + result + }) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? +} + +#[cfg(test)] +mod tests { + use super::Status; + + #[test] + fn legacy_status_has_unknown_version_but_malformed_status_is_rejected() { + let status: Status = + serde_json::from_str(r#"{"claude_desktop_enabled":true,"chatgpt_enabled":false}"#) + .expect("legacy status"); + assert!(status.version.is_empty()); + assert!(status.claude_desktop_enabled); + assert!(!status.chatgpt_enabled); + + for response in [ + r#"{"version":null,"claude_desktop_enabled":true,"chatgpt_enabled":false}"#, + r#"{"version":42,"claude_desktop_enabled":true,"chatgpt_enabled":false}"#, + r#"{"claude_desktop_enabled":true}"#, + r#"{"claude_desktop_enabled":true,"chatgpt_enabled":false,"unexpected":true}"#, + ] { + assert!(serde_json::from_str::(response).is_err()); + } + } +} diff --git a/crates/wizard-proxy/src/diagnostics.rs b/crates/wizard-proxy/src/diagnostics.rs new file mode 100644 index 0000000..8cb09e5 --- /dev/null +++ b/crates/wizard-proxy/src/diagnostics.rs @@ -0,0 +1,83 @@ +//! Error diagnostics without request data, credentials, or destinations. + +use core::{error::Error, fmt::Write as _}; +use std::io; + +/// Describe typed failures and native OS errors, never arbitrary error messages. +/// Custom error strings can contain URLs, credentials, or certificate identities. +pub fn summary(error: &(dyn Error + 'static)) -> String { + let mut description = String::new(); + let mut current = Some(error); + for _ in 0..32 { + let Some(error) = current else { break }; + if let Some(error) = error.downcast_ref::() { + let _ = write!(description, " kind={:?}", error.kind()); + if let Some(code) = error.raw_os_error() { + // Reconstruct from the numeric code, excluding caller-supplied context. + let _ = write!( + description, + " os_error={code} ({})", + io::Error::from_raw_os_error(code) + ); + } + current = error + .get_ref() + .map(|error| error as &dyn Error) + .or_else(|| error.source()); + } else { + if let Some(error) = error.downcast_ref::() { + let _ = write!( + description, + " http_error=true parse={} incomplete={} closed={} timeout={} user={}", + error.is_parse(), + error.is_incomplete_message(), + error.is_closed(), + error.is_timeout(), + error.is_user() + ); + } else if let Some(error) = error.downcast_ref::() { + let category = match error { + tokio_rustls::rustls::Error::InvalidCertificate(_) => "invalid certificate", + tokio_rustls::rustls::Error::NoCertificatesPresented => "missing certificate", + tokio_rustls::rustls::Error::AlertReceived(_) => "peer alert", + _ => "TLS protocol or configuration error", + }; + let _ = write!(description, " tls_error={category}"); + } else if let Some(error) = error.downcast_ref::() { + let _ = write!( + description, + " task_error=true panic={} cancelled={}", + error.is_panic(), + error.is_cancelled() + ); + } + current = error.source(); + } + } + if description.is_empty() { + description.push_str(" non-OS error; unstructured details withheld"); + } + description +} + +/// Log a failed operation with safe diagnostics. Operation names must be static. +pub fn error(operation: &'static str, error: &(dyn Error + 'static)) { + log::error!("{operation} failed:{}", summary(error)); +} + +#[cfg(test)] +mod tests { + use super::summary; + use std::io; + + #[test] + fn wrapped_os_errors_remain_visible_without_exposing_custom_messages() { + let error = io::Error::other(io::Error::from_raw_os_error(24)); + assert!(summary(&error).contains("os_error=24")); + let error = io::Error::other("secret token and destination must not be logged"); + let message = summary(&error); + assert!(message.contains("kind=Other")); + assert!(!message.contains("secret")); + assert!(!message.contains("destination")); + } +} diff --git a/crates/wizard-proxy/src/http.rs b/crates/wizard-proxy/src/http.rs index 40fe237..f62f5a1 100644 --- a/crates/wizard-proxy/src/http.rs +++ b/crates/wizard-proxy/src/http.rs @@ -1,5 +1,8 @@ use crate::{ + ProxySlot, ServerState, + control::{Command, TIMEOUT}, destination::{Route, route}, + diagnostics, run_proxy_session, upstream::Upstream, }; use alloc::sync::Arc; @@ -10,7 +13,7 @@ use hyper::header::{ ALLOW, CONNECTION, CONTENT_LENGTH, HOST, HeaderName, HeaderValue, PROXY_AUTHORIZATION, TRANSFER_ENCODING, USER_AGENT, }; -use hyper::http::uri::Authority; +use hyper::http::uri::{Authority, PathAndQuery}; use hyper::service::service_fn; use hyper::upgrade::{OnUpgrade, Upgraded}; use hyper::{HeaderMap, Method, Request, Response, StatusCode, Uri}; @@ -18,7 +21,7 @@ use hyper_util::rt::{TokioIo, TokioTimer}; use std::io; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; use tokio::net::TcpStream; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; #[cfg(test)] @@ -26,8 +29,8 @@ use tokio::time::timeout; mod tests; const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); -/// After one side of a tunnel closes cleanly, the other may finish for this long. A client -/// that half-closes and then vanishes would otherwise leave a silent remote open forever. +// Allow the remaining direction to finish after a clean half-close, but do not +// retain a silent peer forever. This is not an idle timeout on an open tunnel. const HALF_CLOSE_TIMEOUT: Duration = Duration::from_secs(if cfg!(test) { 1 } else { 30 }); type Body = UnsyncBoxBody; @@ -43,55 +46,198 @@ struct Tunnel { remote: Remote, } -pub(crate) async fn serve_client(stream: TcpStream, upstream: Arc) { +// The HTTP service announces the request kind before starting any proxy I/O. +// The connection task then supplies an upstream or a management response. +enum Dispatch { + Proxy(oneshot::Sender>>), + Management(Command, oneshot::Sender>), +} + +pub(crate) async fn serve_client( + stream: TcpStream, + slot: Arc, + management: Option>, +) { + let managed = management.is_some(); + let address = match stream.local_addr() { + Ok(address) => Some(address.to_string()), + Err(error) => { + diagnostics::error("proxy local socket address lookup", &error); + None + } + }; + let (dispatch, mut requests) = mpsc::channel(1); let (driver_sender, mut drivers) = mpsc::channel::(1); let (tunnel_sender, mut tunnels) = mpsc::channel::(1); - let service = service_fn(move |request| { - let upstream = Arc::clone(&upstream); + let service = service_fn(move |request: Request| { + let dispatch = dispatch.clone(); + let address = address.clone(); let driver_sender = driver_sender.clone(); let tunnel_sender = tunnel_sender.clone(); - async move { Ok::<_, Infallible>(handle(request, upstream, driver_sender, tunnel_sender).await) } + async move { + let response = if managed + && request.method() != Method::CONNECT + && request.uri().scheme().is_none() + && request.uri().authority().is_none() + { + match management_command(&request, address.as_deref()) { + Err((status, allow)) => { + let mut response = error_response(status); + if let Some(allow) = allow { + response + .headers_mut() + .insert(ALLOW, HeaderValue::from_static(allow)); + } + response + } + Ok(command) => { + let (reply, response) = oneshot::channel(); + if dispatch + .send(Dispatch::Management(command, reply)) + .await + .is_err() + { + return Ok::<_, Infallible>(error_response( + StatusCode::SERVICE_UNAVAILABLE, + )); + } + response + .await + .unwrap_or_else(|_| error_response(StatusCode::SERVICE_UNAVAILABLE)) + } + } + } else { + let (ready, upstream) = oneshot::channel(); + if dispatch.send(Dispatch::Proxy(ready)).await.is_err() { + return Ok::<_, Infallible>(error_response(StatusCode::SERVICE_UNAVAILABLE)); + } + handle( + request, + upstream.await.unwrap_or(None), + driver_sender, + tunnel_sender, + ) + .await + }; + Ok::<_, Infallible>(response) + } }); let mut builder = hyper::server::conn::http1::Builder::new(); - builder - .keep_alive(false) - // Enables hyper's default 30 s timeout for clients that never send a request head. - .timer(TokioTimer::new()); + builder.keep_alive(false).timer(TokioTimer::new()); + if managed { + builder + .timer(TokioTimer::new()) + .header_read_timeout(TIMEOUT); + } let connection = builder .serve_connection(TokioIo::new(stream), service) .with_upgrades(); - tokio::pin!(connection); - - // Drive both sides in this one task. Cancelling it drops both sockets, with no - // detached upstream tasks. Each client connection serves one request or tunnel. - // A direct CONNECT has no HTTP client driver; it hands us a TCP socket - // when the server connection upgrades. - let driver = tokio::select! { - _ = &mut connection => None, - driver = drivers.recv() => driver, + // Own the complete connection future, not just its pinned reference, so the + // generation wrapper can drop every socket before releasing its read guard. + let mut session = Box::pin(async move { + tokio::pin!(connection); + let driver = tokio::select! { + result = &mut connection => { + if let Err(error) = result { + diagnostics::error("client HTTP connection", &error); + } + None + }, + driver = drivers.recv() => driver, + }; + if let Some(mut driver) = driver { + tokio::select! { + result = &mut connection => { + if let Err(error) = result { + diagnostics::error("client HTTP connection", &error); + } + }, + result = &mut driver => { + if let Err(error) = result { + diagnostics::error("remote HTTP connection", &error); + } + if let Err(error) = (&mut connection).await { + diagnostics::error("client HTTP connection", &error); + } + }, + } + } + if let Ok(tunnel) = tunnels.try_recv() { + let client = match tunnel.client.await { + Ok(client) => client, + Err(error) => { + diagnostics::error("client CONNECT upgrade", &error); + return; + } + }; + let client = TokioIo::new(client); + let result = match tunnel.remote { + Remote::Direct(remote) => relay(client, remote).await, + Remote::Upstream(remote) => relay(client, TokioIo::new(remote)).await, + }; + if let Err(error) = result { + diagnostics::error("proxy tunnel transfer", &error); + } + } + }); + let request = tokio::select! { + request = requests.recv() => request, + () = &mut session => return, }; - if let Some(mut driver) = driver { - tokio::select! { - _ = &mut connection => {}, - _ = &mut driver => { let _ = (&mut connection).await; }, + match request { + Some(Dispatch::Proxy(ready)) => { + let generation = slot.current.read().await.clone(); + if let Some(generation) = generation { + let upstream = Arc::clone(&generation.upstream); + run_proxy_session(generation, async move { + if ready.send(Some(upstream)).is_err() { + log::error!("proxy dispatch receiver closed before generation delivery"); + } + session.await; + }) + .await; + } else { + if ready.send(None).is_err() { + log::error!("proxy dispatch receiver closed before disabled response"); + } + session.await; + } } - } - - if let Ok(tunnel) = tunnels.try_recv() - && let Ok(client) = tunnel.client.await - { - let client = TokioIo::new(client); - match tunnel.remote { - Remote::Direct(remote) => relay(client, remote).await, - Remote::Upstream(remote) => relay(client, TokioIo::new(remote)).await, + Some(Dispatch::Management(command, reply)) => { + let Some(state) = management else { return }; + let _active = state.management.read().await; + // Ownership in this inner scope ensures socket cleanup precedes the + // management guard's release, including on task cancellation. + { + let session = session; + let stopping = *state.stopping.borrow(); + let response = if stopping { + error_response(StatusCode::SERVICE_UNAVAILABLE) + } else { + match command { + Command::Status => json_response(&state.status_with_lock().await), + Command::Reload => json_response(&state.reload_with_lock().await), + Command::Shutdown => json_response(&state.shutdown_with_lock().await), + } + }; + // Do not race accepted work against client disconnects. Only the + // reply attempt is bounded; shutdown waits on the active guard. + if reply.send(response).is_err() { + log::error!("management response receiver closed"); + } + if timeout(TIMEOUT, session).await.is_err() { + log::error!("management reply timed out"); + } + } } + None => {} } } -/// Copy both directions until both close; an error, or [`HALF_CLOSE_TIMEOUT`] after -/// the first clean close, drops both sides. TLS and application protocols inside -/// CONNECT are opaque bytes. -async fn relay(client: impl AsyncRead + AsyncWrite, remote: impl AsyncRead + AsyncWrite) { +async fn relay( + client: impl AsyncRead + AsyncWrite, + remote: impl AsyncRead + AsyncWrite, +) -> io::Result<()> { let (mut client_read, mut client_write) = tokio::io::split(client); let (mut remote_read, mut remote_write) = tokio::io::split(remote); let upload = async { @@ -103,22 +249,90 @@ async fn relay(client: impl AsyncRead + AsyncWrite, remote: impl AsyncRead + Asy client_write.shutdown().await }; tokio::pin!(upload, download); - tokio::select! { - result = &mut upload => if result.is_ok() { - let _ = timeout(HALF_CLOSE_TIMEOUT, download).await; + let remaining = tokio::select! { + result = &mut upload => { + result?; + timeout(HALF_CLOSE_TIMEOUT, download).await }, - result = &mut download => if result.is_ok() { - let _ = timeout(HALF_CLOSE_TIMEOUT, upload).await; + result = &mut download => { + result?; + timeout(HALF_CLOSE_TIMEOUT, upload).await }, + }; + remaining.map_err(|_| { + log::error!("proxy tunnel half-close deadline expired"); + io::Error::new( + io::ErrorKind::TimedOut, + "proxy tunnel half-close deadline expired", + ) + })? +} + +fn management_command( + request: &Request, + address: Option<&str>, +) -> Result)> { + if request + .headers() + .get(HOST) + .and_then(|host| host.to_str().ok()) + != address + || address.is_none() + || request.headers().contains_key(TRANSFER_ENCODING) + || request + .headers() + .get(CONTENT_LENGTH) + .is_some_and(|length| length != "0") + { + return Err((StatusCode::BAD_REQUEST, None)); + } + let (method, command) = match request.uri().path_and_query().map(PathAndQuery::as_str) { + Some("/status") => (Method::GET, Command::Status), + Some("/reload") => (Method::POST, Command::Reload), + Some("/shutdown") => (Method::POST, Command::Shutdown), + _ => return Err((StatusCode::NOT_FOUND, None)), + }; + if request.method() != method { + let allow = match command { + Command::Status => "GET", + Command::Reload | Command::Shutdown => "POST", + }; + return Err((StatusCode::METHOD_NOT_ALLOWED, Some(allow))); } + Ok(command) +} + +fn json_response(value: &impl serde::Serialize) -> Response { + let Ok(bytes) = serde_json::to_vec(value) else { + return error_response(StatusCode::INTERNAL_SERVER_ERROR); + }; + let mut response = Response::new( + Full::new(Bytes::from(bytes)) + .map_err(|never| match never {}) + .boxed_unsync(), + ); + response + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("close")); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); + response } async fn handle( mut request: Request, - upstream: Arc, + upstream: Option>, driver: mpsc::Sender, tunnels: mpsc::Sender, ) -> Response { + let Some(upstream) = upstream else { + return error_response(StatusCode::SERVICE_UNAVAILABLE); + }; + if request.method() == Method::CONNECT { if !valid_connect_target(request.uri()) || request.headers().contains_key(TRANSFER_ENCODING) @@ -141,7 +355,10 @@ async fn handle( let remote = match result { Ok(Ok(remote)) => remote, Ok(Err(status)) => return error_response(status), - Err(_) => return error_response(StatusCode::GATEWAY_TIMEOUT), + Err(_) => { + log::error!("CONNECT establishment timed out"); + return error_response(StatusCode::GATEWAY_TIMEOUT); + } }; let tunnel = Tunnel { client: hyper::upgrade::on(&mut request), @@ -210,15 +427,25 @@ async fn handle( }; let mut sender = match timeout(CONNECT_TIMEOUT, connection).await { Ok(Ok(sender)) => sender, - Ok(Err(_)) => return error_response(StatusCode::BAD_GATEWAY), - Err(_) => return error_response(StatusCode::GATEWAY_TIMEOUT), + Ok(Err(error)) => { + diagnostics::error("direct HTTP connection establishment", &error); + return error_response(StatusCode::BAD_GATEWAY); + } + Err(_) => { + log::error!("direct HTTP connection establishment timed out"); + return error_response(StatusCode::GATEWAY_TIMEOUT); + } }; // Do not impose the connection deadline on streaming request/response bodies. let response = sender .send_request(request.map(BodyExt::boxed_unsync)) .await; - let Ok(mut response) = response else { - return error_response(StatusCode::BAD_GATEWAY); + let mut response = match response { + Ok(response) => response, + Err(error) => { + diagnostics::error("direct HTTP request", &error); + return error_response(StatusCode::BAD_GATEWAY); + } }; if response.status() == StatusCode::PROXY_AUTHENTICATION_REQUIRED || response.status() == StatusCode::SWITCHING_PROTOCOLS @@ -265,7 +492,10 @@ async fn connect_remote( connect_tunnel(target, upstream, driver) .await .map(Remote::Upstream) - .map_err(|_| StatusCode::BAD_GATEWAY) + .map_err(|error| { + diagnostics::error("upstream CONNECT establishment", &error); + StatusCode::BAD_GATEWAY + }) } Route::Direct => { let authority = target.authority().ok_or(StatusCode::BAD_REQUEST)?; @@ -273,7 +503,10 @@ async fn connect_remote( connect_direct(authority, port) .await .map(Remote::Direct) - .map_err(|_| StatusCode::BAD_GATEWAY) + .map_err(|error| { + diagnostics::error("direct CONNECT establishment", &error); + StatusCode::BAD_GATEWAY + }) } } } @@ -323,6 +556,10 @@ async fn connect_tunnel( .await .map_err(io::Error::other)?; if !response.status().is_success() { + log::error!( + "upstream rejected CONNECT: status={}", + response.status().as_u16() + ); return Err(io::Error::other("upstream rejected CONNECT")); } hyper::upgrade::on(&mut response) @@ -367,6 +604,7 @@ fn empty_body() -> Body { } fn error_response(status: StatusCode) -> Response { + log::warn!("proxy request rejected: status={}", status.as_u16()); // Never expose upstream response bodies, credentials, or request details in errors. let message = status.canonical_reason().unwrap_or("Proxy error"); let mut response = Response::new( diff --git a/crates/wizard-proxy/src/http_test.rs b/crates/wizard-proxy/src/http_test.rs index 69b5c36..555d2b3 100644 --- a/crates/wizard-proxy/src/http_test.rs +++ b/crates/wizard-proxy/src/http_test.rs @@ -1,4 +1,4 @@ -use crate::{Proxy, serve, upstream}; +use crate::{Proxy, ProxySlot, run_proxy_session, spawn, upstream}; use alloc::sync::Arc; use core::{net::SocketAddr, time::Duration}; use hyper::http::uri::Authority; @@ -6,7 +6,6 @@ use socket2::SockRef; use std::io; use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{oneshot, watch}; use tokio::time::timeout; use tokio_rustls::rustls::{ClientConfig, RootCertStore}; @@ -20,26 +19,43 @@ async fn start_proxy() -> io::Result<(SocketAddr, Proxy)> { .expect("TLS versions") .with_root_certificates(RootCertStore::empty()) .with_no_client_auth(); - let upstream = Arc::new(upstream::Upstream::new( - tls, - upstream::authorization("test-token")?, - )); + let slot = Arc::new(ProxySlot::default()); + slot.set_token(Some("test-token"), &tls).await?; let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; - let (shutdown, receiver) = oneshot::channel(); - let (finished, stopped) = watch::channel(()); - let task = tokio::spawn(async move { - let _finished = finished; - serve(listener, address, upstream, receiver).await; - }); - Ok(( - address, - Proxy { - shutdown, - task, - stopped, - }, - )) + Ok((address, spawn(listener, slot, tls, None))) +} + +#[tokio::test] +async fn half_closed_tunnel_to_silent_remote_closes_after_timeout() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let origin = TcpListener::bind("127.0.0.1:0").await?; + let target = origin.local_addr()?; + let (address, proxy) = start_proxy().await?; + let mut client = TcpStream::connect(address).await?; + client + .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) + .await?; + let (mut remote, _) = origin.accept().await?; + assert!(read_head(&mut client).await?.starts_with("HTTP/1.1 200 ")); + client.shutdown().await?; + let mut byte = [0]; + assert_eq!(remote.read(&mut byte).await?, 0); + assert_eq!(client.read(&mut byte).await?, 0); + proxy.stop().await + }) + .await + .expect("half-closed tunnel stayed open") +} + +#[tokio::test] +async fn direct_connections_use_tcp_keepalive() -> io::Result<()> { + let origin = TcpListener::bind("127.0.0.1:0").await?; + let authority = + Authority::try_from(origin.local_addr()?.to_string()).map_err(io::Error::other)?; + let stream = super::connect_direct(&authority, 80).await?; + assert!(SockRef::from(&stream).keepalive()?); + Ok(()) } async fn read_head(stream: &mut (impl AsyncRead + Unpin)) -> io::Result { @@ -186,29 +202,6 @@ async fn direct_connect_preserves_buffered_bytes_and_half_close() -> io::Result< }).await.expect("direct CONNECT timed out") } -#[tokio::test] -async fn half_closed_tunnel_to_silent_remote_closes_after_timeout() -> io::Result<()> { - timeout(Duration::from_secs(5), async { - let origin = TcpListener::bind("127.0.0.1:0").await?; - let target = origin.local_addr()?; - let (address, proxy) = start_proxy().await?; - let mut client = TcpStream::connect(address).await?; - client - .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) - .await?; - // The remote stays open and silent after the client's FIN. - let (mut remote, _) = origin.accept().await?; - assert!(read_head(&mut client).await?.starts_with("HTTP/1.1 200 ")); - client.shutdown().await?; - let mut byte = [0]; - assert_eq!(remote.read(&mut byte).await?, 0); - assert_eq!(client.read(&mut byte).await?, 0); - proxy.stop().await - }) - .await - .expect("half-closed tunnel stayed open") -} - #[tokio::test] async fn stopping_proxy_closes_both_direct_tunnel_sockets() -> io::Result<()> { timeout(Duration::from_secs(5), async { @@ -257,6 +250,9 @@ async fn malformed_destination_is_rejected_before_dialing() -> io::Result<()> { timeout(Duration::from_secs(5), async { let (address, proxy) = start_proxy().await?; for request in [ + // Standalone (GUI-owned) listeners never expose management routes. + "GET /status HTTP/1.1\r\nHost: localhost\r\n\r\n", + "POST /reload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n", "CONNECT example.com HTTP/1.1\r\nHost: example.com\r\n\r\n", "CONNECT 127.0.0.1:0 HTTP/1.1\r\nHost: 127.0.0.1:0\r\n\r\n", "CONNECT user@example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", @@ -275,12 +271,138 @@ async fn malformed_destination_is_rejected_before_dialing() -> io::Result<()> { }).await.expect("invalid destination timed out") } -#[tokio::test] -async fn direct_connections_use_tcp_keepalive() -> io::Result<()> { - let origin = TcpListener::bind("127.0.0.1:0").await?; - let authority = - Authority::try_from(origin.local_addr()?.to_string()).map_err(io::Error::other)?; - let stream = super::connect_direct(&authority, 80).await?; - assert!(SockRef::from(&stream).keepalive()?); +async fn open_tunnel( + address: SocketAddr, + origin: &TcpListener, +) -> io::Result<(TcpStream, TcpStream)> { + let target = origin.local_addr()?; + let mut client = TcpStream::connect(address).await?; + client + .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) + .await?; + let (remote, _) = origin.accept().await?; + assert!(read_head(&mut client).await?.starts_with("HTTP/1.1 200 ")); + Ok((client, remote)) +} + +async fn exchange(client: &mut TcpStream, remote: &mut TcpStream) -> io::Result<()> { + client.write_all(b"ping").await?; + let mut bytes = [0; 4]; + remote.read_exact(&mut bytes).await?; + assert_eq!(&bytes, b"ping"); + remote.write_all(b"pong").await?; + client.read_exact(&mut bytes).await?; + assert_eq!(&bytes, b"pong"); Ok(()) } + +#[tokio::test] +async fn token_changes_drain_sessions_but_preserve_the_listener() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let origin = TcpListener::bind("127.0.0.1:0").await?; + let (address, mut proxy) = start_proxy().await?; + let (mut client, mut remote) = open_tunnel(address, &origin).await?; + + proxy.set_token(Some("test-token")).await?; + exchange(&mut client, &mut remote).await?; + // Failed preparation must not publish a replacement or cancel sessions. + assert!(proxy.set_token(Some("")).await.is_err()); + exchange(&mut client, &mut remote).await?; + + proxy.set_token(Some("replacement-token")).await?; + let mut byte = [0]; + assert_eq!(client.read(&mut byte).await?, 0); + assert_eq!(remote.read(&mut byte).await?, 0); + assert!( + proxy + .slot + .current + .read() + .await + .as_ref() + .is_some_and(|generation| { + generation.upstream.authorization + == upstream::authorization("replacement-token") + .expect("fixture authorization") + }) + ); + let (mut client, mut remote) = open_tunnel(address, &origin).await?; + exchange(&mut client, &mut remote).await?; + + proxy.set_token(None).await?; + assert_eq!(client.read(&mut byte).await?, 0); + assert_eq!(remote.read(&mut byte).await?, 0); + assert!(!proxy.task.is_finished()); + assert!(TcpListener::bind(address).await.is_err()); + let target = origin.local_addr()?; + for request in [ + format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"), + format!("GET http://{target}/ HTTP/1.1\r\nHost: {target}\r\n\r\n"), + ] { + let mut client = TcpStream::connect(address).await?; + client.write_all(request.as_bytes()).await?; + assert!(read_head(&mut client).await?.starts_with("HTTP/1.1 503 ")); + } + + proxy.set_token(Some("enabled-again")).await?; + let (mut client, mut remote) = open_tunnel(address, &origin).await?; + exchange(&mut client, &mut remote).await?; + proxy.stop().await + }) + .await + .expect("token update timed out") +} + +#[tokio::test] +async fn retired_generation_cannot_start_a_late_session() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let (_, mut proxy) = start_proxy().await?; + let old = proxy + .slot + .current + .read() + .await + .clone() + .expect("enabled proxy"); + proxy.set_token(Some("replacement-token")).await?; + + let mut polled = false; + run_proxy_session(old, async { polled = true }).await; + assert!(!polled, "a retired generation must not perform proxy I/O"); + proxy.stop().await + }) + .await + .expect("late session timed out") +} + +#[tokio::test] +async fn token_update_waits_for_session_future_to_be_dropped() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let (_, mut proxy) = start_proxy().await?; + let old = proxy + .slot + .current + .read() + .await + .clone() + .expect("enabled proxy"); + let (started, ready) = tokio::sync::oneshot::channel(); + let (held, mut released) = tokio::sync::oneshot::channel::<()>(); + let task = tokio::spawn(run_proxy_session(old, async move { + let _held = held; + let _ = started.send(()); + core::future::pending::<()>().await; + })); + ready.await.map_err(io::Error::other)?; + + proxy.set_token(None).await?; + assert!(matches!( + released.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Closed) + )); + task.await.map_err(io::Error::other)?; + proxy.stop().await + }) + .await + .expect("session cleanup timed out") +} diff --git a/crates/wizard-proxy/src/lib.rs b/crates/wizard-proxy/src/lib.rs index b606ec0..957e9b9 100644 --- a/crates/wizard-proxy/src/lib.rs +++ b/crates/wizard-proxy/src/lib.rs @@ -3,27 +3,31 @@ //! return 405 with Allow: CONNECT); unlisted ports return 403 for every method. //! Unmatched domains and explicit IP addresses connect directly, without proxy credentials. //! Malformed destinations are rejected before opening a connection. -//! Failed accepts, e.g. while file descriptors are exhausted, are retried: the listener -//! stops only on request. Outgoing sockets use TCP keepalive, so a peer that vanishes -//! without closing does not hold a tunnel open forever; neither does a half-closed tunnel. //! Dropping the handle requests shutdown; use [`Proxy::stop`] to wait for all sockets to close. extern crate alloc; use alloc::sync::Arc; -use core::{net::SocketAddr, time::Duration}; +use core::time::Duration; use socket2::{SockRef, TcpKeepalive}; use std::env::consts::{ARCH, OS}; use std::io; -use std::time::Instant; use tokio::net::{TcpListener, TcpStream, ToSocketAddrs}; -use tokio::sync::{oneshot, watch}; +use tokio::sync::{Mutex, RwLock, oneshot, watch}; use tokio::task::{JoinHandle, JoinSet}; +use tokio_rustls::rustls::ClientConfig; + +pub mod control; +pub mod diagnostics; mod destination; mod http; mod upstream; +#[cfg(test)] +#[path = "server_test.rs"] +mod server_tests; + pub use upstream::{HOST as UPSTREAM_HOST, PORT as UPSTREAM_PORT, USERNAME as UPSTREAM_USERNAME}; /// Listener of Claude Desktop, with its own token. @@ -31,17 +35,18 @@ pub const CLAUDE_DESKTOP_ADDRESS: &str = "127.0.0.1:33787"; /// Listener of `ChatGPT`, with its own token. pub const CHATGPT_ADDRESS: &str = "127.0.0.1:33788"; -/// While descriptors are exhausted, an immediate retry would fail again at once. -const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(100); -/// Accept failures are logged at most this often, so they cannot rotate older entries out of the log. -const ACCEPT_WARNING_INTERVAL: Duration = Duration::from_secs(60); - -/// Detects a dead peer within about three minutes. Tunnels have no idle timeout, -/// because idle WebSocket and streaming connections are legitimate. +// Idle WebSocket and streaming connections remain valid; detect vanished peers +// with TCP keepalive instead of applying an application-level idle timeout. const KEEPALIVE: TcpKeepalive = TcpKeepalive::new() .with_time(Duration::from_secs(60)) .with_interval(Duration::from_secs(15)); +async fn connect(address: impl ToSocketAddrs) -> io::Result { + let stream = TcpStream::connect(address).await?; + SockRef::from(&stream).set_tcp_keepalive(&KEEPALIVE)?; + Ok(stream) +} + /// `User-Agent` of requests Wizard itself sends, e.g. `CoreInfraAI-Wizard/ (macos; aarch64)`. /// OS and architecture are those of the running binary. #[must_use] @@ -52,15 +57,197 @@ pub fn user_agent() -> String { ) } -/// Owns a running listener. No settings are read or watched by this crate. +/// Owns a standalone listener. This handle does not read or watch settings. pub struct Proxy { shutdown: oneshot::Sender<()>, - task: JoinHandle<()>, + task: JoinHandle>, stopped: watch::Receiver<()>, + slot: Arc, + tls: ClientConfig, +} + +// Shared configuration, never listener handles: tasks must not own themselves +// through this state. No Debug because the slots contain credentials. +struct ServerState { + reload_lock: Mutex<()>, + tls: ClientConfig, + claude_desktop: Arc, + chatgpt: Arc, + // Shutdown waits for accepted commands and their bounded reply attempts. + management: RwLock<()>, + stopping: watch::Sender, +} + +impl ServerState { + fn new(tls: ClientConfig) -> Self { + Self { + reload_lock: Mutex::new(()), + tls, + claude_desktop: Arc::new(ProxySlot::default()), + chatgpt: Arc::new(ProxySlot::default()), + management: RwLock::new(()), + stopping: watch::channel(false).0, + } + } + + async fn status_with_lock(&self) -> control::Status { + let _guard = self.reload_lock.lock().await; + self.current_status().await + } + + // Called with reload_lock held, so the pair cannot be observed mid-update. + async fn current_status(&self) -> control::Status { + control::Status { + version: env!("CARGO_PKG_VERSION").to_owned(), + claude_desktop_enabled: self.claude_desktop.current.read().await.is_some(), + chatgpt_enabled: self.chatgpt.current.read().await.is_some(), + } + } + + async fn shutdown_with_lock(&self) -> control::ShutdownResponse { + let _guard = self.reload_lock.lock().await; + log::info!("graceful proxy shutdown requested through HTTP"); + self.stopping.send_replace(true); + control::ShutdownResponse { + pid: std::process::id(), + } + } + + async fn reload_with_lock(&self) -> control::ReloadResponse { + let _guard = self.reload_lock.lock().await; + let started = std::time::Instant::now(); + log::info!("proxy settings reload started"); + let result = async { + if *self.stopping.borrow() { + return Err("proxy server is stopping"); + } + let mut settings = wizard_settings::load_from_file() + .inspect_err(|error| diagnostics::error("proxy settings load", error.as_ref())) + .map_err(|_| "failed to load Wizard settings")?; + let claude_token = settings.claude_proxy_enabled.take(); + let chatgpt_token = settings.codex_proxy_enabled.take(); + drop(settings); + + log::info!("applying Claude Desktop proxy settings"); + let claude = self + .claude_desktop + .set_token(claude_token.as_deref(), &self.tls) + .await + .inspect_err(|error| diagnostics::error("Claude Desktop generation update", error)) + .map_err(|_| "failed to update Claude Desktop proxy"); + log::info!("applying ChatGPT proxy settings"); + let chatgpt = self + .chatgpt + .set_token(chatgpt_token.as_deref(), &self.tls) + .await + .inspect_err(|error| diagnostics::error("ChatGPT generation update", error)) + .map_err(|_| "failed to update ChatGPT proxy"); + // Attempt both, retaining the first error without rolling back success. + claude.and(chatgpt) + } + .await; + let status = self.current_status().await; + match &result { + Ok(()) => log::info!( + "proxy settings reload completed: claude_desktop={}, chatgpt={}, elapsed_ms={}", + status.claude_desktop_enabled, + status.chatgpt_enabled, + started.elapsed().as_millis(), + ), + // These errors are static diagnostics, never settings or credentials. + Err(error) => log::error!( + "proxy settings reload failed: {error}; claude_desktop={}, chatgpt={}, elapsed_ms={}", + status.claude_desktop_enabled, + status.chatgpt_enabled, + started.elapsed().as_millis(), + ), + } + if result.is_ok() && !status.enabled() { + // Prevent later queued Reloads from re-enabling during shutdown. + // run_listeners still waits for the current HTTP reply to finish. + self.stopping.send_replace(true); + } + control::ReloadResponse { + status, + error: result.err().map(str::to_owned), + } + } +} + +// No Debug: generations contain tokens and upstream credentials. +#[derive(Default)] +struct ProxySlot { + current: RwLock>>, +} + +struct Generation { + token: String, + upstream: Arc, + cancelled: watch::Sender, + active: RwLock<()>, +} + +impl ProxySlot { + // Writers must be serialized through the whole update, including draining. + // The standalone handle enforces this with &mut self; the managed server + // holds ServerState::reload_lock across both slot updates. + async fn set_token(&self, token: Option<&str>, tls: &ClientConfig) -> io::Result<()> { + let mut current = self.current.write().await; + if current.as_ref().map(|generation| generation.token.as_str()) == token { + return Ok(()); + } + + let next = match token { + None => None, + Some(token) => { + validate_token(token)?; + let authorization = upstream::authorization(token)?; + let upstream = upstream::Upstream::new(tls.clone(), authorization); + let (cancelled, _) = watch::channel(false); + Some(Arc::new(Generation { + token: token.to_owned(), + upstream: Arc::new(upstream), + cancelled, + active: RwLock::new(()), + })) + } + }; + + let change = match (current.is_some(), next.is_some()) { + (false, true) => "enabled", + (true, true) => "replaced", + (_, false) => "disabled", + }; + let previous = core::mem::replace(&mut *current, next); + log::info!("proxy generation {change}"); + if let Some(previous) = previous { + let started = std::time::Instant::now(); + log::info!("cancelling and draining previous proxy generation"); + previous.cancelled.send_replace(true); + drop(current); + + // Old sessions drop their sockets before releasing their read guards. + // Late arrivals check cancellation before doing any proxy I/O. + let _drained = previous.active.write().await; + log::info!( + "previous proxy generation drained: elapsed_ms={}", + started.elapsed().as_millis(), + ); + } + Ok(()) + } } impl Proxy { - /// Wait for the listener task to finish, including panic or cancellation. + /// Update proxying without rebinding the listener. None rejects proxy requests + /// with HTTP 503. Unchanged tokens preserve sessions; changes cancel and drain + /// the old generation. Preparation errors preserve the current generation. + /// The caller must finish awaiting this operation to wait for session cleanup. + pub async fn set_token(&mut self, token: Option<&str>) -> io::Result<()> { + self.slot.set_token(token, &self.tls).await + } + + /// Wait for the listener task to finish, including failure, panic or cancellation. /// The returned future does not borrow the handle and can be observed independently. pub fn stopped(&self) -> impl Future + Send + 'static + use<> { let mut stopped = self.stopped.clone(); @@ -72,94 +259,267 @@ impl Proxy { /// Stop accepting requests, cancel active connections, and wait for their cleanup. pub async fn stop(self) -> io::Result<()> { drop(self.shutdown); - self.task.await.map_err(io::Error::other) + self.task.await.map_err(io::Error::other)? } } +/// Read Wizard settings and run both fixed-address listeners with unauthenticated +/// GET /status, POST /reload and POST /shutdown. With both proxies disabled, +/// return without binding. +/// TLS roots load once for the pair; disabled proxy traffic receives HTTP 503. +/// Accepted management commands and bounded reply attempts finish before shutdown. +/// This function does not register OS services or change saved settings. +pub async fn run_managed(shutdown: impl Future) -> io::Result<()> { + // Avoid loading system certificates when no proxy is needed. Reload rereads + // after the potentially slow TLS initialization, using the latest settings. + if !wizard_settings::load_from_file() + .inspect_err(|error| diagnostics::error("proxy startup settings load", error.as_ref())) + .map_err(|_| io::Error::other("failed to load Wizard settings"))? + .local_proxy_enabled() + { + log::info!("proxy startup skipped: both proxies are disabled"); + return Ok(()); + } + log::info!("loading proxy TLS roots"); + let tls = tokio::task::spawn_blocking(upstream::tls_config) + .await + .map_err(io::Error::other)??; + let state = Arc::new(ServerState::new(tls)); + let response = state.reload_with_lock().await; + if let Some(error) = response.error { + return Err(io::Error::other(error)); + } + if !response.status.enabled() { + return Ok(()); + } + + // Bind both before starting tasks. If the second bind fails, the first + // listener is dropped too; a disabled app's port must also be available. + let claude = TcpListener::bind(CLAUDE_DESKTOP_ADDRESS) + .await + .inspect_err(|error| { + diagnostics::error("Claude Desktop listener bind", error); + })?; + let chatgpt = TcpListener::bind(CHATGPT_ADDRESS) + .await + .inspect_err(|error| { + diagnostics::error("ChatGPT listener bind", error); + })?; + log::info!( + "proxy listeners bound: claude_desktop={CLAUDE_DESKTOP_ADDRESS}, chatgpt={CHATGPT_ADDRESS}" + ); + run_listeners(state, claude, chatgpt, shutdown).await +} + +async fn run_listeners( + state: Arc, + claude: TcpListener, + chatgpt: TcpListener, + shutdown: impl Future, +) -> io::Result<()> { + // Handles stay private. Only ServerState::reload changes managed tokens. + let claude = spawn( + claude, + Arc::clone(&state.claude_desktop), + state.tls.clone(), + Some(Arc::clone(&state)), + ); + let chatgpt = spawn( + chatgpt, + Arc::clone(&state.chatgpt), + state.tls.clone(), + Some(Arc::clone(&state)), + ); + let mut stopping = state.stopping.subscribe(); + + tokio::pin!(shutdown); + let result = tokio::select! { + biased; + () = &mut shutdown => { + log::info!("proxy shutdown requested by caller"); + Ok(()) + }, + _ = stopping.wait_for(|stopping| *stopping) => { + log::info!("proxy shutdown requested through management"); + Ok(()) + }, + () = claude.stopped() => { + log::error!("Claude Desktop listener stopped unexpectedly"); + Err(io::Error::other("Claude Desktop listener stopped unexpectedly")) + }, + () = chatgpt.stopped() => { + log::error!("ChatGPT listener stopped unexpectedly"); + Err(io::Error::other("ChatGPT listener stopped unexpectedly")) + }, + }; + + state.stopping.send_replace(true); + // Finish accepted management work before cancelling connection tasks. + // stopping already prevents new work. + log::info!("waiting for accepted management commands before shutdown"); + drop(state.management.write().await); + log::info!("stopping proxy listeners and draining connections"); + let (claude, chatgpt) = tokio::join!(claude.stop(), chatgpt.stop()); + // Prefer the original listener failure to a synthetic stopped notification. + let result = claude.and(chatgpt).and(result); + match &result { + Ok(()) => log::info!("proxy listeners stopped"), + Err(error) => diagnostics::error("proxy listeners shutdown", error), + } + result +} + /// Bind `address` and start proxying with a snapshot of `token`. /// Upstream connectivity is checked per request, not during startup. pub async fn start(address: &str, token: &str) -> io::Result { + listen(address, Some(token)).await +} + +/// Bind once, optionally enabling proxying. A disabled listener returns HTTP 503 +/// until enabled through [`Proxy::set_token`]. No management endpoints are served. +/// System TLS roots are loaded once, including when initially disabled. +pub async fn listen(address: &str, token: Option<&str>) -> io::Result { + if let Some(token) = token { + validate_token(token)?; + } + let tls = tokio::task::spawn_blocking(upstream::tls_config) + .await + .map_err(io::Error::other)??; + let slot = Arc::new(ProxySlot::default()); + slot.set_token(token, &tls).await?; + let listener = TcpListener::bind(address).await?; + Ok(spawn(listener, slot, tls, None)) +} + +fn validate_token(token: &str) -> io::Result<()> { if token.is_empty() { - // TODO: Decide the final behavior when the saved CoreInfra token is absent. return Err(io::Error::new( io::ErrorKind::InvalidInput, "CoreInfra token is not set", )); } + Ok(()) +} - let authorization = upstream::authorization(token)?; - let tls = tokio::task::spawn_blocking(upstream::tls_config) - .await - .map_err(io::Error::other)??; - let upstream = Arc::new(upstream::Upstream::new(tls, authorization)); - let listener = TcpListener::bind(address).await?; - let address = listener.local_addr()?; +fn spawn( + listener: TcpListener, + slot: Arc, + tls: ClientConfig, + management: Option>, +) -> Proxy { let (shutdown, receiver) = oneshot::channel(); let (finished, stopped) = watch::channel(()); + let state = Arc::clone(&slot); let task = tokio::spawn(async move { // Dropping the only sender wakes observers on every task-exit path. let _finished = finished; - serve(listener, address, upstream, receiver).await; + serve(listener, state, management, receiver).await }); - Ok(Proxy { + Proxy { shutdown, task, stopped, - }) + slot, + tls, + } } async fn serve( listener: TcpListener, - address: SocketAddr, - upstream: Arc, + slot: Arc, + management: Option>, mut shutdown: oneshot::Receiver<()>, -) { +) -> io::Result<()> { let mut connections = JoinSet::new(); - let mut warned_at: Option = None; + accept_connections( + || async { listener.accept().await.map(|(stream, _)| stream) }, + &slot, + management.as_ref(), + &mut shutdown, + &mut connections, + ) + .await; + + drop(listener); + // Managed shutdown is quiesced by run_listeners before stop() is called. + connections.abort_all(); + while let Some(result) = connections.join_next().await { + if let Err(error) = result + && !error.is_cancelled() + { + diagnostics::error("proxy connection task during shutdown", &error); + } + } + Ok(()) +} + +const ACCEPT_RETRY_DELAY: core::time::Duration = core::time::Duration::from_millis(500); + +// Keep the accept operation injectable so resource exhaustion can be tested +// without changing process limits or exhausting the machine's descriptors. +async fn accept_connections( + mut accept: impl FnMut() -> F, + slot: &Arc, + management: Option<&Arc>, + shutdown: &mut oneshot::Receiver<()>, + connections: &mut JoinSet<()>, +) where + F: Future>, +{ + let mut retry_at = None; loop { tokio::select! { biased; - _ = &mut shutdown => break, - Some(_) = connections.join_next(), if !connections.is_empty() => {}, - accepted = listener.accept() => { + _ = &mut *shutdown => break, + Some(result) = connections.join_next(), if !connections.is_empty() => { + if let Err(error) = result { + diagnostics::error("proxy connection task", &error); + } + }, + accepted = async { + if let Some(deadline) = retry_at { + tokio::time::sleep_until(deadline).await; + } + accept().await + } => { match accepted { - Ok((stream, _)) => { - let upstream = Arc::clone(&upstream); - connections.spawn(http::serve_client(stream, upstream)); + Ok(stream) => { + retry_at = None; + connections.spawn(http::serve_client( + stream, Arc::clone(slot), management.cloned(), + )); } - // Only this client is gone; the listener is unaffected. - Err(error) - if matches!( - error.kind(), - io::ErrorKind::ConnectionAborted | io::ErrorKind::ConnectionReset - ) => {} - // Typically EMFILE: closing tunnels will free descriptors. Err(error) => { - if warned_at.is_none_or(|at| at.elapsed() >= ACCEPT_WARNING_INTERVAL) { - warned_at = Some(Instant::now()); - log::warn!( - "local proxy on {address} cannot accept connections, retrying: {error}" - ); - } - tokio::select! { - biased; - _ = &mut shutdown => break, - () = tokio::time::sleep(ACCEPT_RETRY_DELAY) => {}, - } + diagnostics::error("proxy listener accept; retrying after 500 ms", &error); + // accept errors do not invalidate the owned listener. Keep + // sessions alive and retry without rebinding or spinning. + // Store the deadline outside the select future: completed + // connections must not bypass or extend this backoff. + retry_at = Some(tokio::time::Instant::now() + ACCEPT_RETRY_DELAY); } } } } } - - drop(listener); - connections.abort_all(); - while connections.join_next().await.is_some() {} } -/// Connect to a remote host, enabling [`KEEPALIVE`] on the socket. -async fn connect(address: impl ToSocketAddrs) -> io::Result { - let stream = TcpStream::connect(address).await?; - SockRef::from(&stream).set_tcp_keepalive(&KEEPALIVE)?; - Ok(stream) +async fn run_proxy_session(generation: Arc, session: impl Future) { + let active = generation.active.read().await; + { + // Own the future in this inner scope: even on cancellation, its sockets + // must be dropped before releasing the active-session guard below. + let session = session; + let mut cancelled = generation.cancelled.subscribe(); + let is_cancelled = *cancelled.borrow(); + if is_cancelled { + drop(session); + } else { + tokio::select! { + biased; + _ = cancelled.changed() => {}, + () = session => {}, + } + } + } + drop(active); } diff --git a/crates/wizard-proxy/src/server_test.rs b/crates/wizard-proxy/src/server_test.rs new file mode 100644 index 0000000..1ddacb0 --- /dev/null +++ b/crates/wizard-proxy/src/server_test.rs @@ -0,0 +1,402 @@ +use super::*; +use core::time::Duration; +use std::{ + fs, + path::Path, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio::time::timeout; +use tokio_rustls::rustls::RootCertStore; + +fn state() -> Arc { + let tls = ClientConfig::builder_with_provider(Arc::new( + tokio_rustls::rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("TLS versions") + .with_root_certificates(RootCertStore::empty()) + .with_no_client_auth(); + Arc::new(ServerState::new(tls)) +} + +#[tokio::test] +async fn accept_errors_preserve_tunnels_and_resume_requests_after_backoff() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let state = state(); + state + .claude_desktop + .set_token(Some("fixture"), &state.tls) + .await?; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let destination = TcpListener::bind("127.0.0.1:0").await?; + let target = destination.local_addr()?; + let attempts = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = Arc::clone(&attempts); + let (stop, mut shutdown) = oneshot::channel(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + accept_connections( + || { + let attempt = { + let mut times = recorded.lock().expect("attempt times"); + times.push(tokio::time::Instant::now()); + times.len() + }; + let listener = &listener; + async move { + match attempt { + 2 => Err(io::Error::from(io::ErrorKind::OutOfMemory)), + 3 => Err(io::Error::from(io::ErrorKind::ConnectionAborted)), + _ => listener.accept().await.map(|(stream, _)| stream), + } + } + }, + &state.claude_desktop, + Some(&state), + &mut shutdown, + &mut connections, + ) + .await; + connections.abort_all(); + while let Some(result) = connections.join_next().await { + if let Err(error) = result { + assert!( + error.is_cancelled(), + "unexpected connection failure: {error}" + ); + } + } + }); + + let mut client = TcpStream::connect(address).await?; + client + .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) + .await?; + let (mut remote, _) = destination.accept().await?; + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + header.push(client.read_u8().await?); + assert!(header.len() < 8192); + } + assert!(header.starts_with(b"HTTP/1.1 200")); + // The first accepted connection survives subsequent accept failures. + client.write_all(b"live").await?; + let mut bytes = [0; 4]; + remote.read_exact(&mut bytes).await?; + assert_eq!(&bytes, b"live"); + + let response: control::Status = + control::request(&address.to_string(), control::Command::Status).await?; + assert!(response.claude_desktop_enabled); + { + let times = attempts.lock().expect("attempt times"); + assert!(times.len() >= 4); + assert!(times[2] - times[1] >= ACCEPT_RETRY_DELAY); + assert!(times[3] - times[2] >= ACCEPT_RETRY_DELAY); + } + remote.write_all(b"back").await?; + client.read_exact(&mut bytes).await?; + assert_eq!(&bytes, b"back"); + let _ = stop.send(()); + task.await.map_err(io::Error::other)?; + assert_eq!(client.read(&mut bytes).await?, 0); + Ok(()) + }) + .await + .expect("accept recovery timed out") +} + +#[tokio::test] +async fn shutdown_interrupts_accept_backoff() { + let state = state(); + let (stop, mut shutdown) = oneshot::channel(); + let (failed, mut failure) = tokio::sync::mpsc::unbounded_channel(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + accept_connections( + || async { + failed.send(()).expect("report injected failure"); + Err(io::Error::from(io::ErrorKind::OutOfMemory)) + }, + &state.claude_desktop, + None, + &mut shutdown, + &mut connections, + ) + .await; + }); + timeout(Duration::from_secs(2), failure.recv()) + .await + .expect("injected failure"); + let _ = stop.send(()); + timeout(Duration::from_millis(250), task) + .await + .expect("shutdown must not wait for the retry delay") + .expect("accept task"); + assert!(failure.try_recv().is_err(), "must not retry in a hot loop"); +} + +fn settings(claude: Option<&str>, chatgpt: Option<&str>) { + wizard_settings::save_to_file(&wizard_settings::Settings { + claude_proxy_enabled: claude.map(str::to_owned), + codex_proxy_enabled: chatgpt.map(str::to_owned), + ..wizard_settings::Settings::default() + }) + .expect("save isolated settings"); +} + +async fn disabling_reload_replies_before_exit() -> io::Result<()> { + settings(Some("claude-fixture"), Some("chatgpt-fixture")); + let state = state(); + assert!(state.reload_with_lock().await.error.is_none()); + let claude = TcpListener::bind("127.0.0.1:0").await?; + let chatgpt = TcpListener::bind("127.0.0.1:0").await?; + let address = chatgpt.local_addr()?; + let task = tokio::spawn(run_listeners( + state, + claude, + chatgpt, + core::future::pending(), + )); + + let status: control::Status = + control::request(&address.to_string(), control::Command::Status).await?; + assert_eq!(status.version, env!("CARGO_PKG_VERSION")); + assert!(status.claude_desktop_enabled && status.chatgpt_enabled); + + settings(None, None); + let response: control::ReloadResponse = + control::request(&address.to_string(), control::Command::Reload).await?; + assert!(response.error.is_none()); + assert_eq!(response.status.version, env!("CARGO_PKG_VERSION")); + assert!(!response.status.enabled()); + task.await.map_err(io::Error::other)??; + Ok(()) +} + +async fn shutdown_replies_before_exit_and_preserves_settings() -> io::Result<()> { + settings(Some("shutdown-fixture"), None); + let state = state(); + assert!(state.reload_with_lock().await.error.is_none()); + let claude = TcpListener::bind("127.0.0.1:0").await?; + let chatgpt = TcpListener::bind("127.0.0.1:0").await?; + let address = claude.local_addr()?; + let task = tokio::spawn(run_listeners( + state, + claude, + chatgpt, + core::future::pending(), + )); + + let destination = TcpListener::bind("127.0.0.1:0").await?; + let target = destination.local_addr()?; + let mut client = TcpStream::connect(address).await?; + client + .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) + .await?; + let (mut remote, _) = destination.accept().await?; + + let response: control::ShutdownResponse = + control::request(&address.to_string(), control::Command::Shutdown).await?; + assert_eq!(response.pid, std::process::id()); + task.await.map_err(io::Error::other)??; + // A completed Shutdown closes both ends of an active direct tunnel. + let mut reply = Vec::new(); + client.read_to_end(&mut reply).await?; + assert!(reply.starts_with(b"HTTP/1.1 200")); + assert_eq!(remote.read(&mut [0_u8; 1]).await?, 0); + assert!( + wizard_settings::load_from_file() + .expect("saved settings") + .local_proxy_enabled() + ); + Ok(()) +} + +async fn accepted_reload_survives_disconnect_and_shutdown() -> io::Result<()> { + settings(Some("initial"), None); + let state = state(); + assert!(state.reload_with_lock().await.error.is_none()); + let old = state + .claude_desktop + .current + .read() + .await + .clone() + .expect("enabled slot"); + // Hold draining so disconnect and shutdown occur during an accepted Reload. + let active = old.active.read().await; + let mut cancelled = old.cancelled.subscribe(); + let claude = TcpListener::bind("127.0.0.1:0").await?; + let chatgpt = TcpListener::bind("127.0.0.1:0").await?; + let address = claude.local_addr()?; + let (stop, shutdown) = oneshot::channel(); + let mut task = tokio::spawn(run_listeners(Arc::clone(&state), claude, chatgpt, async { + let _ = shutdown.await; + })); + + settings(Some("replacement"), None); + let mut client = TcpStream::connect(address).await?; + client + .write_all( + format!("POST /reload HTTP/1.1\r\nHost: {address}\r\nContent-Length: 0\r\n\r\n") + .as_bytes(), + ) + .await?; + cancelled.changed().await.map_err(io::Error::other)?; + drop(client); + let _ = stop.send(()); + assert!( + timeout(Duration::from_millis(100), &mut task) + .await + .is_err() + ); + + drop(active); + task.await.map_err(io::Error::other)??; + assert!( + state + .claude_desktop + .current + .read() + .await + .as_ref() + .is_some_and(|generation| generation.token == "replacement") + ); + Ok(()) +} + +async fn failed_reload_keeps_working_configuration() -> io::Result<()> { + settings(Some("working"), Some("working-chatgpt")); + let state = state(); + assert!(state.reload_with_lock().await.error.is_none()); + let original = state + .claude_desktop + .current + .read() + .await + .clone() + .expect("generation"); + let claude = TcpListener::bind("127.0.0.1:0").await?; + let chatgpt = TcpListener::bind("127.0.0.1:0").await?; + let address = claude.local_addr()?.to_string(); + let (stop, shutdown) = oneshot::channel(); + let task = tokio::spawn(run_listeners(Arc::clone(&state), claude, chatgpt, async { + let _ = shutdown.await; + })); + fs::write( + wizard_settings::paths::settings_path().expect("settings path"), + "invalid = [", + )?; + let failed: control::ReloadResponse = + control::request(&address, control::Command::Reload).await?; + assert!(failed.error.is_some()); + assert!(failed.status.claude_desktop_enabled && failed.status.chatgpt_enabled); + + // Preserve an invalid slot's generation; the other slot may still update. + settings(Some(""), Some("updated-chatgpt")); + let failed: control::ReloadResponse = + control::request(&address, control::Command::Reload).await?; + assert!(failed.error.is_some()); + assert!(Arc::ptr_eq( + &original, + state + .claude_desktop + .current + .read() + .await + .as_ref() + .expect("retained generation") + )); + assert!(!*original.cancelled.borrow()); + assert_eq!( + state + .chatgpt + .current + .read() + .await + .as_ref() + .expect("updated generation") + .token, + "updated-chatgpt" + ); + + settings(Some("repaired"), Some("updated-chatgpt")); + let repaired: control::ReloadResponse = + control::request(&address, control::Command::Reload).await?; + assert!(repaired.error.is_none()); + let status: control::Status = control::request(&address, control::Command::Status).await?; + assert!(status.claude_desktop_enabled && status.chatgpt_enabled); + let _ = stop.send(()); + task.await.map_err(io::Error::other)??; + Ok(()) +} + +// Windows Known Folder resolution ignores HOME; never use a real user profile. +// The test and all shared protocol code still compile on Windows. +#[test] +#[cfg_attr( + windows, + ignore = "requires isolated Unix HOME/XDG settings resolution" +)] +fn isolated_http_management() { + const MARKER: &str = "WIZARD_HTTP_TEST_HOME"; + if let Some(home) = std::env::var_os(MARKER) { + assert_eq!(std::env::var_os("HOME"), Some(home.clone())); + let path = wizard_settings::paths::settings_path().expect("settings path"); + assert!( + path.starts_with(Path::new(&home)), + "refuse real settings path" + ); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + timeout(Duration::from_secs(30), async { + failed_reload_keeps_working_configuration() + .await + .expect("failed Reload preserves service and permits recovery"); + disabling_reload_replies_before_exit() + .await + .expect("disabling Reload response"); + shutdown_replies_before_exit_and_preserves_settings() + .await + .expect("graceful Shutdown response"); + accepted_reload_survives_disconnect_and_shutdown() + .await + .expect("shutdown during disconnected Reload"); + }) + .await + .expect("management fixtures timed out"); + }); + return; + } + let directory = std::env::temp_dir().join(format!( + "wizard-http-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir(&directory).expect("fixture directory"); + let result = Command::new(std::env::current_exe().expect("test executable")) + .args(["--exact", "server_tests::isolated_http_management"]) + .env(MARKER, &directory) + .env("HOME", &directory) + .env("XDG_CONFIG_HOME", directory.join("config")) + .output() + .expect("isolated test process"); + fs::remove_dir_all(directory).expect("fixture cleanup"); + assert!( + result.status.success(), + "isolated management test failed: {}", + String::from_utf8_lossy(&result.stderr) + ); +} diff --git a/crates/wizard-system-proxy/Cargo.toml b/crates/wizard-system-proxy/Cargo.toml new file mode 100644 index 0000000..b50bf3f --- /dev/null +++ b/crates/wizard-system-proxy/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "wizard-system-proxy" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +wizard-proxy.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-core-foundation.workspace = true +objc2-security.workspace = true +objc2-system-configuration.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { workspace = true, features = ["Win32_Networking_WinInet", "Win32_Foundation"] } diff --git a/crates/wizard-system-proxy/src/lib.rs b/crates/wizard-system-proxy/src/lib.rs new file mode 100644 index 0000000..3ffdd2f --- /dev/null +++ b/crates/wizard-system-proxy/src/lib.rs @@ -0,0 +1,152 @@ +//! System HTTP/HTTPS proxy settings, independent of the listener's lifecycle. +//! +//! Blocking API: callers must use a worker thread. macOS mutations may request +//! authorization. No API starts the proxy, changes its token, or checks its health. +//! Settings are identified by endpoint, not proof of ownership: another program +//! using exactly the same endpoint is indistinguishable from Wizard. + +use anyhow::{Context as _, Result, ensure}; +use std::sync::Mutex; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; +mod windows_config; + +#[cfg(target_os = "macos")] +use macos::Native; +#[cfg(target_os = "windows")] +use windows::Native; + +#[cfg(test)] +mod tests; + +static LOCK: Mutex<()> = Mutex::new(()); + +/// Configuration state, not listener availability or application traffic routing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProxyState { + Disabled, + Wizard, + Foreign, +} + +/// No endpoints, PAC URLs, credentials or complete OS settings are exposed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceStatus { + pub id: String, + pub http: ProxyState, + pub https: ProxyState, + pub conflict: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SystemProxyStatus { + pub services: Vec, +} + +/// Read supported services. Does not request authorization. +pub fn status() -> Result { + let _guard = LOCK + .lock() + .map_err(|_| anyhow::anyhow!("system proxy lock poisoned"))?; + native_status() +} + +/// Set HTTP and HTTPS to the Claude Desktop endpoint, refusing active conflicts. +/// Existing bypass rules and inactive settings are preserved except for the HTTP/ +/// HTTPS endpoints being replaced. There is no persistent backup or rollback. +pub fn enable() -> Result<()> { + update(true) +} + +/// Disable only HTTP/HTTPS entries pointing to Wizard. Preserve foreign entries. +pub fn disable() -> Result<()> { + update(false) +} + +fn update(enabled: bool) -> Result<()> { + let _guard = LOCK + .lock() + .map_err(|_| anyhow::anyhow!("system proxy lock poisoned"))?; + native_update(enabled) +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn native_status() -> Result { + let snapshot = Native::read()?; + Ok(SystemProxyStatus { + services: Native::services(&snapshot)?, + }) +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn native_update(enabled: bool) -> Result<()> { + change::(enabled) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn native_status() -> Result { + anyhow::bail!("system proxy configuration is supported only on macOS and Windows") +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn native_update(_: bool) -> Result<()> { + anyhow::bail!("system proxy configuration is supported only on macOS and Windows") +} + +// The boundary lets regression tests exercise the entire mutation flow without +// touching the network configuration, authorization UI or native APIs. +#[cfg_attr( + not(any(target_os = "macos", target_os = "windows", test)), + expect(dead_code) +)] +trait Backend { + type Snapshot: PartialEq; + fn read() -> Result; + fn services(snapshot: &Self::Snapshot) -> Result>; + fn prepare(snapshot: &Self::Snapshot, enabled: bool) -> Result; + fn write(before: &Self::Snapshot, after: &Self::Snapshot) -> Result<()>; +} + +#[cfg_attr( + not(any(target_os = "macos", target_os = "windows", test)), + expect(dead_code) +)] +fn change(enabled: bool) -> Result<()> { + let before = B::read()?; + let services = B::services(&before)?; + ensure!(!services.is_empty(), "no supported network services"); + ensure!( + !enabled || !services.iter().any(|s| s.conflict), + "active system proxy, PAC or auto-discovery conflicts with Wizard" + ); + let after = B::prepare(&before, enabled)?; + if before == after { + return Ok(()); + } + + ensure!( + B::read()? == before, + "system proxy settings changed; try again" + ); + B::write(&before, &after) + .context("system proxy update failed; settings may be partially applied")?; + ensure!( + B::read().context("cannot verify system proxy update; settings may already be applied")? + == after, + "system proxy verification failed; settings may be partially applied or changed externally" + ); + Ok(()) +} + +fn endpoint_state(enabled: bool, endpoint: &str) -> ProxyState { + if !enabled { + ProxyState::Disabled + } else if endpoint == wizard_proxy::CLAUDE_DESKTOP_ADDRESS { + ProxyState::Wizard + } else { + ProxyState::Foreign + } +} diff --git a/crates/wizard-system-proxy/src/macos.rs b/crates/wizard-system-proxy/src/macos.rs new file mode 100644 index 0000000..47589bc --- /dev/null +++ b/crates/wizard-system-proxy/src/macos.rs @@ -0,0 +1,325 @@ +//! Native `SystemConfiguration` adapter. Only this module crosses the FFI boundary. +#![expect( + unsafe_code, + reason = "CoreFoundation and authorization APIs require checked FFI calls" +)] + +use crate::{Backend, ProxyState, ServiceStatus, endpoint_state}; +use anyhow::{Context as _, Result, ensure}; +use core::{net::SocketAddr, ptr}; +use objc2_core_foundation::{CFDictionary, CFNumber, CFRetained, CFString, CFType, Type as _}; +use objc2_security::{ + AuthorizationCreate, AuthorizationFlags, AuthorizationFree, AuthorizationItem, + AuthorizationItemSet, AuthorizationRef, +}; +use objc2_system_configuration::{ + SCNetworkService, SCNetworkSet, SCPreferences, kSCEntNetProxies, + kSCNetworkInterfaceTypeEthernet, kSCNetworkInterfaceTypeIEEE80211, +}; + +pub(super) struct Native; + +#[derive(Clone, PartialEq)] +pub(super) struct Snapshot { + location: String, + services: Vec, +} + +#[derive(Clone, PartialEq)] +struct Service { + id: String, + config: CFRetained, +} + +fn preferences() -> Result> { + SCPreferences::new(None, &CFString::from_str("Wizard system proxy"), None) + .context("cannot open system network preferences") +} + +fn read_preferences(prefs: &SCPreferences) -> Result { + let set = SCNetworkSet::current(prefs).context("no current network location")?; + let location = set + .set_id() + .context("network location has no identifier")? + .to_string(); + let all = set + .services() + .context("cannot enumerate network services")?; + // SystemConfiguration returns a CFArray of SCNetworkService objects. + let all = unsafe { all.cast_unchecked::() }; + let mut services = Vec::new(); + for service in all.iter() { + if !service.enabled() { + continue; + } + let interface = service + .interface() + .context("network service has no interface")?; + let kind = interface + .interface_type() + .context("network interface has no type")?; + // These exported constants are immutable framework-owned CFStrings. + if unsafe { + &*kind != kSCNetworkInterfaceTypeEthernet && &*kind != kSCNetworkInterfaceTypeIEEE80211 + } { + continue; + } + let id = service + .service_id() + .context("network service has no identifier")? + .to_string(); + let protocol = service + .protocol(unsafe { kSCEntNetProxies }) + .context("network service has no proxy protocol")?; + ensure!( + protocol.enabled(), + "network service proxy protocol is disabled" + ); + let config = protocol + .configuration() + .unwrap_or_else(|| CFDictionary::::empty().as_opaque().retain()); + services.push(Service { id, config }); + } + services.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(Snapshot { location, services }) +} + +fn value(config: &CFDictionary, key: &str) -> Option> { + // SC proxy dictionaries contain CF property-list keys and values. + unsafe { config.cast_unchecked::() }.get(&CFString::from_str(key)) +} + +fn number(config: &CFDictionary, key: &str) -> Result { + let Some(value) = value(config, key) else { + return Ok(0); + }; + value + .downcast_ref::() + .and_then(CFNumber::as_i64) + .context("invalid numeric proxy setting") +} + +fn state(config: &CFDictionary, prefix: &str) -> Result { + if number(config, &format!("{prefix}Enable"))? == 0 { + return Ok(ProxyState::Disabled); + } + let host = value(config, &format!("{prefix}Proxy")).context("enabled proxy has no host")?; + let host = host + .downcast_ref::() + .context("invalid proxy host")?; + let port = number(config, &format!("{prefix}Port"))?; + Ok(endpoint_state(true, &format!("{host}:{port}"))) +} + +fn service_status(service: &Service) -> Result { + let config = &service.config; + let http = state(config, "HTTP")?; + let https = state(config, "HTTPS")?; + let mut conflict = http == ProxyState::Foreign || https == ProxyState::Foreign; + // FTP/RTSP/Gopher are legacy, but must not silently coexist with our setting. + for key in [ + "SOCKSEnable", + "FTPEnable", + "RTSPEnable", + "GopherEnable", + "ProxyAutoConfigEnable", + "ProxyAutoDiscoveryEnable", + ] { + conflict |= number(config, key)? != 0; + } + Ok(ServiceStatus { + id: service.id.clone(), + http, + https, + conflict, + }) +} + +fn replacing(config: &CFDictionary, key: &str, value: &CFType) -> CFRetained { + // A fresh dictionary preserves every unrelated property-list value and never + // mutates the original snapshot returned by SystemConfiguration. + let dict = unsafe { config.cast_unchecked::() }; + let (mut keys, mut values) = dict.to_vecs(); + let key = CFString::from_str(key); + let key_type: &CFType = &key; + if let Some(index) = keys.iter().position(|k| **k == *key_type) { + values[index] = value.retain(); + } else { + keys.push(key_type.retain()); + values.push(value.retain()); + } + CFDictionary::from_slices( + &keys.iter().map(|k| &**k).collect::>(), + &values.iter().map(|v| &**v).collect::>(), + ) + .as_opaque() + .retain() +} + +impl Backend for Native { + type Snapshot = Snapshot; + + fn read() -> Result { + let prefs = preferences()?; + read_preferences(&prefs) + } + + fn services(snapshot: &Snapshot) -> Result> { + snapshot.services.iter().map(service_status).collect() + } + + fn prepare(snapshot: &Snapshot, enabled: bool) -> Result { + let address: SocketAddr = wizard_proxy::CLAUDE_DESKTOP_ADDRESS.parse()?; + let host = CFString::from_str(&address.ip().to_string()); + let port = CFNumber::new_i32(i32::from(address.port())); + let mut after = snapshot.clone(); + for service in &mut after.services { + for prefix in ["HTTP", "HTTPS"] { + if enabled { + service.config = replacing(&service.config, &format!("{prefix}Proxy"), &host); + service.config = replacing(&service.config, &format!("{prefix}Port"), &port); + service.config = replacing( + &service.config, + &format!("{prefix}Enable"), + &CFNumber::new_i32(1), + ); + } else if state(&service.config, prefix)? == ProxyState::Wizard { + service.config = replacing( + &service.config, + &format!("{prefix}Enable"), + &CFNumber::new_i32(0), + ); + } + } + } + Ok(after) + } + + fn write(before: &Snapshot, after: &Snapshot) -> Result<()> { + // No authorization is requested until common preflight has succeeded. + let authorization = Authorization::new()?; + // Authorization stays alive until after preferences and its lock drop. + let prefs = unsafe { + SCPreferences::with_authorization( + None, + &CFString::from_str("Wizard system proxy"), + None, + authorization.0, + ) + } + .context("cannot open authorized network preferences")?; + ensure!(prefs.lock(false), "cannot lock network preferences"); + let _lock = PreferencesLock(&prefs); + ensure!( + read_preferences(&prefs)? == *before, + "network settings changed during authorization; try again" + ); + for (old, new) in before.services.iter().zip(&after.services) { + if old == new { + continue; + } + let service = SCNetworkService::copy(&prefs, &CFString::from_str(&new.id)) + .context("network service disappeared")?; + let protocol = service + .protocol(unsafe { kSCEntNetProxies }) + .context("proxy protocol disappeared")?; + // The snapshot is an owned property-list dictionary valid for this call. + ensure!( + unsafe { protocol.set_configuration(Some(&new.config)) }, + "cannot stage proxy configuration" + ); + } + ensure!(prefs.commit_changes(), "cannot commit network preferences"); + ensure!( + prefs.apply_changes(), + "network preferences saved but could not be applied" + ); + Ok(()) + } +} + +struct PreferencesLock<'a>(&'a SCPreferences); +impl Drop for PreferencesLock<'_> { + fn drop(&mut self) { + self.0.unlock(); + } +} + +struct Authorization(AuthorizationRef); +impl Authorization { + fn new() -> Result { + let mut item = AuthorizationItem { + name: c"system.preferences.network".as_ptr(), + valueLength: 0, + value: ptr::null_mut(), + flags: 0, + }; + let rights = AuthorizationItemSet { + count: 1, + items: &raw mut item, + }; + let mut authorization = ptr::null(); + // All pointers refer to live stack values; the API returns an owned ref. + let code = unsafe { + AuthorizationCreate( + &raw const rights, + ptr::null(), + AuthorizationFlags::InteractionAllowed | AuthorizationFlags::ExtendRights, + &raw mut authorization, + ) + }; + ensure!( + code == 0 && !authorization.is_null(), + "network configuration authorization failed ({code})" + ); + Ok(Self(authorization)) + } +} +impl Drop for Authorization { + fn drop(&mut self) { + // Release our reference without revoking shared authorization rights. + unsafe { + AuthorizationFree(self.0, AuthorizationFlags::Defaults); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_unrelated_properties_and_disables_only_our_protocol() -> Result<()> { + let empty = CFDictionary::::empty(); + let config = replacing( + empty.as_opaque(), + "ExceptionsList", + &CFString::from_str("fixture"), + ); + let before = Snapshot { + location: "fixture".into(), + services: vec![Service { + id: "fixture".into(), + config, + }], + }; + let enabled = Native::prepare(&before, true)?; + assert_eq!(Native::services(&enabled)?[0].http, ProxyState::Wizard); + assert_eq!( + value(&before.services[0].config, "ExceptionsList"), + value(&enabled.services[0].config, "ExceptionsList") + ); + let mut foreign = enabled; + foreign.services[0].config = replacing( + &foreign.services[0].config, + "HTTPSProxy", + &CFString::from_str("foreign.example"), + ); + let disabled = Native::prepare(&foreign, false)?; + let status = Native::services(&disabled)?; + assert_eq!(status[0].http, ProxyState::Disabled); + assert_eq!(status[0].https, ProxyState::Foreign); + assert!(status[0].conflict); + Ok(()) + } +} diff --git a/crates/wizard-system-proxy/src/tests.rs b/crates/wizard-system-proxy/src/tests.rs new file mode 100644 index 0000000..74d8891 --- /dev/null +++ b/crates/wizard-system-proxy/src/tests.rs @@ -0,0 +1,178 @@ +use super::*; +use crate::windows_config::{Config, DIRECT, PROXY}; +use core::cell::RefCell; + +fn disabled() -> Config { + Config { + flags: DIRECT, + server: String::new(), + bypass: "localhost;".into(), + pac: "https://example.invalid/proxy.pac".into(), + } +} + +#[derive(Default)] +struct MockState { + snapshot: Vec, + reads: usize, + writes: usize, + concurrent_change: bool, + partial_failure: bool, + ignore_write: bool, +} + +thread_local! { static MOCK: RefCell = RefCell::new(MockState::default()); } +struct Mock; +impl Backend for Mock { + type Snapshot = Vec; + fn read() -> Result { + MOCK.with_borrow_mut(|m| { + m.reads += 1; + if m.concurrent_change && m.reads == 2 { + m.snapshot[0].bypass = "changed".into(); + } + Ok(m.snapshot.clone()) + }) + } + fn services(snapshot: &Self::Snapshot) -> Result> { + snapshot.iter().map(Config::status).collect() + } + fn prepare(snapshot: &Self::Snapshot, enabled: bool) -> Result { + snapshot.iter().map(|c| c.prepare(enabled)).collect() + } + fn write(_: &Self::Snapshot, after: &Self::Snapshot) -> Result<()> { + MOCK.with_borrow_mut(|m| { + m.writes += 1; + if m.partial_failure { + m.snapshot[0] = after[0].clone(); + anyhow::bail!("fixture write failure"); + } + if !m.ignore_write { + m.snapshot.clone_from(after); + } + Ok(()) + }) + } +} + +fn setup(configs: Vec) { + MOCK.with_borrow_mut(|m| { + *m = MockState { + snapshot: configs, + ..MockState::default() + } + }); +} + +#[test] +fn preflights_every_service_before_any_write() { + for flags in [DIRECT | 4, DIRECT | 8, DIRECT | PROXY] { + let mut conflict = disabled(); + conflict.flags = flags; + conflict.server = "http=foreign.example:8080".into(); + setup(vec![disabled(), conflict]); + assert!(change::(true).is_err()); + MOCK.with_borrow(|m| assert_eq!(m.writes, 0)); + } +} + +#[test] +fn no_services_and_concurrent_changes_never_write() { + setup(vec![]); + assert!(change::(true).is_err()); + setup(vec![disabled()]); + MOCK.with_borrow_mut(|m| m.concurrent_change = true); + assert!(change::(true).is_err()); + MOCK.with_borrow(|m| assert_eq!(m.writes, 0)); +} + +#[test] +fn repeated_enable_disable_are_noops_and_preserve_bypass_and_pac() -> Result<()> { + setup(vec![disabled()]); + change::(true)?; + change::(true)?; + MOCK.with_borrow(|m| { + assert_eq!(m.writes, 1); + assert_eq!(m.snapshot[0].bypass, disabled().bypass); + assert_eq!(m.snapshot[0].pac, disabled().pac); + }); + change::(false)?; + change::(false)?; + MOCK.with_borrow(|m| assert_eq!(m.writes, 2)); + Ok(()) +} + +#[test] +fn partial_failures_and_failed_verification_are_reported_without_retry() { + setup(vec![disabled(), disabled()]); + MOCK.with_borrow_mut(|m| m.partial_failure = true); + let error = change::(true).expect_err("partial application must fail"); + assert!(error.to_string().contains("partially applied")); + MOCK.with_borrow(|m| { + assert_eq!(m.writes, 1); + assert!(m.snapshot[0].flags & PROXY != 0); + assert_eq!(m.snapshot[1].flags & PROXY, 0); + }); + setup(vec![disabled()]); + MOCK.with_borrow_mut(|m| m.ignore_write = true); + assert!(change::(true).is_err()); + MOCK.with_borrow(|m| assert_eq!(m.writes, 1)); +} + +#[test] +fn disabling_preserves_foreign_protocols_and_auto_modes() -> Result<()> { + let mut config = disabled(); + config.flags |= PROXY | 4 | 8; + config.server = format!( + "http={};https=foreign.example:443;socks=foreign.example:1080", + wizard_proxy::CLAUDE_DESKTOP_ADDRESS + ); + let after = config.prepare(false)?; + assert_eq!(after.flags, config.flags); + assert_eq!( + after.server, + "https=foreign.example:443;socks=foreign.example:1080" + ); + assert_eq!(after.pac, config.pac); + assert_eq!(after.bypass, config.bypass); + assert!(after.status()?.conflict); + assert!(after.prepare(false)? == after); + Ok(()) +} + +#[test] +fn global_address_is_supported_but_not_confused_with_similar_endpoints() -> Result<()> { + for server in [ + "proxy.example:8080", + "127.0.0.1:1", + "http://127.0.0.1:33787", + "127.0.0.1:337870", + ] { + let mut config = disabled(); + config.flags |= PROXY; + config.server = server.into(); + assert!(config.status()?.conflict); + assert!(config.prepare(false)? == config); + } + let mut config = disabled(); + config.flags |= PROXY; + config.server = wizard_proxy::CLAUDE_DESKTOP_ADDRESS.into(); + assert!(!config.status()?.conflict); + assert_eq!(config.prepare(false)?.flags, DIRECT); + Ok(()) +} + +#[test] +fn malformed_or_unknown_mappings_cannot_be_silently_overwritten() -> Result<()> { + for server in ["http=a:1;http=b:2", "http=a:1;garbage", "a:1;b:2", "http="] { + let mut config = disabled(); + config.flags |= PROXY; + config.server = server.into(); + assert!(config.status().is_err()); + } + let mut dormant = disabled(); + dormant.server = "socks=example.invalid:1080".into(); + assert!(dormant.prepare(true).is_err()); + assert!(dormant.prepare(false)? == dormant); + Ok(()) +} diff --git a/crates/wizard-system-proxy/src/windows.rs b/crates/wizard-system-proxy/src/windows.rs new file mode 100644 index 0000000..fed34d8 --- /dev/null +++ b/crates/wizard-system-proxy/src/windows.rs @@ -0,0 +1,163 @@ +//! Current-user LAN `WinINet` settings; never `WinHTTP` or RAS/VPN profiles. +#![expect(unsafe_code, reason = "WinINet uses unions and caller-owned buffers")] + +use crate::{Backend, ServiceStatus, windows_config::Config}; +use anyhow::{Result, ensure}; +use core::{mem::size_of, ptr}; +use windows::{ + Win32::{ + Foundation::{GlobalFree, HGLOBAL}, + Networking::WinInet::{ + INTERNET_OPTION_PER_CONNECTION_OPTION, INTERNET_OPTION_PROXY_SETTINGS_CHANGED, + INTERNET_OPTION_REFRESH, INTERNET_PER_CONN, INTERNET_PER_CONN_AUTOCONFIG_URL, + INTERNET_PER_CONN_FLAGS, INTERNET_PER_CONN_FLAGS_UI, INTERNET_PER_CONN_OPTION_LISTW, + INTERNET_PER_CONN_OPTIONW, INTERNET_PER_CONN_OPTIONW_0, INTERNET_PER_CONN_PROXY_BYPASS, + INTERNET_PER_CONN_PROXY_SERVER, InternetQueryOptionW, InternetSetOptionW, + }, + }, + core::PWSTR, +}; + +pub(super) struct Native; + +fn native_result(result: windows::core::Result<()>) -> Result<()> { + result.map_err(|error| anyhow::anyhow!("WinINet operation failed (HRESULT {})", error.code().0)) +} + +fn option(kind: INTERNET_PER_CONN) -> INTERNET_PER_CONN_OPTIONW { + INTERNET_PER_CONN_OPTIONW { + dwOption: kind, + Value: INTERNET_PER_CONN_OPTIONW_0::default(), + } +} + +fn list(options: &mut [INTERNET_PER_CONN_OPTIONW]) -> Result { + Ok(INTERNET_PER_CONN_OPTION_LISTW { + dwSize: u32::try_from(size_of::())?, + pszConnection: PWSTR::null(), + dwOptionCount: u32::try_from(options.len())?, + dwOptionError: 0, + pOptions: options.as_mut_ptr(), + }) +} + +// Query allocates the three strings with GlobalAlloc, including when a later +// conversion fails. Never log their contents (PAC URLs may contain credentials). +struct Query([INTERNET_PER_CONN_OPTIONW; 4]); +impl Drop for Query { + fn drop(&mut self) { + for option in &self.0[1..] { + // These entries are string options, zero-initialized before querying. + unsafe { + let pointer = option.Value.pszValue.0; + if !pointer.is_null() { + let _ = GlobalFree(Some(HGLOBAL(pointer.cast()))); + } + } + } + } +} + +fn string(option: &INTERNET_PER_CONN_OPTIONW) -> Result { + // Only called on successfully queried string options. WinINet provides a + // NUL-terminated UTF-16 allocation owned by Query until conversion completes. + unsafe { + let value = option.Value.pszValue; + if value.is_null() { + return Ok(String::new()); + } + value + .to_string() + .map_err(|_| anyhow::anyhow!("invalid UTF-16 system proxy setting")) + } +} + +impl Backend for Native { + type Snapshot = Config; + + fn read() -> Result { + let mut query = Query([ + option(INTERNET_PER_CONN(INTERNET_PER_CONN_FLAGS_UI)), + option(INTERNET_PER_CONN_PROXY_SERVER), + option(INTERNET_PER_CONN_PROXY_BYPASS), + option(INTERNET_PER_CONN_AUTOCONFIG_URL), + ]); + let mut list = list(&mut query.0)?; + let mut length = list.dwSize; + // Null session/connection selects current-user LAN settings. FLAGS_UI + // preserves the user's WPAD choice rather than its last detection result. + native_result(unsafe { + InternetQueryOptionW( + None, + INTERNET_OPTION_PER_CONNECTION_OPTION, + Some(ptr::from_mut(&mut list).cast()), + &raw mut length, + ) + })?; + ensure!( + list.dwOptionError == 0, + "WinINet could not read a proxy option" + ); + Ok(Config { + // The first option is the DWORD flags, not an allocated string. + flags: unsafe { query.0[0].Value.dwValue }, + server: string(&query.0[1])?, + bypass: string(&query.0[2])?, + pac: string(&query.0[3])?, + }) + } + + fn services(snapshot: &Config) -> Result> { + Ok(vec![snapshot.status()?]) + } + + fn prepare(snapshot: &Config, enabled: bool) -> Result { + snapshot.prepare(enabled) + } + + fn write(before: &Config, after: &Config) -> Result<()> { + // WinINet has no compare-and-swap API; this narrows but cannot eliminate + // races with other processes. Never write bypass or the retained PAC URL. + ensure!( + Self::read()? == *before, + "system proxy settings changed; try again" + ); + let mut server: Vec = after.server.encode_utf16().chain([0]).collect(); + let mut options = [ + INTERNET_PER_CONN_OPTIONW { + dwOption: INTERNET_PER_CONN_FLAGS, + Value: INTERNET_PER_CONN_OPTIONW_0 { + dwValue: after.flags, + }, + }, + INTERNET_PER_CONN_OPTIONW { + dwOption: INTERNET_PER_CONN_PROXY_SERVER, + Value: INTERNET_PER_CONN_OPTIONW_0 { + pszValue: PWSTR(server.as_mut_ptr()), + }, + }, + ]; + let mut list = list(&mut options)?; + // list, options and server remain alive through the synchronous call. + native_result(unsafe { + InternetSetOptionW( + None, + INTERNET_OPTION_PER_CONNECTION_OPTION, + Some(ptr::from_mut(&mut list).cast()), + list.dwSize, + ) + })?; + ensure!( + list.dwOptionError == 0, + "WinINet could not apply a proxy option" + ); + // Attempt both notifications even if the first fails; do not roll back + // successful writes or report them as an unchanged configuration. + let changed = native_result(unsafe { + InternetSetOptionW(None, INTERNET_OPTION_PROXY_SETTINGS_CHANGED, None, 0) + }); + let refresh = + native_result(unsafe { InternetSetOptionW(None, INTERNET_OPTION_REFRESH, None, 0) }); + changed.and(refresh) + } +} diff --git a/crates/wizard-system-proxy/src/windows_config.rs b/crates/wizard-system-proxy/src/windows_config.rs new file mode 100644 index 0000000..77fcae3 --- /dev/null +++ b/crates/wizard-system-proxy/src/windows_config.rs @@ -0,0 +1,128 @@ +//! `WinINet`'s string grammar and flags, compiled and tested on every platform. +#![cfg_attr(not(any(target_os = "windows", test)), expect(dead_code))] + +use crate::{ProxyState, ServiceStatus, endpoint_state}; +use anyhow::{Result, ensure}; + +// WinINet PROXY_TYPE_* values. Kept here so policy tests need no Windows API. +pub(super) const DIRECT: u32 = 1; +pub(super) const PROXY: u32 = 2; +const AUTO_URL: u32 = 4; +const AUTO_DETECT: u32 = 8; + +#[derive(Clone, PartialEq, Eq)] +pub(super) struct Config { + pub flags: u32, + pub server: String, + pub bypass: String, + pub pac: String, +} + +fn entries(server: &str) -> Result> { + if !server.contains('=') { + ensure!( + !server.contains(';'), + "unsupported system proxy server syntax" + ); + return Ok(vec![("", server.trim())]); + } + let mut entries = Vec::new(); + for entry in server.split(';').filter(|s| !s.trim().is_empty()) { + let (protocol, endpoint) = entry + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("unsupported system proxy server syntax"))?; + let protocol = protocol.trim(); + let endpoint = endpoint.trim(); + ensure!( + !protocol.is_empty() + && !endpoint.is_empty() + && !entries + .iter() + .any(|(p, _): &(&str, &str)| p.eq_ignore_ascii_case(protocol)), + "invalid or duplicate system proxy protocol" + ); + entries.push((protocol, endpoint)); + } + ensure!(!entries.is_empty(), "empty system proxy protocol list"); + Ok(entries) +} + +impl Config { + pub fn status(&self) -> Result { + let active = self.flags & PROXY != 0; + let parsed = if active { + entries(&self.server)? + } else { + Vec::new() + }; + let endpoint = |protocol: &str| { + parsed + .iter() + .find(|(p, _)| p.is_empty() || p.eq_ignore_ascii_case(protocol)) + .map_or("", |(_, value)| *value) + }; + let http = endpoint_state(active && !endpoint("http").is_empty(), endpoint("http")); + let https = endpoint_state(active && !endpoint("https").is_empty(), endpoint("https")); + let other = parsed.iter().any(|(p, _)| { + !p.is_empty() && !p.eq_ignore_ascii_case("http") && !p.eq_ignore_ascii_case("https") + }); + Ok(ServiceStatus { + id: "current-user-lan".into(), + http, + https, + conflict: http == ProxyState::Foreign + || https == ProxyState::Foreign + || other + || (active && self.server.trim().is_empty()) + || self.flags & (AUTO_URL | AUTO_DETECT) != 0 + || self.flags & !(DIRECT | PROXY | AUTO_URL | AUTO_DETECT) != 0, + }) + } + + pub fn prepare(&self, enabled: bool) -> Result { + let mut after = self.clone(); + if enabled { + let address = wizard_proxy::CLAUDE_DESKTOP_ADDRESS; + // WinINet has a single manual-proxy switch; retaining a dormant SOCKS + // or FTP mapping would activate it. Refuse rather than erase it. + if self.flags & PROXY == 0 && self.server.contains('=') { + ensure!( + !entries(&self.server)? + .iter() + .any(|(p, _)| !p.eq_ignore_ascii_case("http") + && !p.eq_ignore_ascii_case("https")), + "inactive non-HTTP proxy mappings would be activated; clear them first" + ); + } + after.server = format!("http={address};https={address}"); + after.flags |= DIRECT | PROXY; + } else if self.flags & PROXY != 0 { + let parsed = entries(&self.server)?; + if parsed.len() == 1 && parsed[0].0.is_empty() { + if parsed[0].1 == wizard_proxy::CLAUDE_DESKTOP_ADDRESS { + after.flags = (after.flags & !PROXY) | DIRECT; + } + } else { + let remaining: Vec<_> = self + .server + .split(';') + .filter(|entry| { + let Some((protocol, address)) = entry.split_once('=') else { + return true; + }; + !((protocol.trim().eq_ignore_ascii_case("http") + || protocol.trim().eq_ignore_ascii_case("https")) + && address.trim() == wizard_proxy::CLAUDE_DESKTOP_ADDRESS) + }) + .collect(); + let server = remaining.join(";"); + if server.trim_matches(';').trim().is_empty() { + after.flags = (after.flags & !PROXY) | DIRECT; + } else { + after.server = server; + } + } + } + Ok(after) + } +}