proxy.rs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. //! Global outbound HTTP proxy plumbing.
  2. //!
  3. //! At app launch we read the user-set proxy config out of the same
  4. //! `app-state.json` the frontend's tauri-plugin-store writes, and
  5. //! translate it into HTTP_PROXY / HTTPS_PROXY / NO_PROXY environment
  6. //! variables. reqwest (used by tauri-plugin-http) reads those env
  7. //! vars on client construction and routes every outbound request
  8. //! through the configured proxy.
  9. //!
  10. //! Reading the on-disk JSON directly (rather than going through a
  11. //! Rust binding to plugin-store) keeps this module independent of
  12. //! plugin lifecycle: we only need a stable file path and serde.
  13. //! Cost is one duplicated key name (`proxyConfig`) — see
  14. //! src/lib/project-store.ts for the matching write site.
  15. use std::path::Path;
  16. use serde::{Deserialize, Serialize};
  17. const DEFAULT_BYPASS_LIST: &str =
  18. "localhost,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,*.local";
  19. #[derive(Debug, Serialize, Deserialize)]
  20. pub struct ProxyConfig {
  21. #[serde(default)]
  22. pub enabled: bool,
  23. #[serde(default)]
  24. pub url: String,
  25. #[serde(default = "default_true", rename = "bypassLocal")]
  26. pub bypass_local: bool,
  27. }
  28. // Hand-written Default impl so its `bypass_local` matches what
  29. // serde produces for a missing field — `derive(Default)` would
  30. // give `false`, which would silently disagree with the
  31. // "missing-key means bypass on" semantics encoded by the serde
  32. // `default = "default_true"` attribute. No caller invokes
  33. // `ProxyConfig::default()` today, but pinning the two paths to
  34. // the same value avoids a footgun if one ever does.
  35. impl Default for ProxyConfig {
  36. fn default() -> Self {
  37. Self {
  38. enabled: false,
  39. url: String::new(),
  40. bypass_local: true,
  41. }
  42. }
  43. }
  44. fn default_true() -> bool {
  45. true
  46. }
  47. /// Read `proxyConfig` out of the project's `app-state.json`. Returns
  48. /// None if the file doesn't exist, can't be parsed, or has no proxy
  49. /// section — caller treats those identically to "no proxy".
  50. pub fn read_proxy_config_from_store(store_path: &Path) -> Option<ProxyConfig> {
  51. let content = std::fs::read_to_string(store_path).ok()?;
  52. let json: serde_json::Value = serde_json::from_str(&content).ok()?;
  53. let proxy = json.get("proxyConfig")?;
  54. serde_json::from_value(proxy.clone()).ok()
  55. }
  56. /// Read and apply the stored proxy config. Missing or unreadable
  57. /// config is treated exactly like a disabled proxy, so inherited
  58. /// HTTP_PROXY / HTTPS_PROXY / NO_PROXY values from the parent process
  59. /// cannot silently affect users who never enabled proxy in the app.
  60. pub fn apply_proxy_env_from_store(store_path: &Path) -> String {
  61. let config = read_proxy_config_from_store(store_path).unwrap_or_default();
  62. apply_proxy_env(&config)
  63. }
  64. /// Apply a proxy config by setting the env vars reqwest reads.
  65. /// Returns a short human-readable summary for logging.
  66. ///
  67. /// Validates the URL scheme — only `http://` and `https://` are
  68. /// accepted in this version. Anything else (SOCKS5, malformed,
  69. /// missing scheme) is treated as "disabled" so the user doesn't
  70. /// silently trip over a half-working proxy.
  71. ///
  72. /// Concurrency note: `std::env::set_var` mutates process-wide
  73. /// state and is racy if any other thread reads env at the same
  74. /// instant. Two callers exist: (1) the Tauri setup hook, which
  75. /// runs before any HTTP client thread starts (safe), and (2)
  76. /// the `set_proxy_env` IPC command, which can race with an
  77. /// in-flight `reqwest::Client::new()` reading HTTP_PROXY. The
  78. /// race window is microseconds and the worst case is one fetch
  79. /// reading the previous value — acceptable for a user-initiated
  80. /// toggle, and matches what every other "global proxy switch"
  81. /// in similar apps does. Documented here so a future Rust
  82. /// edition that hard-fails on this pattern doesn't surprise us.
  83. pub fn apply_proxy_env(config: &ProxyConfig) -> String {
  84. // Every "disabled" path MUST clear all three env vars, not just
  85. // return — otherwise toggling the proxy off after it was on
  86. // leaves the previous values in place and reqwest keeps routing
  87. // through the now-removed proxy. The same applies to invalid
  88. // URLs and unsupported schemes (treat as disabled).
  89. let url = config.url.trim();
  90. let invalid_scheme =
  91. !url.starts_with("http://") && !url.starts_with("https://");
  92. if !config.enabled || url.is_empty() || invalid_scheme {
  93. clear_proxy_env();
  94. return if !config.enabled {
  95. "disabled".to_string()
  96. } else if url.is_empty() {
  97. "disabled (empty url)".to_string()
  98. } else {
  99. // Mask before logging — an invalid URL might still
  100. // contain a password the user mis-typed.
  101. format!("disabled (unsupported scheme: {})", redact_url(url))
  102. };
  103. }
  104. set_proxy_var("HTTP_PROXY", url);
  105. set_proxy_var("HTTPS_PROXY", url);
  106. set_proxy_var("ALL_PROXY", url);
  107. if config.bypass_local {
  108. set_proxy_var("NO_PROXY", DEFAULT_BYPASS_LIST);
  109. } else {
  110. // Bypass off — clear NO_PROXY so a previously-set value
  111. // doesn't leak through.
  112. remove_proxy_var("NO_PROXY");
  113. }
  114. format!(
  115. "enabled ({}, bypass_local={})",
  116. redact_url(url),
  117. config.bypass_local
  118. )
  119. }
  120. /// Strip embedded basic-auth credentials from a URL before logging.
  121. /// `http://user:pass@host:port` → `http://***@host:port`. URLs
  122. /// without credentials pass through untouched. Used so stderr /
  123. /// Console.app / journalctl output doesn't persist proxy
  124. /// passwords.
  125. fn redact_url(url: &str) -> String {
  126. // Find `scheme://` then check for `user[:pass]@` between that
  127. // and the next `/` (or end). If found, replace with `***@`.
  128. let scheme_end = match url.find("://") {
  129. Some(i) => i + 3,
  130. None => return url.to_string(),
  131. };
  132. let after_scheme = &url[scheme_end..];
  133. // The userinfo segment, if present, is up to the first '@'
  134. // that comes BEFORE the first '/'. A '@' after a '/' is part
  135. // of the path and must not be matched.
  136. let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
  137. let userinfo_end = match after_scheme[..path_start].find('@') {
  138. Some(i) => i,
  139. None => return url.to_string(), // no credentials embedded
  140. };
  141. let mut out = String::with_capacity(url.len());
  142. out.push_str(&url[..scheme_end]);
  143. out.push_str("***");
  144. out.push_str(&after_scheme[userinfo_end..]);
  145. out
  146. }
  147. /// Remove all three proxy env vars. Called whenever the user
  148. /// disables the proxy or supplies an invalid URL — this is what
  149. /// makes "turn off proxy" actually take effect for the next fetch
  150. /// (without it, the previous HTTP_PROXY / HTTPS_PROXY / NO_PROXY
  151. /// stay set in the process env and reqwest keeps using them).
  152. fn clear_proxy_env() {
  153. remove_proxy_var("HTTP_PROXY");
  154. remove_proxy_var("HTTPS_PROXY");
  155. remove_proxy_var("ALL_PROXY");
  156. remove_proxy_var("NO_PROXY");
  157. }
  158. fn set_proxy_var(name: &str, value: &str) {
  159. std::env::set_var(name, value);
  160. std::env::set_var(name.to_ascii_lowercase(), value);
  161. }
  162. fn remove_proxy_var(name: &str) {
  163. std::env::remove_var(name);
  164. std::env::remove_var(name.to_ascii_lowercase());
  165. }
  166. #[cfg(test)]
  167. mod tests {
  168. use super::*;
  169. /// Cargo runs tests in parallel by default and these tests all
  170. /// touch the same process-wide env vars. Without serializing
  171. /// them, one test's set_var leaks into another test's
  172. /// assertion. A single mutex shared across the test module
  173. /// forces them to run one at a time without bringing in a
  174. /// `serial_test` dependency.
  175. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
  176. /// Run a closure with the proxy-related env vars cleared (and
  177. /// serialized via ENV_MUTEX), then restore whatever was there
  178. /// before — keeps tests from contaminating each other or the
  179. /// host shell.
  180. fn isolated<F: FnOnce()>(f: F) {
  181. // Recover from poison — a panic in one test leaves the
  182. // mutex poisoned, but we have no shared state inside it
  183. // so resuming with the inner () is safe.
  184. let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
  185. let names = [
  186. "HTTP_PROXY",
  187. "HTTPS_PROXY",
  188. "ALL_PROXY",
  189. "NO_PROXY",
  190. "http_proxy",
  191. "https_proxy",
  192. "all_proxy",
  193. "no_proxy",
  194. ];
  195. let snap = names.map(|name| std::env::var(name).ok());
  196. for name in names {
  197. std::env::remove_var(name);
  198. }
  199. f();
  200. for (name, value) in names.into_iter().zip(snap) {
  201. match value {
  202. Some(v) => std::env::set_var(name, v),
  203. None => std::env::remove_var(name),
  204. }
  205. }
  206. }
  207. #[test]
  208. fn disabled_sets_no_env() {
  209. isolated(|| {
  210. let s = apply_proxy_env(&ProxyConfig {
  211. enabled: false,
  212. url: "http://x:1".into(),
  213. bypass_local: true,
  214. });
  215. assert!(s.contains("disabled"));
  216. assert!(std::env::var("HTTP_PROXY").is_err());
  217. assert!(std::env::var("HTTPS_PROXY").is_err());
  218. assert!(std::env::var("ALL_PROXY").is_err());
  219. assert!(std::env::var("http_proxy").is_err());
  220. assert!(std::env::var("https_proxy").is_err());
  221. assert!(std::env::var("all_proxy").is_err());
  222. });
  223. }
  224. #[test]
  225. fn enabled_sets_both_proxy_envs() {
  226. isolated(|| {
  227. apply_proxy_env(&ProxyConfig {
  228. enabled: true,
  229. url: "http://127.0.0.1:7890".into(),
  230. bypass_local: true,
  231. });
  232. assert_eq!(
  233. std::env::var("HTTP_PROXY").unwrap(),
  234. "http://127.0.0.1:7890"
  235. );
  236. assert_eq!(
  237. std::env::var("HTTPS_PROXY").unwrap(),
  238. "http://127.0.0.1:7890"
  239. );
  240. assert_eq!(
  241. std::env::var("ALL_PROXY").unwrap(),
  242. "http://127.0.0.1:7890"
  243. );
  244. assert_eq!(
  245. std::env::var("http_proxy").unwrap(),
  246. "http://127.0.0.1:7890"
  247. );
  248. assert_eq!(
  249. std::env::var("https_proxy").unwrap(),
  250. "http://127.0.0.1:7890"
  251. );
  252. assert_eq!(
  253. std::env::var("all_proxy").unwrap(),
  254. "http://127.0.0.1:7890"
  255. );
  256. let no_proxy = std::env::var("NO_PROXY").unwrap();
  257. let lowercase_no_proxy = std::env::var("no_proxy").unwrap();
  258. assert!(no_proxy.contains("localhost"));
  259. assert!(no_proxy.contains("127.0.0.0/8"));
  260. assert!(no_proxy.contains("192.168.0.0/16"));
  261. assert_eq!(lowercase_no_proxy, no_proxy);
  262. });
  263. }
  264. #[test]
  265. fn bypass_local_off_clears_no_proxy() {
  266. isolated(|| {
  267. std::env::set_var("NO_PROXY", "stale-value");
  268. apply_proxy_env(&ProxyConfig {
  269. enabled: true,
  270. url: "http://x:1".into(),
  271. bypass_local: false,
  272. });
  273. // The stale value must be cleared so the user's intent
  274. // (everything goes through the proxy) is honored.
  275. assert!(std::env::var("NO_PROXY").is_err());
  276. });
  277. }
  278. #[test]
  279. fn rejects_unsupported_schemes() {
  280. isolated(|| {
  281. apply_proxy_env(&ProxyConfig {
  282. enabled: true,
  283. url: "socks5://x:1".into(),
  284. bypass_local: true,
  285. });
  286. assert!(std::env::var("HTTP_PROXY").is_err());
  287. });
  288. }
  289. #[test]
  290. fn rejects_empty_url() {
  291. isolated(|| {
  292. apply_proxy_env(&ProxyConfig {
  293. enabled: true,
  294. url: " ".into(),
  295. bypass_local: true,
  296. });
  297. assert!(std::env::var("HTTP_PROXY").is_err());
  298. });
  299. }
  300. #[test]
  301. fn disable_after_enable_clears_previously_set_env_vars() {
  302. // Regression: if the user enabled the proxy, then disables
  303. // it, the next request must NOT keep going through the
  304. // (now-removed) proxy. apply_proxy_env's "disabled" path
  305. // must actively unset HTTP_PROXY / HTTPS_PROXY / NO_PROXY,
  306. // not just return without writing.
  307. isolated(|| {
  308. apply_proxy_env(&ProxyConfig {
  309. enabled: true,
  310. url: "http://127.0.0.1:7890".into(),
  311. bypass_local: true,
  312. });
  313. assert_eq!(
  314. std::env::var("HTTP_PROXY").unwrap(),
  315. "http://127.0.0.1:7890",
  316. );
  317. apply_proxy_env(&ProxyConfig {
  318. enabled: false,
  319. url: "http://127.0.0.1:7890".into(),
  320. bypass_local: true,
  321. });
  322. assert!(std::env::var("HTTP_PROXY").is_err());
  323. assert!(std::env::var("HTTPS_PROXY").is_err());
  324. assert!(std::env::var("ALL_PROXY").is_err());
  325. assert!(std::env::var("NO_PROXY").is_err());
  326. assert!(std::env::var("http_proxy").is_err());
  327. assert!(std::env::var("https_proxy").is_err());
  328. assert!(std::env::var("all_proxy").is_err());
  329. assert!(std::env::var("no_proxy").is_err());
  330. });
  331. }
  332. #[test]
  333. fn unsupported_scheme_after_enable_clears_env() {
  334. // Same regression class for invalid-URL changes — switching
  335. // the URL to something we won't apply (socks5://) must clear
  336. // any previously-applied http(s) values, not silently keep
  337. // them.
  338. isolated(|| {
  339. apply_proxy_env(&ProxyConfig {
  340. enabled: true,
  341. url: "http://127.0.0.1:7890".into(),
  342. bypass_local: true,
  343. });
  344. apply_proxy_env(&ProxyConfig {
  345. enabled: true,
  346. url: "socks5://x:1".into(),
  347. bypass_local: true,
  348. });
  349. assert!(std::env::var("HTTP_PROXY").is_err());
  350. });
  351. }
  352. #[test]
  353. fn https_proxy_url_is_supported() {
  354. isolated(|| {
  355. apply_proxy_env(&ProxyConfig {
  356. enabled: true,
  357. url: "https://proxy.corp:443".into(),
  358. bypass_local: false,
  359. });
  360. assert_eq!(
  361. std::env::var("HTTPS_PROXY").unwrap(),
  362. "https://proxy.corp:443"
  363. );
  364. });
  365. }
  366. #[test]
  367. fn redacts_basic_auth_credentials_in_url() {
  368. assert_eq!(
  369. redact_url("http://user:pass@proxy.corp:8080"),
  370. "http://***@proxy.corp:8080",
  371. );
  372. // URL with path after host: '@' in path must not be matched
  373. assert_eq!(
  374. redact_url("http://user:pass@proxy.corp:8080/some@path"),
  375. "http://***@proxy.corp:8080/some@path",
  376. );
  377. // Username only (no password)
  378. assert_eq!(
  379. redact_url("http://user@proxy.corp:8080"),
  380. "http://***@proxy.corp:8080",
  381. );
  382. // No credentials — pass through
  383. assert_eq!(
  384. redact_url("http://proxy.corp:8080"),
  385. "http://proxy.corp:8080",
  386. );
  387. // No scheme at all (defensive — invalid URL shouldn't crash)
  388. assert_eq!(redact_url("garbage"), "garbage");
  389. }
  390. #[test]
  391. fn apply_proxy_env_summary_does_not_leak_password() {
  392. isolated(|| {
  393. let summary = apply_proxy_env(&ProxyConfig {
  394. enabled: true,
  395. url: "http://secretuser:secretpass@proxy.corp:8080".into(),
  396. bypass_local: true,
  397. });
  398. assert!(!summary.contains("secretpass"));
  399. assert!(!summary.contains("secretuser"));
  400. assert!(summary.contains("***"));
  401. assert!(summary.contains("proxy.corp:8080"));
  402. });
  403. }
  404. #[test]
  405. fn default_trait_matches_serde_missing_field_semantics() {
  406. // Regression: derive(Default) makes bypass_local = false, but
  407. // serde with `default = "default_true"` makes a missing field
  408. // = true. The two must agree so a ProxyConfig::default() and
  409. // a serde-deserialized empty `{}` produce identical values.
  410. let default_via_trait = ProxyConfig::default();
  411. let default_via_serde: ProxyConfig = serde_json::from_str("{}").unwrap();
  412. assert_eq!(default_via_trait.enabled, default_via_serde.enabled);
  413. assert_eq!(default_via_trait.url, default_via_serde.url);
  414. assert_eq!(
  415. default_via_trait.bypass_local, default_via_serde.bypass_local,
  416. "Default trait and serde-default must agree on bypass_local",
  417. );
  418. // And both should be the safe default: bypass on, proxy off.
  419. assert!(!default_via_trait.enabled);
  420. assert!(default_via_trait.bypass_local);
  421. }
  422. #[test]
  423. fn parses_camelcase_bypassLocal_field() {
  424. // Frontend writes `bypassLocal` (camelCase). We must accept
  425. // that exact spelling — verify the serde rename works.
  426. let json = r#"{"enabled": true, "url": "http://x:1", "bypassLocal": false}"#;
  427. let cfg: ProxyConfig = serde_json::from_str(json).unwrap();
  428. assert!(cfg.enabled);
  429. assert_eq!(cfg.url, "http://x:1");
  430. assert!(!cfg.bypass_local);
  431. }
  432. #[test]
  433. fn missing_proxyConfig_returns_none() {
  434. let dir = tempdir_for_test();
  435. let path = dir.join("missing.json");
  436. assert!(read_proxy_config_from_store(&path).is_none());
  437. }
  438. #[test]
  439. fn missing_store_config_clears_inherited_proxy_env() {
  440. isolated(|| {
  441. let dir = tempdir_for_test();
  442. let path = dir.join("missing.json");
  443. std::env::set_var("HTTP_PROXY", "http://inherited:8080");
  444. std::env::set_var("HTTPS_PROXY", "http://inherited:8080");
  445. std::env::set_var("ALL_PROXY", "http://inherited:8080");
  446. std::env::set_var("NO_PROXY", "localhost");
  447. std::env::set_var("http_proxy", "http://inherited:8080");
  448. std::env::set_var("https_proxy", "http://inherited:8080");
  449. std::env::set_var("all_proxy", "http://inherited:8080");
  450. std::env::set_var("no_proxy", "localhost");
  451. let summary = apply_proxy_env_from_store(&path);
  452. assert!(summary.contains("disabled"));
  453. assert!(std::env::var("HTTP_PROXY").is_err());
  454. assert!(std::env::var("HTTPS_PROXY").is_err());
  455. assert!(std::env::var("ALL_PROXY").is_err());
  456. assert!(std::env::var("NO_PROXY").is_err());
  457. assert!(std::env::var("http_proxy").is_err());
  458. assert!(std::env::var("https_proxy").is_err());
  459. assert!(std::env::var("all_proxy").is_err());
  460. assert!(std::env::var("no_proxy").is_err());
  461. });
  462. }
  463. #[test]
  464. fn parses_proxy_config_from_store_file() {
  465. let dir = tempdir_for_test();
  466. let path = dir.join("app-state.json");
  467. std::fs::write(
  468. &path,
  469. r#"{"proxyConfig": {"enabled": true, "url": "http://x:1", "bypassLocal": true}}"#,
  470. )
  471. .unwrap();
  472. let cfg = read_proxy_config_from_store(&path).unwrap();
  473. assert!(cfg.enabled);
  474. assert_eq!(cfg.url, "http://x:1");
  475. }
  476. #[test]
  477. fn ignores_store_file_with_no_proxy_section() {
  478. let dir = tempdir_for_test();
  479. let path = dir.join("app-state.json");
  480. std::fs::write(&path, r#"{"otherKey": "value"}"#).unwrap();
  481. assert!(read_proxy_config_from_store(&path).is_none());
  482. }
  483. fn tempdir_for_test() -> std::path::PathBuf {
  484. let stamp = std::time::SystemTime::now()
  485. .duration_since(std::time::UNIX_EPOCH)
  486. .unwrap()
  487. .as_nanos();
  488. let dir = std::env::temp_dir().join(format!("llm-wiki-proxy-test-{stamp}"));
  489. std::fs::create_dir_all(&dir).unwrap();
  490. dir
  491. }
  492. }