diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 5a4c7b59..4e7643a2 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -1055,6 +1055,22 @@ async fn run_nginx() -> Result { /// rejected. Stripped during nginx bootstrap so the backend solely owns CORS. const NGINX_LND_DUP_CORS: &str = " add_header Access-Control-Allow-Origin $http_origin always;\n add_header Access-Control-Allow-Credentials \"true\" always;\n"; +/// S4 follow-up (2026-08-07): pre-fix nodes proxy /aiui/api/web-search +/// STRAIGHT to SearXNG (127.0.0.1:8888) with no session check — anyone on the +/// LAN can run searches attributed to the node's IP. The canonical conf routes +/// it through the session-gated daemon (5678) and forwards the Cookie. The +/// stale target string is unique to that block, so a plain replace is safe. +/// Pure and testable; `None` when the stale shape is absent. +fn heal_stale_web_search_block(content: &str) -> Option { + if !content.contains("proxy_pass http://127.0.0.1:8888/search;") { + return None; + } + Some(content.replace( + "proxy_pass http://127.0.0.1:8888/search;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;", + "proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;", + )) +} + async fn patch_nginx_conf(path: &str) -> Result { let content = fs::read_to_string(path) .await @@ -1084,6 +1100,7 @@ async fn patch_nginx_conf(path: &str) -> Result { content.contains("listen 80 default_server;") && !content.contains("listen [::]:80"); let missing_v6_https = content.contains("listen 443 ssl default_server;") && !content.contains("listen [::]:443"); + let stale_web_search = heal_stale_web_search_block(&content).is_some(); if !missing_app_catalog && !missing_bitcoin_status && !missing_lnd_proxy @@ -1093,12 +1110,18 @@ async fn patch_nginx_conf(path: &str) -> Result { && !needs_fedimint_css && !missing_v6_http && !missing_v6_https + && !stale_web_search { return Ok(false); } let mut patched = content.clone(); + if let Some(p) = heal_stale_web_search_block(&patched) { + patched = p; + } + + if missing_v6_http { patched = patched.replace( "listen 80 default_server;", @@ -1270,6 +1293,19 @@ mod tests { let outcome = PodmanHealOutcome::Unhealthy; assert_ne!(outcome, PodmanHealOutcome::Healthy); } + + #[test] + fn stale_web_search_block_is_gated_and_idempotent() { + let stale = " location /aiui/api/web-search {\n proxy_pass http://127.0.0.1:8888/search;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 30s;\n }"; + let healed = heal_stale_web_search_block(stale).expect("stale block must heal"); + assert!(healed.contains("proxy_pass http://127.0.0.1:5678;")); + assert!(healed.contains("proxy_set_header Cookie $http_cookie;")); + assert!(!healed.contains("8888")); + // Second pass is a no-op (idempotent self-heal). + assert!(heal_stale_web_search_block(&healed).is_none()); + // A config without the block is untouched. + assert!(heal_stale_web_search_block("location / { try_files $uri /index.html; }").is_none()); + } } /// Repair this node's own systemd restart policy.