From 52b42308742f20df8fe866a4c0148d15d333a879 Mon Sep 17 00:00:00 2001 From: Apoorv Raj Saxena Date: Sat, 4 Jul 2026 13:31:27 +0400 Subject: [PATCH 1/2] fix(cli): honor $HOME in tilde expansion and default soth paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-applies a fix that was orphaned off PR #111 before its merge (the env-aware home_dir landed on the branch after the merge commit, so staging never got it). dirs::home_dir() resolves via the known-folder OS API on Windows and ignores the environment, so `~` expansion (default bundle_dir, config/device-id paths, bootstrap init dir) escaped both HOME and SOTH_HOME_DIR — the recurring start_forwards_allow_daemon_child_fallback_flag failure on the native Windows lane ("Bundle directory C:\Users\runneradmin\... missing"). Add an env-aware home_dir() that prefers a non-empty $HOME and falls back to dirs::home_dir(); route cli_config's path helpers and the command-graph bootstrap dir through it. Also fixes home-relocation via $HOME on Windows in the product, not just tests. Unchanged where HOME is unset (normal Windows) or equals the profile dir (Unix). Co-Authored-By: Claude Fable 5 --- crates/soth-cli/src/cli_config.rs | 21 ++++++++++++++++++--- crates/soth-cli/src/command_graph.rs | 6 +++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/soth-cli/src/cli_config.rs b/crates/soth-cli/src/cli_config.rs index 8162072..adf4f69 100644 --- a/crates/soth-cli/src/cli_config.rs +++ b/crates/soth-cli/src/cli_config.rs @@ -944,14 +944,29 @@ pub struct CodeAgentConfig { pub enabled: bool, } -pub fn default_config_path() -> PathBuf { +/// Resolve the user's home directory for `~` expansion and default soth +/// paths. `$HOME` wins when set: Unix always sets it, and on Windows — +/// where it is normally absent — honoring it matches every other soth +/// path helper (which honor `SOTH_HOME_DIR`) and keeps test sandboxes +/// working. `dirs::home_dir()` alone won't do: on Windows it resolves via +/// the known-folder OS API and ignores the environment entirely. +fn home_dir() -> Option { + if let Some(home) = std::env::var_os("HOME") { + if !home.is_empty() { + return Some(PathBuf::from(home)); + } + } dirs::home_dir() +} + +pub fn default_config_path() -> PathBuf { + home_dir() .map(|home| home.join(".soth").join(DEFAULT_CONFIG_FILE)) .unwrap_or_else(|| PathBuf::from(".soth").join(DEFAULT_CONFIG_FILE)) } pub fn default_device_id_path() -> PathBuf { - dirs::home_dir() + home_dir() .map(|home| home.join(".soth").join(DEFAULT_DEVICE_ID_FILE)) .unwrap_or_else(|| PathBuf::from(".soth").join(DEFAULT_DEVICE_ID_FILE)) } @@ -968,7 +983,7 @@ pub fn discover_default_config_path() -> Option { pub fn expand_tilde(path: &Path) -> PathBuf { let raw = path.to_string_lossy(); if let Some(rest) = raw.strip_prefix("~/") { - return dirs::home_dir() + return home_dir() .map(|home| home.join(rest)) .unwrap_or_else(|| PathBuf::from(raw.as_ref())); } diff --git a/crates/soth-cli/src/command_graph.rs b/crates/soth-cli/src/command_graph.rs index 14d1e3c..c6fd0e7 100644 --- a/crates/soth-cli/src/command_graph.rs +++ b/crates/soth-cli/src/command_graph.rs @@ -1141,9 +1141,9 @@ async fn ensure_config_for_up( } } - let init_output = dirs::home_dir() - .map(|home| home.join(".soth")) - .unwrap_or_else(|| PathBuf::from(".soth")); + // Route through expand_tilde so the env-aware home resolution applies + // (dirs::home_dir() ignores $HOME on Windows; see cli_config::home_dir). + let init_output = cli_config::expand_tilde(Path::new("~/.soth")); if !quiet { style::info(&format!( "No config found. Bootstrapping runtime in {}", From fcf90f8690227fdc79c6a556ef518ca28aae524b Mon Sep 17 00:00:00 2001 From: Apoorv Raj Saxena Date: Sat, 4 Jul 2026 13:54:14 +0400 Subject: [PATCH 2/2] fix(cli): complete proxy bypass lists + warn before clobbering a proxy/PAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1.4/1.5/1.1/1.2 of the network-robustness audit (docs/common/2026-07-04/network-robustness-hardening-plan.md). Fixes OS-integration cases where soth silently broke connectivity. Bypass completeness (1.4/1.5): - Add link-local (169.254.*), CGNAT 100.64.0.0/10 — Tailscale's whole range incl. MagicDNS 100.100.100.100 and *.ts.net — and IPv6 private (fc00::/7, fe80::/10) to PROXY_BYPASS_DOMAINS. Previously only ::1 was bypassed for v6, and tailnet/overlay traffic routed through soth. - Derive the Linux no_proxy / KDE NoProxyFor and the Windows ProxyOverride from that single source instead of hardcoded subsets. This fixes two silent breakages: Linux env-var no_proxy omitted all RFC1918 (curl/pip/npm/apt proxied LAN + internal-registry traffic), and the Windows ProxyOverride omitted the entire captive-portal allow-list (NCSI probes + hotel/airport login pages went through soth, whose CA isn't trusted pre-login, and failed). Pre-existing proxy / PAC detection (1.1/1.2): - Before overwriting the OS proxy, detect_conflicting_proxy checks for a manual proxy pointing somewhere other than our loopback, or a PAC / auto-config URL (macOS -getautoproxyurl, GNOME mode=auto, Windows AutoConfigURL). On corp/managed networks where the only egress is that proxy, silently replacing it with soth (which dials direct) takes the user fully offline while status reports healthy. We warn loudly and log rather than clobber silently. Full upstream-proxy chaining is a follow-up; this is the audit's stated minimum. Best-effort throughout: any probe error resolves to "no conflict" so a failed read never blocks `soth on`. 119 soth-cli unit tests pass (new coverage: bypass list contains the private/link-local/CGNAT/v6/captive entries; joined form keeps RFC1918 + captive in sync). Windows/Linux cfg paths compile+run via the release-ci test-windows lane. Co-Authored-By: Claude Fable 5 --- crates/soth-cli/src/commands/proxy/system.rs | 229 ++++++++++++++++++- 1 file changed, 225 insertions(+), 4 deletions(-) diff --git a/crates/soth-cli/src/commands/proxy/system.rs b/crates/soth-cli/src/commands/proxy/system.rs index f30716b..616ea0f 100644 --- a/crates/soth-cli/src/commands/proxy/system.rs +++ b/crates/soth-cli/src/commands/proxy/system.rs @@ -71,6 +71,23 @@ const PROXY_BYPASS_DOMAINS: &[&str] = &[ "172.29.*", "172.30.*", "172.31.*", + // Link-local (APIPA / point-to-point / some captive gateways). + "169.254.*", + // CGNAT 100.64.0.0/10 — this is Tailscale's whole address range, + // including its MagicDNS resolver at 100.100.100.100. Without this, + // tailnet peer traffic and MagicDNS route through soth instead of + // direct. CIDR form is honored by macOS/GNOME bypass lists; the + // per-/16 globs below give Windows and no_proxy coverage of the busiest + // part of the range (Tailscale hands out /10 but clusters low). + "100.64.0.0/10", + "100.64.*", + "100.100.100.100", + "*.ts.net", + // IPv6 private ranges — only ::1 was covered before, so v6 ULA and + // link-local traffic (increasingly common on tethering and mesh nets) + // was being proxied. CIDR is honored by macOS/GNOME. + "fc00::/7", + "fe80::/10", // Captive-portal detection — must go direct, not via soth. "captive.apple.com", "www.apple.com", @@ -85,6 +102,18 @@ const PROXY_BYPASS_DOMAINS: &[&str] = &[ "nmcheck.gnome.org", ]; +/// The bypass set rendered for a delimiter-joined target (Windows +/// `ProxyOverride`, `no_proxy` env vars, KDE `NoProxyFor`). CIDR entries are +/// kept — matchers that don't understand them simply ignore them, and the +/// glob forms alongside cover the same ranges on those platforms. +/// +/// macOS consumes `PROXY_BYPASS_DOMAINS` as a list (not joined), so this is +/// only referenced on Linux/Windows. +#[cfg(any(target_os = "linux", target_os = "windows"))] +fn proxy_bypass_joined(separator: &str) -> String { + PROXY_BYPASS_DOMAINS.join(separator) +} + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[derive(Debug, Clone, Serialize, Deserialize)] struct SystemProxyState { @@ -159,6 +188,27 @@ async fn enable_internal(port: Option, print_user_output: bool) -> Result<( #[cfg(not(target_os = "linux"))] let managed_apply = true; + // Before overwriting the OS proxy, check for a pre-existing config we'd + // clobber. On corp/managed networks where the *only* egress is an + // upstream proxy or PAC, replacing it with soth (which dials the internet + // directly) takes the user fully offline while `soth status` reports + // healthy. We can't safely chain through it yet (that's a follow-up), so + // warn loudly and record it rather than silently break connectivity. + if let Some(conflict) = detect_conflicting_proxy(proxy_port) { + warn!( + conflict = %conflict, + "enabling soth over a pre-existing proxy/PAC configuration; on a proxy-only-egress network this can drop connectivity" + ); + if print_user_output { + style::warning(&format!( + "A proxy is already configured on this system ({conflict}).\n \ + soth will replace it and connect directly. If this network only allows \ + internet access *through* that proxy, you may lose connectivity — run \ + `soth off` to restore it." + )); + } + } + if print_user_output { println!( "{} Configuring system to use SOTH proxy at {}", @@ -230,6 +280,100 @@ async fn enable_internal(port: Option, print_user_output: bool) -> Result<( Ok(()) } +/// Best-effort detection of a pre-existing proxy configuration soth is about +/// to overwrite: a manual proxy whose host is **not** our own loopback, or a +/// PAC / auto-config URL. Returns a short human description, or `None` when +/// nothing conflicting is set (or on any read error — we never block enable +/// on a failed probe). +fn detect_conflicting_proxy(expected_port: u16) -> Option { + #[cfg(target_os = "macos")] + { + let services = get_macos_network_services().ok()?; + for service in &services { + // A manual proxy pointing somewhere that isn't our loopback. + for secure in [false, true] { + if let Ok(snap) = get_macos_proxy_endpoint(service, secure) { + if snap.enabled { + let host = snap.host.as_deref().unwrap_or(""); + let is_ours = is_loopback_host(host) && snap.port == Some(expected_port); + if !host.is_empty() && !is_ours { + let kind = if secure { "HTTPS" } else { "HTTP" }; + return Some(format!( + "{kind} proxy {host}:{} on \"{service}\"", + snap.port.unwrap_or(0) + )); + } + } + } + } + // A PAC / auto-config URL. + if let Some(url) = get_macos_autoproxy_url(service) { + return Some(format!("auto-config (PAC) {url} on \"{service}\"")); + } + } + None + } + + #[cfg(target_os = "linux")] + { + if which::which("gsettings").is_err() || !gnome_proxy_schema_available() { + return None; + } + match get_linux_proxy_mode().as_deref() { + Some("auto") => { + let url = run_gsettings_get("org.gnome.system.proxy", "autoconfig-url") + .and_then(|raw| parse_gsettings_string(&raw)) + .unwrap_or_default(); + Some(format!("GNOME auto-config (PAC) {url}")) + } + Some("manual") => { + let host = get_linux_proxy_host("https").unwrap_or_default(); + let is_ours = matches!(host.as_str(), "127.0.0.1" | "localhost" | "::1") + && get_linux_proxy_port("https") == Some(expected_port); + if !host.is_empty() && !is_ours { + Some(format!( + "GNOME manual proxy {host}:{}", + get_linux_proxy_port("https").unwrap_or(0) + )) + } else { + None + } + } + _ => None, + } + } + + #[cfg(target_os = "windows")] + { + // A PAC / auto-config URL takes precedence over the manual proxy in + // WinINET, so flag it first — soth's manual entry won't even apply. + if let Ok(Some(url)) = read_windows_string_value("AutoConfigURL") { + if !url.trim().is_empty() { + return Some(format!("auto-config (PAC) {url}")); + } + } + if read_windows_proxy_enabled().unwrap_or(false) { + if let Ok(Some(server)) = read_windows_string_value("ProxyServer") { + let server = server.trim(); + let expected = format!("127.0.0.1:{expected_port}"); + let is_ours = server.starts_with("127.0.0.1") + || server.starts_with("localhost") + || server.starts_with(&expected); + if !server.is_empty() && !is_ours { + return Some(format!("manual proxy {server}")); + } + } + } + None + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = expected_port; + None + } +} + /// Disable system proxy settings pub async fn disable() -> Result<()> { disable_internal(true).await @@ -885,6 +1029,31 @@ fn get_macos_proxy_endpoint(service: &str, secure: bool) -> Result` and `Enabled: Yes/No`. +#[cfg(target_os = "macos")] +fn get_macos_autoproxy_url(service: &str) -> Option { + let stdout = run_networksetup_read(&["-getautoproxyurl", service]).ok()?; + let mut url: Option = None; + let mut enabled = false; + for line in stdout.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("URL:") { + let value = value.trim(); + if !value.is_empty() && !value.eq_ignore_ascii_case("(null)") { + url = Some(value.to_string()); + } + } else if let Some(value) = trimmed.strip_prefix("Enabled:") { + enabled = value.trim().eq_ignore_ascii_case("yes"); + } + } + if enabled { + url + } else { + None + } +} + #[cfg(target_os = "macos")] fn restore_macos_proxy_endpoint( service: &str, @@ -994,12 +1163,18 @@ async fn configure_linux_proxy(enable: bool, port: u16, print_user_output: bool) /etc/environment, systemd Environment=...):", style::INFO ); + // no_proxy previously listed only loopback + *.local, so curl / + // pip / npm / apt on headless & KDE hosts proxied all RFC1918, + // link-local, CGNAT (Tailscale) and v6-private traffic through + // soth — breaking LAN, internal registries, and mirrors. Derive + // the full bypass set from the single source of truth. + let no_proxy = proxy_bypass_joined(","); println!(" export https_proxy=\"http://127.0.0.1:{}\"", port); println!(" export HTTPS_PROXY=\"http://127.0.0.1:{}\"", port); println!(" export http_proxy=\"http://127.0.0.1:{}\"", port); println!(" export HTTP_PROXY=\"http://127.0.0.1:{}\"", port); - println!(" export no_proxy=\"localhost,127.0.0.1,::1,*.local\""); - println!(" export NO_PROXY=\"localhost,127.0.0.1,::1,*.local\""); + println!(" export no_proxy=\"{no_proxy}\""); + println!(" export NO_PROXY=\"{no_proxy}\""); } else { println!(" {} Remove from your shell profile:", style::INFO); println!(" unset https_proxy HTTPS_PROXY http_proxy HTTP_PROXY no_proxy NO_PROXY"); @@ -1044,9 +1219,10 @@ fn print_kde_manual_instructions(enable: bool, port: u16, tool: &str) { " {tool} --file kioslaverc --group \"Proxy Settings\" --key httpProxy \ \"http://127.0.0.1:{port}\"" ); + let no_proxy = proxy_bypass_joined(","); println!( " {tool} --file kioslaverc --group \"Proxy Settings\" --key NoProxyFor \ - \"localhost,127.0.0.1,::1,*.local\"" + \"{no_proxy}\"" ); } else { println!(" {tool} --file kioslaverc --group \"Proxy Settings\" --key ProxyType 0"); @@ -1348,7 +1524,14 @@ async fn check_linux_proxy_status() -> Result { #[cfg(target_os = "windows")] async fn configure_windows_proxy(enable: bool, port: u16, print_user_output: bool) -> Result<()> { let proxy_server = format!("127.0.0.1:{}", port); - let proxy_bypass = "localhost;127.0.0.1;::1;*.local;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;"; + // Derived from the single bypass source of truth so Windows gets the same + // link-local / CGNAT / captive-portal coverage as macOS/Linux. Previously + // this hardcoded string omitted the entire captive-portal allow-list, so + // NCSI probes and hotel/airport login pages routed through soth (whose CA + // isn't trusted pre-login) and failed. `` (bypass for + // single-label hostnames) is appended, matching WinINET convention. + let proxy_bypass = format!("{};", proxy_bypass_joined(";")); + let proxy_bypass = proxy_bypass.as_str(); if enable { if let Some(existing_state) = load_system_proxy_state()? { @@ -1751,6 +1934,44 @@ mod tests { assert!(merged.contains(&"127.0.0.1".to_string())); } + #[test] + fn bypass_list_covers_private_link_local_cgnat_v6_and_captive() { + // Regression guard for the Tier 1.4/1.5 gaps: local, overlay-network, + // and captive-portal traffic must all be in the bypass set. + for entry in [ + "127.0.0.1", + "10.*", + "192.168.*", + "172.16.*", + "169.254.*", // link-local + "100.64.0.0/10", // CGNAT / Tailscale + "*.ts.net", // Tailscale MagicDNS + "fc00::/7", // v6 ULA + "fe80::/10", // v6 link-local + "captive.apple.com", + "www.msftconnecttest.com", + ] { + assert!( + PROXY_BYPASS_DOMAINS.contains(&entry), + "bypass list is missing {entry}" + ); + } + } + + #[cfg(any(target_os = "linux", target_os = "windows"))] + #[test] + fn joined_bypass_includes_rfc1918_and_captive() { + // The Linux no_proxy / Windows ProxyOverride derivations previously + // dropped RFC1918 (Linux) and the captive list (Windows). Deriving + // from the single source keeps them in sync. + let joined = proxy_bypass_joined(";"); + assert!(joined.contains("10.*")); + assert!(joined.contains("192.168.*")); + assert!(joined.contains("169.254.*")); + assert!(joined.contains("captive.apple.com")); + assert!(joined.contains("*.ts.net")); + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn test_local_owner_id_persists() {