From 376471ac06f4d0580dcee2063b71728ccab6a5bb Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 22 Aug 2026 14:49:27 +0300 Subject: [PATCH] fix(config): bypass proxy for loopback backend calls The config CLI built its HTTP client with reqwest::Client::new(), which honours HTTP_PROXY/HTTPS_PROXY and has no loopback exemption. The AionUi backend listens on loopback, so any proxy exported for unrelated traffic captured these local calls. The failure was misleading: requests never reached the router, so they were absent from aioncore.log while the backend served 200 for the same route, and the CLI reported CONFIG_HTTP_STATUS_ERROR ... status="503": AionUi backend returned an error status. That points at a healthy backend. config capabilities kept working because it answers locally, which made the CLI look partly alive and reinforced the wrong diagnosis. Build the client with no_proxy() when AIONUI_BASE_URL is loopback. A remote base URL keeps the proxy environment, since reaching it may depend on the proxy. --- crates/aionui-app/src/commands/cmd_config.rs | 62 +++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/aionui-app/src/commands/cmd_config.rs b/crates/aionui-app/src/commands/cmd_config.rs index c32dc12f9..035bab35c 100644 --- a/crates/aionui-app/src/commands/cmd_config.rs +++ b/crates/aionui-app/src/commands/cmd_config.rs @@ -34,8 +34,49 @@ pub async fn run_config(args: ConfigArgs) -> ExitCode { } } +/// Returns true when `base_url` points at this machine. +/// +/// Used to decide whether the proxy environment applies. Parse failures are +/// treated as non-loopback so an unusual base URL keeps the previous behaviour. +fn is_loopback_base_url(base_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(base_url) else { + return false; + }; + let Some(host) = url.host_str() else { + return false; + }; + // `host_str` keeps the brackets around an IPv6 literal. + let host = host.trim_start_matches('[').trim_end_matches(']'); + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +/// Builds the HTTP client used for every backend call. +/// +/// `reqwest::Client::new()` honours `HTTP_PROXY`/`HTTPS_PROXY` and has no +/// loopback exemption, so a proxy exported for unrelated traffic captures these +/// local calls. The request never reaches the backend, and the CLI reports +/// `CONFIG_HTTP_STATUS_ERROR ... AionUi backend returned an error status`, +/// which points at a backend that is healthy and serving the same route. +/// +/// Bypass the proxy when the backend is local. A remote base URL still uses the +/// proxy environment, since reaching it may depend on the proxy. +fn build_client() -> reqwest::Client { + let bypass_proxy = std::env::var(ENV_BASE_URL) + .map(|base_url| is_loopback_base_url(&base_url)) + .unwrap_or(true); + + let builder = reqwest::Client::builder(); + let builder = if bypass_proxy { builder.no_proxy() } else { builder }; + + builder.build().unwrap_or_else(|_| reqwest::Client::new()) +} + async fn run(args: ConfigArgs) -> Result<(), ConfigError> { - let client = reqwest::Client::new(); + let client = build_client(); match args.command { ConfigCommand::Capabilities => print_envelope(config_capabilities::data(), meta(None), "config capabilities"), ConfigCommand::Context => run_context(&client).await, @@ -1816,6 +1857,25 @@ mod tests { assert_eq!(encode_path_segment("a/b c"), "a%2Fb%20c"); } + #[test] + fn loopback_base_urls_bypass_the_proxy() { + assert!(is_loopback_base_url("http://127.0.0.1:56820")); + assert!(is_loopback_base_url("http://127.1.2.3:56820")); + assert!(is_loopback_base_url("http://localhost:56820")); + assert!(is_loopback_base_url("http://LocalHost:56820")); + assert!(is_loopback_base_url("http://[::1]:56820")); + } + + #[test] + fn remote_base_urls_keep_the_proxy() { + assert!(!is_loopback_base_url("http://192.168.1.10:56820")); + assert!(!is_loopback_base_url("https://aionui.example.com")); + // A host that merely starts with the loopback label is not loopback. + assert!(!is_loopback_base_url("https://localhost.example.com")); + // An unparseable base URL keeps the previous behaviour. + assert!(!is_loopback_base_url("not a url")); + } + #[tokio::test] async fn top_level_user_id_is_always_bound_to_config_env() { let client = reqwest::Client::new();