The peer (T2) attach path had a per-pair access-control layer (`[daemon.policy].allow_inbound`): a target-side allow-list of daemon ids permitted to broker-attach, enforced inside JWT verification, hot-reloadable via SIGHUP / `POST /api/policy/reload`, with a `maestro policy` CLI. On a flat-trust perimeter (the operator's VPN/firewall is the boundary for which hosts connect) that granularity is unused, and deny-by-default made it config friction — cross-daemon attach silently failed until every peer was listed. Remove the allow-list; keep the registry-minted pair-attach JWT and ALL its binding checks (iss=registry, aud=target, sid=session, exp). The firewall gates host->port reachability; it does not stop a reachable host from attaching to a session it wasn't handed or replaying a token elsewhere — that's the JWT's job and it stays. Only the `sub in allow_inbound` check is dropped. Removed: DaemonPolicySection / [daemon.policy].allow_inbound (config + daemon), policy.rs (SIGHUP listener + GET/POST /api/policy), the `maestro policy` CLI, PairAttachVerifier's allow-list field/reload/snapshot + JwtError::NotAllowed, and the e2e broker_rejects_when_target_doesnt_allow_inbound test. The NAP-265 pre-upgrade-502 path stays covered by broker_attach_returns_502_on_perimeter_mismatch. Also dropped the unit's ExecReload=kill -HUP: with no SIGHUP handler left, default SIGHUP disposition is terminate, so wiring `systemctl reload` to it would kill the daemon. Re-introducing a per-pair gate later is a verify-time change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
6.3 KiB
Rust
190 lines
6.3 KiB
Rust
//! Clap-derive subcommand for the daemon, plus a runner that wires together
|
|
//! everything the binary used to do in `main.rs`.
|
|
//!
|
|
//! Lifted out of the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
|
|
//! binary can compose this subcommand alongside the CLI and registry.
|
|
|
|
use std::error::Error;
|
|
use std::path::PathBuf;
|
|
|
|
use clap::Subcommand;
|
|
use tokio::net::TcpListener;
|
|
|
|
use crate::session::DEFAULT_LIVENESS_INTERVAL;
|
|
use crate::sidecar::SidecarStore;
|
|
use crate::tmux::Tmux;
|
|
use crate::worktree::WorktreeManager;
|
|
use crate::{
|
|
attach, default_config_path, default_state_dir, load_config, local_api, run_configure,
|
|
spawn_session_publisher, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus,
|
|
PairAttachVerifier, RegistryClient, SessionManager,
|
|
};
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum DaemonCmd {
|
|
/// Start the daemon. Default when no subcommand is given.
|
|
Run,
|
|
/// Write `~/.config/maestro/config.toml` interactively or from flags.
|
|
Configure(ConfigureArgs),
|
|
}
|
|
|
|
#[derive(Debug, clap::Args)]
|
|
pub struct ConfigureArgs {
|
|
/// Registry base URL (e.g. `https://maestro.example.com`). The daemon
|
|
/// derives its WS endpoint by scheme-swapping and appending `/daemon`.
|
|
#[arg(long)]
|
|
pub registry_url: Option<String>,
|
|
/// Reachable URL clients dial for this daemon's attach WS.
|
|
#[arg(long)]
|
|
pub daemon_url: Option<String>,
|
|
/// Shared token (MAESTRO_TOKEN). Tip: pipe from `pass` / `op` / `secret-tool`.
|
|
#[arg(long)]
|
|
pub token: Option<String>,
|
|
/// Stable daemon ID. Defaults to the system hostname.
|
|
#[arg(long)]
|
|
pub daemon_id: Option<String>,
|
|
/// Local bind address for the per-session WS endpoint.
|
|
#[arg(long)]
|
|
pub bind_addr: Option<String>,
|
|
/// Override the daemon's state directory.
|
|
#[arg(long)]
|
|
pub state_dir: Option<PathBuf>,
|
|
/// Override the worktree root.
|
|
#[arg(long)]
|
|
pub worktree_root: Option<PathBuf>,
|
|
/// Override the recorded hostname (defaults to `gethostname(2)`).
|
|
#[arg(long)]
|
|
pub hostname: Option<String>,
|
|
/// Overwrite an existing config file.
|
|
#[arg(long)]
|
|
pub force: bool,
|
|
/// Disable interactive prompts (errors if any required field is missing).
|
|
#[arg(long)]
|
|
pub non_interactive: bool,
|
|
}
|
|
|
|
/// Dispatch a parsed daemon subcommand. `config_override` is the unified
|
|
/// `--config` flag from the top-level CLI.
|
|
pub async fn dispatch(
|
|
config_override: Option<PathBuf>,
|
|
cmd: Option<DaemonCmd>,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
match cmd.unwrap_or(DaemonCmd::Run) {
|
|
DaemonCmd::Run => run(config_override).await,
|
|
DaemonCmd::Configure(args) => configure(config_override, args),
|
|
}
|
|
}
|
|
|
|
async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|
let config_path: PathBuf = match config_override {
|
|
Some(p) => p,
|
|
None => default_config_path()?,
|
|
};
|
|
let config = load_config(&config_path, EnvOverrides::from_env())?;
|
|
tracing::info!(
|
|
daemon_id = %config.daemon_id,
|
|
registry = %config.registry_url,
|
|
bind = %config.bind_addr,
|
|
"loaded config"
|
|
);
|
|
|
|
let _ = default_state_dir();
|
|
std::fs::create_dir_all(config.sidecar_dir())?;
|
|
std::fs::create_dir_all(config.fifo_dir())?;
|
|
std::fs::create_dir_all(&config.worktree_root)?;
|
|
|
|
let sidecar = SidecarStore::new(config.sidecar_dir());
|
|
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
|
|
let tmux = match config.tmux_tmpdir.clone() {
|
|
Some(p) => Tmux::new().with_tmpdir(p),
|
|
None => Tmux::new(),
|
|
};
|
|
let manager = SessionManager::new_with_replay(
|
|
config.daemon_id.clone(),
|
|
tmux,
|
|
sidecar,
|
|
worktree_mgr,
|
|
config.fifo_dir(),
|
|
config.pty_replay_bytes,
|
|
);
|
|
|
|
let recovered = manager.recover().await?;
|
|
tracing::info!(recovered, "adopted existing tmux sessions");
|
|
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
|
|
|
let verifier = JwtVerifier::new(&config.token, &config.daemon_id);
|
|
let pair_verifier = PairAttachVerifier::new(&config.token, &config.daemon_id);
|
|
|
|
let events = LocalEventBus::new();
|
|
spawn_session_publisher(manager.clone(), events.clone());
|
|
|
|
let client = RegistryClient::new_with_bus(
|
|
config.registry_url.clone(),
|
|
config.daemon_id.clone(),
|
|
config.hostname.clone(),
|
|
config.daemon_url.clone(),
|
|
config.home_dir.clone(),
|
|
config.token.clone(),
|
|
manager.clone(),
|
|
Some(events.clone()),
|
|
)
|
|
.with_perimeter(config.perimeter());
|
|
let catalog = client.catalog();
|
|
|
|
let local_state = local_api::state_for(
|
|
manager.clone(),
|
|
config.hostname.clone(),
|
|
config.daemon_url.clone(),
|
|
config.home_dir.clone(),
|
|
catalog.clone(),
|
|
events,
|
|
client.broker(),
|
|
);
|
|
let broker_state = local_api::BrokerState {
|
|
manager: manager.clone(),
|
|
catalog,
|
|
broker: client.broker(),
|
|
self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()),
|
|
self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new),
|
|
};
|
|
let router = attach::router(manager.clone(), verifier, pair_verifier)
|
|
.merge(local_api::router(local_state))
|
|
.merge(local_api::broker_router(broker_state));
|
|
|
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
|
let bound = listener.local_addr()?;
|
|
tracing::info!(addr = %bound, "attach WS listening");
|
|
|
|
tokio::spawn(async move { client.run().await });
|
|
|
|
let shutdown = async {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
tracing::info!("ctrl-c received — shutting down");
|
|
};
|
|
|
|
axum::serve(listener, router)
|
|
.with_graceful_shutdown(shutdown)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn configure(config_override: Option<PathBuf>, args: ConfigureArgs) -> Result<(), Box<dyn Error>> {
|
|
let path: PathBuf = match config_override {
|
|
Some(p) => p,
|
|
None => default_config_path()?,
|
|
};
|
|
let inputs = ConfigureInputs {
|
|
registry_url: args.registry_url,
|
|
daemon_url: args.daemon_url,
|
|
token: args.token,
|
|
daemon_id: args.daemon_id,
|
|
bind_addr: args.bind_addr,
|
|
state_dir: args.state_dir,
|
|
worktree_root: args.worktree_root,
|
|
hostname: args.hostname,
|
|
};
|
|
let written = run_configure(&path, inputs, !args.non_interactive, args.force)?;
|
|
eprintln!("wrote {}", written.display());
|
|
Ok(())
|
|
}
|