From f69c7a9eee28e1ee17abed13d2504a5d088d9a4b Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Wed, 27 May 2026 09:53:19 +0000 Subject: [PATCH] feat(maestro): hot-reload [daemon.policy] via SIGHUP + maestro policy CLI (NAP-237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PairAttachVerifier` now stores `allow_inbound` behind `Arc>` 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:`. 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) --- crates/maestro-cli/src/cli.rs | 32 ++++ crates/maestro-cli/src/lib.rs | 1 + crates/maestro-cli/src/main.rs | 18 ++- crates/maestro-cli/src/policy.rs | 127 +++++++++++++++ crates/maestro-daemon/src/jwt.rs | 86 ++++++++++- crates/maestro-daemon/src/lib.rs | 2 + crates/maestro-daemon/src/main.rs | 17 +- crates/maestro-daemon/src/policy.rs | 196 ++++++++++++++++++++++++ crates/maestro-installer/src/systemd.rs | 9 ++ docs/INSTALL.md | 16 ++ 10 files changed, 493 insertions(+), 11 deletions(-) create mode 100644 crates/maestro-cli/src/policy.rs create mode 100644 crates/maestro-daemon/src/policy.rs diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index 6237f3b..934e975 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -42,6 +42,23 @@ pub enum Command { Kill(KillArgs), /// Set the default registry URL persisted in the CLI config. SetRegistry(SetRegistryArgs), + /// Inspect or hot-reload the local daemon's `[daemon.policy]` (NAP-237). + #[command(subcommand)] + Policy(PolicyCmd), +} + +#[derive(Debug, Subcommand)] +pub enum PolicyCmd { + /// Re-read `~/.config/maestro/config.toml` and apply + /// `[daemon.policy].allow_inbound` without restarting the daemon. Affects + /// only new attach attempts — in-flight sessions keep their connection. + Reload, + /// Print the daemon's current effective `allow_inbound` list. + Show { + /// Output as JSON instead of one entry per line. + #[arg(long)] + json: bool, + }, } #[derive(Debug, clap::Args)] @@ -195,6 +212,21 @@ mod tests { } } + #[test] + fn parses_policy_reload() { + let cli = Cli::try_parse_from(["maestro", "policy", "reload"]).unwrap(); + assert!(matches!(cli.command, Command::Policy(PolicyCmd::Reload))); + } + + #[test] + fn parses_policy_show_json() { + let cli = Cli::try_parse_from(["maestro", "policy", "show", "--json"]).unwrap(); + match cli.command { + Command::Policy(PolicyCmd::Show { json }) => assert!(json), + other => panic!("wrong subcommand: {other:?}"), + } + } + #[test] fn rejects_env_without_equals() { let err = Cli::try_parse_from([ diff --git a/crates/maestro-cli/src/lib.rs b/crates/maestro-cli/src/lib.rs index 6702ea6..ccf03ec 100644 --- a/crates/maestro-cli/src/lib.rs +++ b/crates/maestro-cli/src/lib.rs @@ -9,4 +9,5 @@ pub mod attach; pub mod cli; pub mod config; pub mod http; +pub mod policy; pub mod sessions; diff --git a/crates/maestro-cli/src/main.rs b/crates/maestro-cli/src/main.rs index eedf6f8..683441f 100644 --- a/crates/maestro-cli/src/main.rs +++ b/crates/maestro-cli/src/main.rs @@ -5,9 +5,10 @@ use std::process::ExitCode; use clap::Parser; use maestro_cli::attach::{parse_detach_key, run_attach}; -use maestro_cli::cli::{Cli, Command}; +use maestro_cli::cli::{Cli, Command, PolicyCmd}; use maestro_cli::config::{default_config_path, CliConfig, ConfigError}; use maestro_cli::http::RegistryClient; +use maestro_cli::policy::{run_reload as run_policy_reload, run_show as run_policy_show}; use maestro_cli::sessions::{fetch_connect_info, run_kill, run_list, run_spawn}; use tracing_subscriber::EnvFilter; @@ -52,6 +53,8 @@ enum CliError { Attach(#[from] maestro_cli::attach::AttachError), #[error("{0}")] Io(#[from] std::io::Error), + #[error("{0}")] + Policy(#[from] maestro_cli::policy::PolicyError), } #[allow(clippy::result_large_err)] @@ -70,6 +73,17 @@ async fn dispatch(cli: Cli) -> Result<(), CliError> { return Ok(()); } + // `policy` talks to the local daemon's HTTP API, not the registry. Handle + // it here so it works even without a registry URL configured. + if let Command::Policy(sub) = &cli.command { + let mut stdout = std::io::stdout().lock(); + match sub { + PolicyCmd::Reload => run_policy_reload(&config_path, &mut stdout).await?, + PolicyCmd::Show { json } => run_policy_show(&config_path, *json, &mut stdout).await?, + } + return Ok(()); + } + let client = build_client(cli.registry_url.clone(), &config_path)?; match cli.command { @@ -96,7 +110,7 @@ async fn dispatch(cli: Cli) -> Result<(), CliError> { run_kill(&client, &args).await?; println!("killed {}", args.session_id); } - Command::SetRegistry(_) => unreachable!("handled above"), + Command::SetRegistry(_) | Command::Policy(_) => unreachable!("handled above"), } Ok(()) } diff --git a/crates/maestro-cli/src/policy.rs b/crates/maestro-cli/src/policy.rs new file mode 100644 index 0000000..099539c --- /dev/null +++ b/crates/maestro-cli/src/policy.rs @@ -0,0 +1,127 @@ +//! `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 = 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:` 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"); + } +} diff --git a/crates/maestro-daemon/src/jwt.rs b/crates/maestro-daemon/src/jwt.rs index 3f4aacc..5a812db 100644 --- a/crates/maestro-daemon/src/jwt.rs +++ b/crates/maestro-daemon/src/jwt.rs @@ -3,6 +3,7 @@ //! the registry on the attach hot path. Spec §Auth. use std::collections::HashSet; +use std::sync::{Arc, RwLock}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use maestro_protocol::{JwtClaims, PairAttachClaims}; @@ -68,19 +69,22 @@ pub fn issue(secret: &str, claims: &JwtClaims) -> Result>`] so it can be swapped at runtime by +/// [`PairAttachVerifier::reload`] (NAP-237 hot reload via SIGHUP / +/// `POST /api/policy/reload`). Clones share the same inner — any reload +/// observed by one clone is observed by all of them. #[derive(Clone)] pub struct PairAttachVerifier { decoding: DecodingKey, daemon_id: String, - allow_inbound: HashSet, + allow_inbound: Arc>>, } impl std::fmt::Debug for PairAttachVerifier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PairAttachVerifier") .field("daemon_id", &self.daemon_id) - .field("allow_inbound", &self.allow_inbound) + .field("allow_inbound", &self.snapshot()) .field("decoding", &"") .finish() } @@ -95,10 +99,30 @@ impl PairAttachVerifier { Self { decoding: DecodingKey::from_secret(secret.as_bytes()), daemon_id: daemon_id.into(), - allow_inbound: allow_inbound.into_iter().collect(), + allow_inbound: Arc::new(RwLock::new(allow_inbound.into_iter().collect())), } } + /// Swap the effective `allow_inbound` set. Affects subsequent `verify` + /// calls; in-flight attaches keep their existing connection (per spec + /// — revocation requires killing the session). + pub fn reload(&self, allow_inbound: impl IntoIterator) { + let mut g = self + .allow_inbound + .write() + .unwrap_or_else(|e| e.into_inner()); + *g = allow_inbound.into_iter().collect(); + } + + /// Sorted snapshot of the current effective `allow_inbound` set. Used by + /// `GET /api/policy` and debug formatting. + pub fn snapshot(&self) -> Vec { + let g = self.allow_inbound.read().unwrap_or_else(|e| e.into_inner()); + let mut v: Vec = g.iter().cloned().collect(); + v.sort(); + v + } + /// Validate the token for the path-bound `session_id`. On success returns /// the home daemon's id (`sub`) for logging/tracing. pub fn verify(&self, token: &str, session_id: Uuid) -> Result { @@ -120,7 +144,11 @@ impl PairAttachVerifier { path: session_id, }); } - if !self.allow_inbound.contains(&claims.sub) { + let allowed = { + let g = self.allow_inbound.read().unwrap_or_else(|e| e.into_inner()); + g.contains(&claims.sub) + }; + if !allowed { return Err(JwtError::NotAllowed { sub: claims.sub }); } Ok(claims.sub) @@ -261,4 +289,52 @@ mod tests { let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, -120)); assert!(v.verify(&token, sid).is_err()); } + + // NAP-237: hot reload. After `reload`, the same verifier (and any + // pre-existing clone) sees the new set on the next `verify`. + + #[test] + fn pair_verifier_reload_admits_new_peer() { + let secret = "s"; + let v = PairAttachVerifier::new(secret, "konishi", std::iter::empty::()); + let sid = Uuid::new_v4(); + let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60)); + assert!(v.verify(&token, sid).is_err()); + v.reload(["laptop".into()]); + assert_eq!(v.verify(&token, sid).unwrap(), "laptop"); + } + + #[test] + fn pair_verifier_reload_revokes_removed_peer() { + let secret = "s"; + let v = PairAttachVerifier::new(secret, "konishi", ["laptop".into(), "phone".into()]); + let sid = Uuid::new_v4(); + let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60)); + assert_eq!(v.verify(&token, sid).unwrap(), "laptop"); + v.reload(["phone".into()]); // drop laptop + assert!(matches!( + v.verify(&token, sid).unwrap_err(), + JwtError::NotAllowed { .. } + )); + } + + #[test] + fn pair_verifier_clone_shares_inner_set() { + // Cloning the verifier (e.g. to hand to axum state and to the SIGHUP + // task) must NOT detach the allow_inbound set — both halves observe + // every reload. + let secret = "s"; + let v1 = PairAttachVerifier::new(secret, "konishi", std::iter::empty::()); + let v2 = v1.clone(); + v1.reload(["laptop".into()]); + let sid = Uuid::new_v4(); + let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60)); + assert_eq!(v2.verify(&token, sid).unwrap(), "laptop"); + } + + #[test] + fn pair_verifier_snapshot_is_sorted() { + let v = PairAttachVerifier::new("s", "konishi", ["phone".into(), "laptop".into()]); + assert_eq!(v.snapshot(), vec!["laptop".to_string(), "phone".into()]); + } } diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 1fe4f11..162c01a 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -14,6 +14,7 @@ pub mod configure; pub mod jwt; pub mod local_api; pub mod local_events; +pub mod policy; pub mod pty; pub mod registry_client; pub mod session; @@ -27,5 +28,6 @@ pub use config::{default_config_path, default_state_dir, load_config, DaemonConf pub use configure::{run as run_configure, ConfigureError, ConfigureInputs}; pub use jwt::{issue as issue_jwt, JwtError, JwtVerifier, PairAttachVerifier}; pub use local_events::{spawn_session_publisher, LocalEventBus}; +pub use policy::{spawn_sighup_listener, PolicyState}; pub use registry_client::{ClientError, RegistryClient, HEARTBEAT_INTERVAL}; pub use session::{SessionError, SessionEvent, SessionManager}; diff --git a/crates/maestro-daemon/src/main.rs b/crates/maestro-daemon/src/main.rs index a2fd194..2a1ff76 100644 --- a/crates/maestro-daemon/src/main.rs +++ b/crates/maestro-daemon/src/main.rs @@ -14,9 +14,9 @@ use maestro_daemon::sidecar::SidecarStore; use maestro_daemon::tmux::Tmux; use maestro_daemon::worktree::WorktreeManager; use maestro_daemon::{ - attach, default_config_path, default_state_dir, load_config, local_api, run_configure, - spawn_session_publisher, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus, - PairAttachVerifier, RegistryClient, SessionManager, + attach, default_config_path, default_state_dir, load_config, local_api, policy, run_configure, + spawn_session_publisher, spawn_sighup_listener, ConfigureInputs, EnvOverrides, JwtVerifier, + LocalEventBus, PairAttachVerifier, PolicyState, RegistryClient, SessionManager, }; use tokio::net::TcpListener; use tracing_subscriber::EnvFilter; @@ -178,9 +178,18 @@ async fn run(config_override: Option) -> Result<(), Box> { self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()), self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new), }; + // NAP-237: hot-reload `[daemon.policy].allow_inbound` via SIGHUP or + // POST /api/policy/reload. Both swap the same verifier instance. + let policy_state = PolicyState { + verifier: pair_verifier.clone(), + config_path: std::sync::Arc::new(config_path.clone()), + }; + spawn_sighup_listener(policy_state.clone()); + let router = attach::router(manager.clone(), verifier, pair_verifier) .merge(local_api::router(local_state)) - .merge(local_api::broker_router(broker_state)); + .merge(local_api::broker_router(broker_state)) + .merge(policy::router(policy_state)); let listener = TcpListener::bind(config.bind_addr).await?; let bound = listener.local_addr()?; diff --git a/crates/maestro-daemon/src/policy.rs b/crates/maestro-daemon/src/policy.rs new file mode 100644 index 0000000..6a1c35b --- /dev/null +++ b/crates/maestro-daemon/src/policy.rs @@ -0,0 +1,196 @@ +//! Hot reload for `[daemon.policy] allow_inbound` (NAP-237). +//! +//! Two triggers, one execution path: +//! +//! - **SIGHUP** — `systemctl --user reload maestrod` (or any `kill -HUP`). +//! - **`POST /api/policy/reload`** — used by `maestro policy reload` so +//! operators don't need to know the PID. Also gives `maestro-web` and +//! other local-API consumers a uniform reload hook. +//! +//! Both re-read `~/.config/maestro/config.toml` and swap the verifier's +//! allow-list atomically; in-flight attaches keep their existing connection +//! (revoking those requires killing the session, per spec). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use maestro_config::MaestroConfigFile; +use serde::Serialize; + +use crate::jwt::PairAttachVerifier; + +#[derive(Debug, Clone)] +pub struct PolicyState { + pub verifier: PairAttachVerifier, + pub config_path: Arc, +} + +#[derive(Debug, Serialize)] +pub struct PolicyView { + pub allow_inbound: Vec, +} + +pub fn router(state: PolicyState) -> Router { + Router::new() + .route("/api/policy", get(get_policy)) + .route("/api/policy/reload", post(reload_policy)) + .with_state(state) +} + +async fn get_policy(State(s): State) -> Json { + Json(PolicyView { + allow_inbound: s.verifier.snapshot(), + }) +} + +async fn reload_policy( + State(s): State, +) -> Result, (StatusCode, String)> { + match reload_from_file(&s.config_path, &s.verifier) { + Ok(list) => { + tracing::info!(?list, source = "http", "reloaded allow_inbound"); + Ok(Json(PolicyView { + allow_inbound: list, + })) + } + Err(e) => { + tracing::error!(error = %e, source = "http", "policy reload failed"); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) + } + } +} + +/// Re-read the unified config file and apply `[daemon.policy].allow_inbound` +/// to the live verifier. Returns the new effective list (sorted), or an +/// error if the file can't be read/parsed — in which case the existing +/// allow-list stays in place so a bad edit doesn't lock everyone out. +pub fn reload_from_file( + config_path: &Path, + verifier: &PairAttachVerifier, +) -> Result, maestro_config::ConfigError> { + let file = MaestroConfigFile::load(config_path)?; + let list = file.daemon.policy.allow_inbound; + verifier.reload(list.iter().cloned()); + Ok(verifier.snapshot()) +} + +/// Spawn a tokio task that re-applies the policy on every SIGHUP. Logs +/// success / failure but never exits — a transient bad config (e.g. mid-edit) +/// becomes a logged error, not a daemon kill. +pub fn spawn_sighup_listener(state: PolicyState) { + tokio::spawn(async move { + let mut sig = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) { + Ok(s) => s, + Err(e) => { + tracing::error!(error = %e, "failed to install SIGHUP handler"); + return; + } + }; + while sig.recv().await.is_some() { + match reload_from_file(&state.config_path, &state.verifier) { + Ok(list) => { + tracing::info!(?list, source = "sighup", "reloaded allow_inbound"); + } + Err(e) => { + tracing::error!(error = %e, source = "sighup", "policy reload failed"); + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_config(path: &Path, allow_inbound: &[&str]) { + let body = if allow_inbound.is_empty() { + r#" +[shared] +registry_url = "http://r" +token = "t" + +[daemon] +daemon_url = "ws://x" +"# + .to_string() + } else { + let list = allow_inbound + .iter() + .map(|s| format!("\"{s}\"")) + .collect::>() + .join(", "); + format!( + r#" +[shared] +registry_url = "http://r" +token = "t" + +[daemon] +daemon_url = "ws://x" + +[daemon.policy] +allow_inbound = [{list}] +"# + ) + }; + std::fs::write(path, body).unwrap(); + } + + #[test] + fn reload_from_file_picks_up_new_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_config(&path, &["laptop"]); + let v = PairAttachVerifier::new("s", "konishi", ["laptop".into()]); + assert_eq!(v.snapshot(), vec!["laptop".to_string()]); + + write_config(&path, &["laptop", "phone"]); + let list = reload_from_file(&path, &v).unwrap(); + assert_eq!(list, vec!["laptop".to_string(), "phone".into()]); + assert_eq!(v.snapshot(), vec!["laptop".to_string(), "phone".into()]); + } + + #[test] + fn reload_from_file_drops_removed_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_config(&path, &["laptop", "phone"]); + let v = PairAttachVerifier::new("s", "konishi", ["laptop".into(), "phone".into()]); + + write_config(&path, &["phone"]); + let list = reload_from_file(&path, &v).unwrap(); + assert_eq!(list, vec!["phone".to_string()]); + assert_eq!(v.snapshot(), vec!["phone".to_string()]); + } + + #[test] + fn reload_from_file_leaves_set_intact_on_parse_error() { + // A mid-edit broken config must not lock everyone out — keep the + // previously-effective list and surface the error to the caller. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_config(&path, &["laptop"]); + let v = PairAttachVerifier::new("s", "konishi", ["laptop".into()]); + std::fs::write(&path, "this = is = not = toml").unwrap(); + assert!(reload_from_file(&path, &v).is_err()); + assert_eq!(v.snapshot(), vec!["laptop".to_string()]); + } + + #[test] + fn reload_from_file_treats_missing_section_as_deny_all() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_config(&path, &["laptop"]); + let v = PairAttachVerifier::new("s", "konishi", ["laptop".into()]); + + write_config(&path, &[]); // no [daemon.policy] at all + let list = reload_from_file(&path, &v).unwrap(); + assert!(list.is_empty()); + assert!(v.snapshot().is_empty()); + } +} diff --git a/crates/maestro-installer/src/systemd.rs b/crates/maestro-installer/src/systemd.rs index 07511c4..e539b15 100644 --- a/crates/maestro-installer/src/systemd.rs +++ b/crates/maestro-installer/src/systemd.rs @@ -52,6 +52,7 @@ pub fn render_user_unit(exec_start: &Path) -> String { [Service]\n\ Type=simple\n\ ExecStart={exec}\n\ + ExecReload=/bin/kill -HUP $MAINPID\n\ Restart=on-failure\n\ RestartSec=5\n\ \n\ @@ -136,6 +137,14 @@ mod tests { assert!(body.contains("Restart=on-failure")); } + #[test] + fn unit_wires_execreload_to_sighup() { + // NAP-237: `systemctl --user reload maestrod` must trigger our + // SIGHUP handler so operators can hot-reload `[daemon.policy]`. + let body = render_user_unit(Path::new("/x/maestrod")); + assert!(body.contains("ExecReload=/bin/kill -HUP $MAINPID")); + } + #[test] fn unit_ends_with_newline() { let body = render_user_unit(Path::new("/x")); diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 491aff5..b71d56a 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -126,6 +126,21 @@ Pick `/tmp` if the daemon must outlive your login session and you can't run tmux sockets with it). The env var `MAESTRO_DAEMON_TMUX_TMPDIR` takes precedence over the file. +## Hot-reloading policy + +`[daemon.policy].allow_inbound` (the list of peer daemons allowed to broker- +attach into this one) can be changed without restarting `maestrod` — restarting +would kill every live session. Two ways to apply an edit: + +```sh +maestro policy reload # POSTs to the local daemon's /api/policy/reload +maestro policy show # prints the current effective list +kill -HUP # equivalent; what `systemctl --user reload` does +``` + +Only newly-arriving attach attempts see the change — sessions already +attached stay attached. To boot a live attacher out, kill the session. + ## What the systemd unit looks like The wizard writes a *user* unit (`systemctl --user`), since `maestrod` runs @@ -140,6 +155,7 @@ After=network-online.target [Service] Type=simple ExecStart=%h/.cargo/bin/maestrod +ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=5