Merge branch 'linear/nap-237-daemon-policy-hot-reload-sighup-maestro-policy-reload'
ci / rust (push) Successful in 4m46s
ci / rust (push) Successful in 4m46s
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -9,4 +9,5 @@ pub mod attach;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod http;
|
||||
pub mod policy;
|
||||
pub mod sessions;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<String, jsonwebtoken::e
|
||||
|
||||
/// Verifier for pair-scoped attach JWTs (NAP-247). Checks signature, `exp`,
|
||||
/// `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)]
|
||||
pub struct PairAttachVerifier {
|
||||
decoding: DecodingKey,
|
||||
daemon_id: String,
|
||||
allow_inbound: HashSet<String>,
|
||||
allow_inbound: Arc<RwLock<HashSet<String>>>,
|
||||
}
|
||||
|
||||
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", &"<redacted>")
|
||||
.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<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
|
||||
/// the home daemon's id (`sub`) for logging/tracing.
|
||||
pub fn verify(&self, token: &str, session_id: Uuid) -> Result<String, JwtError> {
|
||||
@@ -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::<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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
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()?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user