ci / rust (push) Failing after 2m51s
`PairAttachVerifier` now stores `allow_inbound` behind `Arc<RwLock<_>>` so clones share the same set. Two triggers re-read the unified config file and swap it atomically: - SIGHUP — `systemctl --user reload maestrod`, or any `kill -HUP`. The installer's user unit now sets `ExecReload=/bin/kill -HUP $MAINPID`. - `POST /api/policy/reload` — used by `maestro policy reload`. The CLI reads `[daemon].bind_addr` from the unified config (default 7070) and POSTs to `http://127.0.0.1:<port>`. No PID lookup required. Also adds `maestro policy show [--json]` and `GET /api/policy` for "did my edit get picked up?" debugging. A broken mid-edit config logs an error and leaves the previously-effective list in place rather than locking everyone out. In-flight attaches keep their existing connection — revocation requires killing the session, as spec'd. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
128 lines
3.6 KiB
Rust
128 lines
3.6 KiB
Rust
//! `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<String>,
|
|
}
|
|
|
|
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 = reqwest::Client::new().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 = reqwest::Client::new().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:<port>` where `<port>` is `[daemon].bind_addr`'s port
|
|
/// or `7070` if unset / file missing.
|
|
fn local_base_url(config_path: &Path) -> Result<String, PolicyError> {
|
|
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");
|
|
}
|
|
}
|