feat(maestro): hot-reload [daemon.policy] via SIGHUP + maestro policy CLI (NAP-237)
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>
This commit is contained in:
2026-05-27 09:53:19 +00:00
co-authored by Claude Opus 4.7
parent 08f5bbc045
commit f69c7a9eee
10 changed files with 493 additions and 11 deletions
+32
View File
@@ -42,6 +42,23 @@ pub enum Command {
Kill(KillArgs), Kill(KillArgs),
/// Set the default registry URL persisted in the CLI config. /// Set the default registry URL persisted in the CLI config.
SetRegistry(SetRegistryArgs), 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)] #[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] #[test]
fn rejects_env_without_equals() { fn rejects_env_without_equals() {
let err = Cli::try_parse_from([ let err = Cli::try_parse_from([
+1
View File
@@ -9,4 +9,5 @@ pub mod attach;
pub mod cli; pub mod cli;
pub mod config; pub mod config;
pub mod http; pub mod http;
pub mod policy;
pub mod sessions; pub mod sessions;
+16 -2
View File
@@ -5,9 +5,10 @@ use std::process::ExitCode;
use clap::Parser; use clap::Parser;
use maestro_cli::attach::{parse_detach_key, run_attach}; 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::config::{default_config_path, CliConfig, ConfigError};
use maestro_cli::http::RegistryClient; 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 maestro_cli::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -52,6 +53,8 @@ enum CliError {
Attach(#[from] maestro_cli::attach::AttachError), Attach(#[from] maestro_cli::attach::AttachError),
#[error("{0}")] #[error("{0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("{0}")]
Policy(#[from] maestro_cli::policy::PolicyError),
} }
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
@@ -70,6 +73,17 @@ async fn dispatch(cli: Cli) -> Result<(), CliError> {
return Ok(()); 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)?; let client = build_client(cli.registry_url.clone(), &config_path)?;
match cli.command { match cli.command {
@@ -96,7 +110,7 @@ async fn dispatch(cli: Cli) -> Result<(), CliError> {
run_kill(&client, &args).await?; run_kill(&client, &args).await?;
println!("killed {}", args.session_id); println!("killed {}", args.session_id);
} }
Command::SetRegistry(_) => unreachable!("handled above"), Command::SetRegistry(_) | Command::Policy(_) => unreachable!("handled above"),
} }
Ok(()) Ok(())
} }
+127
View File
@@ -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<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");
}
}
+81 -5
View File
@@ -3,6 +3,7 @@
//! the registry on the attach hot path. Spec §Auth. //! the registry on the attach hot path. Spec §Auth.
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use maestro_protocol::{JwtClaims, PairAttachClaims}; use maestro_protocol::{JwtClaims, PairAttachClaims};
@@ -68,19 +69,22 @@ pub fn issue(secret: &str, claims: &JwtClaims) -> Result<String, jsonwebtoken::e
/// Verifier for pair-scoped attach JWTs (NAP-247). Checks signature, `exp`, /// Verifier for pair-scoped attach JWTs (NAP-247). Checks signature, `exp`,
/// `aud == self.daemon_id`, and `sub in allow_inbound`. The `allow_inbound` /// `aud == self.daemon_id`, and `sub in allow_inbound`. The `allow_inbound`
/// list is read at daemon startup; hot reload is NAP-237. /// set lives behind an [`Arc<RwLock<_>>`] 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)] #[derive(Clone)]
pub struct PairAttachVerifier { pub struct PairAttachVerifier {
decoding: DecodingKey, decoding: DecodingKey,
daemon_id: String, daemon_id: String,
allow_inbound: HashSet<String>, allow_inbound: Arc<RwLock<HashSet<String>>>,
} }
impl std::fmt::Debug for PairAttachVerifier { impl std::fmt::Debug for PairAttachVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PairAttachVerifier") f.debug_struct("PairAttachVerifier")
.field("daemon_id", &self.daemon_id) .field("daemon_id", &self.daemon_id)
.field("allow_inbound", &self.allow_inbound) .field("allow_inbound", &self.snapshot())
.field("decoding", &"<redacted>") .field("decoding", &"<redacted>")
.finish() .finish()
} }
@@ -95,10 +99,30 @@ impl PairAttachVerifier {
Self { Self {
decoding: DecodingKey::from_secret(secret.as_bytes()), decoding: DecodingKey::from_secret(secret.as_bytes()),
daemon_id: daemon_id.into(), 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<Item = String>) {
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<String> {
let g = self.allow_inbound.read().unwrap_or_else(|e| e.into_inner());
let mut v: Vec<String> = g.iter().cloned().collect();
v.sort();
v
}
/// Validate the token for the path-bound `session_id`. On success returns /// Validate the token for the path-bound `session_id`. On success returns
/// the home daemon's id (`sub`) for logging/tracing. /// the home daemon's id (`sub`) for logging/tracing.
pub fn verify(&self, token: &str, session_id: Uuid) -> Result<String, JwtError> { pub fn verify(&self, token: &str, session_id: Uuid) -> Result<String, JwtError> {
@@ -120,7 +144,11 @@ impl PairAttachVerifier {
path: session_id, 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 }); return Err(JwtError::NotAllowed { sub: claims.sub });
} }
Ok(claims.sub) Ok(claims.sub)
@@ -261,4 +289,52 @@ mod tests {
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, -120)); let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, -120));
assert!(v.verify(&token, sid).is_err()); 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::<String>());
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::<String>());
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()]);
}
} }
+2
View File
@@ -14,6 +14,7 @@ pub mod configure;
pub mod jwt; pub mod jwt;
pub mod local_api; pub mod local_api;
pub mod local_events; pub mod local_events;
pub mod policy;
pub mod pty; pub mod pty;
pub mod registry_client; pub mod registry_client;
pub mod session; 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 configure::{run as run_configure, ConfigureError, ConfigureInputs};
pub use jwt::{issue as issue_jwt, JwtError, JwtVerifier, PairAttachVerifier}; pub use jwt::{issue as issue_jwt, JwtError, JwtVerifier, PairAttachVerifier};
pub use local_events::{spawn_session_publisher, LocalEventBus}; 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 registry_client::{ClientError, RegistryClient, HEARTBEAT_INTERVAL};
pub use session::{SessionError, SessionEvent, SessionManager}; pub use session::{SessionError, SessionEvent, SessionManager};
+13 -4
View File
@@ -14,9 +14,9 @@ use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::tmux::Tmux; use maestro_daemon::tmux::Tmux;
use maestro_daemon::worktree::WorktreeManager; use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::{ use maestro_daemon::{
attach, default_config_path, default_state_dir, load_config, local_api, run_configure, attach, default_config_path, default_state_dir, load_config, local_api, policy, run_configure,
spawn_session_publisher, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus, spawn_session_publisher, spawn_sighup_listener, ConfigureInputs, EnvOverrides, JwtVerifier,
PairAttachVerifier, RegistryClient, SessionManager, LocalEventBus, PairAttachVerifier, PolicyState, RegistryClient, SessionManager,
}; };
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -178,9 +178,18 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()), self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()),
self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new), 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) let router = attach::router(manager.clone(), verifier, pair_verifier)
.merge(local_api::router(local_state)) .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 listener = TcpListener::bind(config.bind_addr).await?;
let bound = listener.local_addr()?; let bound = listener.local_addr()?;
+196
View File
@@ -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<PathBuf>,
}
#[derive(Debug, Serialize)]
pub struct PolicyView {
pub allow_inbound: Vec<String>,
}
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<PolicyState>) -> Json<PolicyView> {
Json(PolicyView {
allow_inbound: s.verifier.snapshot(),
})
}
async fn reload_policy(
State(s): State<PolicyState>,
) -> Result<Json<PolicyView>, (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<Vec<String>, 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::<Vec<_>>()
.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());
}
}
+9
View File
@@ -52,6 +52,7 @@ pub fn render_user_unit(exec_start: &Path) -> String {
[Service]\n\ [Service]\n\
Type=simple\n\ Type=simple\n\
ExecStart={exec}\n\ ExecStart={exec}\n\
ExecReload=/bin/kill -HUP $MAINPID\n\
Restart=on-failure\n\ Restart=on-failure\n\
RestartSec=5\n\ RestartSec=5\n\
\n\ \n\
@@ -136,6 +137,14 @@ mod tests {
assert!(body.contains("Restart=on-failure")); 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] #[test]
fn unit_ends_with_newline() { fn unit_ends_with_newline() {
let body = render_user_unit(Path::new("/x")); let body = render_user_unit(Path::new("/x"));
+16
View File
@@ -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 tmux sockets with it). The env var `MAESTRO_DAEMON_TMUX_TMPDIR` takes
precedence over the file. 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 <maestrod-pid> # 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 ## What the systemd unit looks like
The wizard writes a *user* unit (`systemctl --user`), since `maestrod` runs The wizard writes a *user* unit (`systemctl --user`), since `maestrod` runs
@@ -140,6 +155,7 @@ After=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=%h/.cargo/bin/maestrod ExecStart=%h/.cargo/bin/maestrod
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5