//! `maestro policy reload | show` — talk to the local daemon's //! `/api/policy` endpoints (NAP-237). //! //! The local daemon's bind port is read from `[daemon].bind_addr` in the //! unified config; the host is always `127.0.0.1` (these endpoints are //! same-host by design — the daemon's bind may be `0.0.0.0` for attach, //! but operators reload policy from the same machine). use std::io::Write; use std::path::Path; use maestro_config::MaestroConfigFile; use serde::Deserialize; const DEFAULT_PORT: u16 = 7070; #[derive(Debug, thiserror::Error)] pub enum PolicyError { #[error("config: {0}")] Config(#[from] maestro_config::ConfigError), #[error("http: {0}")] Http(#[from] reqwest::Error), #[error("daemon returned {status}: {body}")] DaemonError { status: reqwest::StatusCode, body: String, }, #[error("io: {0}")] Io(#[from] std::io::Error), } #[derive(Debug, Deserialize)] pub struct PolicyView { pub allow_inbound: Vec, } pub async fn run_reload(config_path: &Path, out: &mut impl Write) -> Result<(), PolicyError> { let url = format!("{}/api/policy/reload", local_base_url(config_path)?); let resp = crate::http::build_client().post(&url).send().await?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(PolicyError::DaemonError { status, body }); } let view: PolicyView = resp.json().await?; writeln!( out, "reloaded — allow_inbound now has {} entr{}", view.allow_inbound.len(), if view.allow_inbound.len() == 1 { "y" } else { "ies" }, )?; for entry in &view.allow_inbound { writeln!(out, " {entry}")?; } Ok(()) } pub async fn run_show( config_path: &Path, json: bool, out: &mut impl Write, ) -> Result<(), PolicyError> { let url = format!("{}/api/policy", local_base_url(config_path)?); let resp = crate::http::build_client().get(&url).send().await?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(PolicyError::DaemonError { status, body }); } let view: PolicyView = resp.json().await?; if json { writeln!( out, "{}", serde_json::to_string(&view.allow_inbound).unwrap() )?; } else if view.allow_inbound.is_empty() { writeln!( out, "(allow_inbound is empty — every cross-daemon attach is denied)" )?; } else { for entry in &view.allow_inbound { writeln!(out, "{entry}")?; } } Ok(()) } /// `http://127.0.0.1:` where `` is `[daemon].bind_addr`'s port /// or `7070` if unset / file missing. fn local_base_url(config_path: &Path) -> Result { let file = MaestroConfigFile::load_or_default(config_path)?; let port = file .daemon .bind_addr .map(|a| a.port()) .unwrap_or(DEFAULT_PORT); Ok(format!("http://127.0.0.1:{port}")) } #[cfg(test)] mod tests { use super::*; #[test] fn local_base_url_uses_bind_port_or_default() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); // missing file → default assert_eq!(local_base_url(&path).unwrap(), "http://127.0.0.1:7070"); std::fs::write( &path, r#" [daemon] bind_addr = "0.0.0.0:9090" "#, ) .unwrap(); assert_eq!(local_base_url(&path).unwrap(), "http://127.0.0.1:9090"); } }