diff --git a/crates/genie-api/src/routes.rs b/crates/genie-api/src/routes.rs index 280df200..c3c75060 100644 --- a/crates/genie-api/src/routes.rs +++ b/crates/genie-api/src/routes.rs @@ -630,8 +630,8 @@ struct CoreProxyResponse { body: String, } -pub async fn get_actuation_pending(_config: &Config) -> Response { - match proxy_core_json("GET", "/api/actuation/pending", None).await { +pub async fn get_actuation_pending(config: &Config) -> Response { + match proxy_core_json(config, "GET", "/api/actuation/pending", None).await { Ok(proxy) => Response { status: proxy.status, content_type: "application/json", @@ -645,8 +645,8 @@ pub async fn get_actuation_pending(_config: &Config) -> Response { } } -pub async fn get_runtime_contract(_config: &Config) -> Response { - match proxy_core_json("GET", "/api/runtime/contract", None).await { +pub async fn get_runtime_contract(config: &Config) -> Response { + match proxy_core_json(config, "GET", "/api/runtime/contract", None).await { Ok(proxy) => Response { status: proxy.status, content_type: "application/json", @@ -660,8 +660,8 @@ pub async fn get_runtime_contract(_config: &Config) -> Response { } } -pub async fn get_actuation_actions(_config: &Config) -> Response { - match proxy_core_json("GET", "/api/actuation/actions", None).await { +pub async fn get_actuation_actions(config: &Config) -> Response { + match proxy_core_json(config, "GET", "/api/actuation/actions", None).await { Ok(proxy) => Response { status: proxy.status, content_type: "application/json", @@ -711,7 +711,7 @@ pub async fn get_actuation_audit(config: &Config) -> Response { } } -pub async fn post_actuation_confirm(_config: &Config, body: Option<&str>) -> Response { +pub async fn post_actuation_confirm(config: &Config, body: Option<&str>) -> Response { let Some(body) = body else { return Response { status: 400, @@ -720,7 +720,7 @@ pub async fn post_actuation_confirm(_config: &Config, body: Option<&str>) -> Res }; }; - match proxy_core_json("POST", "/api/actuation/confirm", Some(body)).await { + match proxy_core_json(config, "POST", "/api/actuation/confirm", Some(body)).await { Ok(proxy) => Response { status: proxy.status, content_type: "application/json", @@ -734,8 +734,8 @@ pub async fn post_actuation_confirm(_config: &Config, body: Option<&str>) -> Res } } -pub async fn get_memories(_config: &Config) -> Response { - match proxy_core_json("GET", "/api/memories", None).await { +pub async fn get_memories(config: &Config) -> Response { + match proxy_core_json(config, "GET", "/api/memories", None).await { Ok(proxy) => Response { status: proxy.status, content_type: "application/json", @@ -749,7 +749,7 @@ pub async fn get_memories(_config: &Config) -> Response { } } -pub async fn post_memory_update(_config: &Config, body: Option<&str>) -> Response { +pub async fn post_memory_update(config: &Config, body: Option<&str>) -> Response { let Some(body) = body else { return Response { status: 400, @@ -768,7 +768,7 @@ pub async fn post_memory_update(_config: &Config, body: Option<&str>) -> Respons } }; let payload = serde_json::to_string(&parsed).unwrap_or_else(|_| body.to_string()); - match proxy_core_json("POST", "/api/memories/update", Some(&payload)).await { + match proxy_core_json(config, "POST", "/api/memories/update", Some(&payload)).await { Ok(proxy) => Response { status: proxy.status, content_type: "application/json", @@ -782,7 +782,7 @@ pub async fn post_memory_update(_config: &Config, body: Option<&str>) -> Respons } } -pub async fn post_memory_delete(_config: &Config, body: Option<&str>) -> Response { +pub async fn post_memory_delete(config: &Config, body: Option<&str>) -> Response { let Some(body) = body else { return Response { status: 400, @@ -801,7 +801,7 @@ pub async fn post_memory_delete(_config: &Config, body: Option<&str>) -> Respons } }; let payload = serde_json::to_string(&parsed).unwrap_or_else(|_| body.to_string()); - match proxy_core_json("POST", "/api/memories/delete", Some(&payload)).await { + match proxy_core_json(config, "POST", "/api/memories/delete", Some(&payload)).await { Ok(proxy) => Response { status: proxy.status, content_type: "application/json", @@ -815,7 +815,7 @@ pub async fn post_memory_delete(_config: &Config, body: Option<&str>) -> Respons } } -pub async fn post_memory_reorder(_config: &Config, body: Option<&str>) -> Response { +pub async fn post_memory_reorder(config: &Config, body: Option<&str>) -> Response { let Some(body) = body else { return Response { status: 400, @@ -834,7 +834,7 @@ pub async fn post_memory_reorder(_config: &Config, body: Option<&str>) -> Respon } }; let payload = serde_json::to_string(&parsed).unwrap_or_else(|_| body.to_string()); - match proxy_core_json("POST", "/api/memories/reorder", Some(&payload)).await { + match proxy_core_json(config, "POST", "/api/memories/reorder", Some(&payload)).await { Ok(proxy) => Response { status: proxy.status, content_type: "application/json", @@ -849,6 +849,7 @@ pub async fn post_memory_reorder(_config: &Config, body: Option<&str>) -> Respon } async fn proxy_core_json( + config: &Config, method: &str, path: &str, body: Option<&str>, @@ -856,12 +857,17 @@ async fn proxy_core_json( use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; - let mut stream = TcpStream::connect("127.0.0.1:3000") + let addr = config.core_http_addr(); + let host = addr + .rsplit_once(':') + .map(|(host, _)| host) + .unwrap_or(addr.as_str()); + let mut stream = TcpStream::connect(&addr) .await - .map_err(|e| e.to_string())?; + .map_err(|e| format!("{addr}: {e}"))?; let body_str = body.unwrap_or(""); let request = format!( - "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "{method} {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body_str.len(), body_str ); @@ -912,6 +918,13 @@ mod tests { } } + #[test] + fn core_proxy_addr_uses_configured_core_port() { + let mut config = test_config(); + config.core.port = 3001; + assert_eq!(config.core_http_addr(), "127.0.0.1:3001"); + } + #[test] fn dashboard_targets_include_deployed_stack_services() { let config = test_config(); diff --git a/crates/genie-common/src/config.rs b/crates/genie-common/src/config.rs index 798478cd..b4edb197 100644 --- a/crates/genie-common/src/config.rs +++ b/crates/genie-common/src/config.rs @@ -642,6 +642,21 @@ impl Config { Ok(config) } + /// TCP `host:port` for local HTTP clients proxying to genie-core. + /// + /// Uses `[core].bind_host` and `[core].port`. Maps `0.0.0.0` / `::` to + /// `127.0.0.1` because local callers should use loopback even when core + /// listens on all interfaces. + pub fn core_http_addr(&self) -> String { + let host = self.core.bind_host.trim(); + let host = if host.is_empty() || host == "0.0.0.0" || host == "::" { + "127.0.0.1" + } else { + host + }; + format!("{host}:{}", self.core.port) + } + /// Resolve the configured Home Assistant endpoint, if this deployment uses one. pub fn homeassistant_service(&self) -> Option<&ServiceEndpoint> { self.services.homeassistant.as_ref() @@ -940,6 +955,22 @@ mod tests { assert_eq!(config.core.bind_host, "127.0.0.1"); } + #[test] + fn core_http_addr_uses_bind_host_and_port() { + let mut config = test_config(); + config.core.port = 3001; + config.core.bind_host = "127.0.0.1".into(); + assert_eq!(config.core_http_addr(), "127.0.0.1:3001"); + } + + #[test] + fn core_http_addr_maps_listen_all_to_loopback() { + let mut config = test_config(); + config.core.port = 3000; + config.core.bind_host = "0.0.0.0".into(); + assert_eq!(config.core_http_addr(), "127.0.0.1:3000"); + } + #[test] fn core_bind_host_can_be_configured() { let config: CoreConfig = toml::from_str( diff --git a/crates/genie-ctl/src/main.rs b/crates/genie-ctl/src/main.rs index de982b43..e17a14dd 100644 --- a/crates/genie-ctl/src/main.rs +++ b/crates/genie-ctl/src/main.rs @@ -34,22 +34,8 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; const GOVERNOR_SOCK: &str = "/run/geniepod/governor.sock"; -/// HTTP `host:port` for genie-core, from `[core].bind_host` and `[core].port`. -fn core_addr_from_config(config: &Config) -> String { - let host = config.core.bind_host.trim(); - let host = if host.is_empty() { - "127.0.0.1" - } else if host == "0.0.0.0" || host == "::" { - // genie-core may listen on all interfaces; local CLI should use loopback. - "127.0.0.1" - } else { - host - }; - format!("{host}:{}", config.core.port) -} - fn load_core_addr() -> Result { - Ok(core_addr_from_config(&Config::load()?)) + Ok(Config::load()?.core_http_addr()) } const SKILL_RESTART_HINT: &str = "Restart genie-core to load skill changes, or wait until the next startup."; @@ -1784,7 +1770,7 @@ mod tests { connectivity: Default::default(), }; - assert_eq!(super::core_addr_from_config(&config), "127.0.0.1:3001"); + assert_eq!(config.core_http_addr(), "127.0.0.1:3001"); } #[test] @@ -1809,7 +1795,7 @@ mod tests { connectivity: Default::default(), }; - assert_eq!(super::core_addr_from_config(&config), "127.0.0.1:3000"); + assert_eq!(config.core_http_addr(), "127.0.0.1:3000"); } #[test]