diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index dbfb4ee6..4511050a 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -332,27 +332,48 @@ async fn write_line_event(writer: &mut OwnedWriteHalf, stream: PrivStream, data: /// For streaming ops (`CreateContainer`/`UpdateContainer` with `stream: true`) /// output lines are forwarded to `writer` as `PrivEvent::Line` messages and /// the returned strings are empty. +// One match arm per priv op — a flat 1:1 dispatch table. The length tracks +// the op count, not complexity; splitting it would just scatter the mapping. +#[allow(clippy::too_many_lines)] +/// Execute one validated request. /// /// `fd` is the descriptor that arrived with this request, already checked /// against the operation by [`check_fd_agreement`]: `Some` exactly for /// the variants that stream into a caller-supplied descriptor, `None` /// for every other operation. -// One match arm per priv op — a flat 1:1 dispatch table. Every arm either -// delegates directly or validates then delegates; an op whose handling is -// more than that gets its own named function instead, so the match's length -// tracks the op count, not complexity. -#[allow(clippy::too_many_lines)] async fn exec( req: PrivRequest, fd: Option, writer: &mut OwnedWriteHalf, ) -> Result<(String, String)> { match req { - PrivRequest::StartContainer { ref name } => start_container(name).await, + PrivRequest::StartContainer { ref name } => { + validate_container_name(name)?; + let machine = container_system_name(name); + // Clear any start-limit lockout left by earlier failures so a + // now-correct start isn't blocked. nixos-container start does not + // do this itself. Best-effort: if the unit doesn't exist yet + // (first-time create) reset-failed is a no-op and we proceed. + let _ = Command::new("systemctl") + .args(["reset-failed", &format!("container@{machine}.service")]) + .status() + .await; + container_run(&["start", &machine]).await + } - PrivRequest::StopContainer { ref name } => stop_container(name).await, + PrivRequest::StopContainer { ref name } => { + validate_container_name(name)?; + stop_and_release(&container_system_name(name)).await + } - PrivRequest::KillContainer { ref name } => kill_container(name).await, + PrivRequest::KillContainer { ref name } => { + validate_container_name(name)?; + // nixos-container has no kill verb. Use machinectl to send SIGKILL + // to all processes in the container — the right semantics for a + // forced shutdown after a graceful stop has already been attempted. + let machine = container_system_name(name); + machinectl_run(&["kill", &machine, "--signal=SIGKILL"]).await + } PrivRequest::UpdateContainer { ref name, stream } => { container_flake_action("update", name, stream, writer).await @@ -362,14 +383,20 @@ async fn exec( container_flake_action("create", name, stream, writer).await } - PrivRequest::DestroyContainer { ref name } => destroy_container(name).await, + PrivRequest::DestroyContainer { ref name } => { + validate_container_name(name)?; + container_run(&["destroy", &container_system_name(name)]).await + } PrivRequest::ListContainers => container_run(&["list"]).await, PrivRequest::ReadContainerJournal { ref container, ref query, - } => exec_read_container_journal(container, query).await, + } => { + validate_container_system_name(container)?; + read_container_journal(container, query).await + } PrivRequest::WriteNspawnFlags { ref container, @@ -392,41 +419,105 @@ async fn exec( PrivRequest::ReloadGatewayNginx => sync_gateway_nginx().await, - PrivRequest::RunForgeAdmin { ref args } => exec_forge_admin(args).await, + PrivRequest::RunForgeAdmin { ref args } => { + for arg in args { + validate_forge_admin_arg(arg)?; + } + run_forge_admin(args).await + } PrivRequest::SetAgentPaused { ref agent_name, paused, - } => exec_set_agent_paused(agent_name, paused), + } => { + validate_agent_name(agent_name)?; + set_agent_paused(agent_name, paused) + } PrivRequest::WriteAgentForgeToken { ref agent_name, ref token, - } => write_forge_token(agent_name, token), + } => { + validate_agent_name(agent_name)?; + write_agent_state_file(agent_name, "forge-token", &format!("{token}\n")) + } PrivRequest::WriteAgentMatrixToken { ref agent_name, ref token, ref account, ref homeserver, - } => write_matrix_token(agent_name, token, account.as_deref(), homeserver.as_deref()), + } => { + validate_agent_name(agent_name)?; + // Build the token filename. `None` → the hive account's + // `matrix-token`; `Some(a)` → `matrix-token-`. The account + // suffix MUST be validated as a plain identifier (no `/`, `.`, + // `..`) before it goes into the filename, or a crafted account + // could traverse out of the state dir — `write_agent_state_file` + // trusts its `filename` argument. + let filename = match account { + None => "matrix-token".to_owned(), + Some(a) => { + validate_name_chars(a)?; + format!("matrix-token-{a}") + } + }; + let res = write_agent_state_file(agent_name, &filename, &format!("{token}\n"))?; + // For an extra account, persist its homeserver in a sidecar + // (`matrix-account-.json`) so the daemon can auto-discover the + // account without a static `matrixAccounts` config entry. Only + // when both `account` and `homeserver` are present; the account + // suffix is already validated above. + if let (Some(a), Some(hs)) = (account, homeserver) { + let meta = serde_json::to_string(&MatrixAccountSidecar { + homeserver: hs.as_str(), + }) + .context("serialize matrix account sidecar")?; + write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?; + } + Ok(res) + } PrivRequest::WriteAgentGithubToken { ref agent_name, ref token, - } => write_github_token(agent_name, token), + } => { + validate_agent_name(agent_name)?; + write_agent_state_file(agent_name, "github-token", &format!("{token}\n")) + } PrivRequest::WriteAgentExtraForgeAccount { ref agent_name, ref label, ref base_url, ref token, - } => write_extra_forge_account(agent_name, label, base_url, token), + } => { + validate_agent_name(agent_name)?; + validate_name_chars(label)?; + let res = write_agent_state_file( + agent_name, + &format!("forge-{label}-token"), + &format!("{token}\n"), + )?; + // Sidecar carries the base URL — there's no host-side nix config + // for extra forges, so this is the only place it's persisted. + let meta = serde_json::to_string(&ForgeSidecar { + base_url: base_url.as_str(), + }) + .context("serialize forge account sidecar")?; + write_agent_state_file(agent_name, &format!("forge-{label}.json"), &meta)?; + Ok(res) + } PrivRequest::DeleteAgentExtraForgeAccount { ref agent_name, ref label, - } => delete_extra_forge_account(agent_name, label), + } => { + validate_agent_name(agent_name)?; + validate_name_chars(label)?; + delete_agent_state_file(agent_name, &format!("forge-{label}-token"))?; + delete_agent_state_file(agent_name, &format!("forge-{label}.json")) + } PrivRequest::RestartMatrixDaemon { ref agent_name } => { restart_matrix_daemon(agent_name).await @@ -439,37 +530,52 @@ async fn exec( } PrivRequest::EnsureAgentSubvolume { ref agent_name } => { - exec_ensure_agent_subvolume(agent_name).await + validate_agent_name(agent_name)?; + ensure_agent_subvolume(agent_name).await } PrivRequest::DeleteAgentSubvolume { ref agent_name } => { - exec_delete_agent_subvolume(agent_name).await + validate_agent_name(agent_name)?; + delete_agent_subvolume(agent_name).await } PrivRequest::EnsureBtrfsQuota => ensure_btrfs_quota().await, PrivRequest::ReadSubvolumeUsage { ref agent_name } => { - exec_read_subvolume_usage(agent_name).await + validate_agent_name(agent_name)?; + read_subvolume_usage(agent_name).await } PrivRequest::SetSubvolumeQuota { ref agent_name, limit_bytes, - } => exec_set_subvolume_quota(agent_name, limit_bytes).await, + } => { + validate_agent_name(agent_name)?; + set_subvolume_quota(agent_name, limit_bytes).await + } PrivRequest::UpgradeAgentSubvolume { ref agent_name } => { - exec_upgrade_agent_subvolume(agent_name).await + validate_agent_name(agent_name)?; + upgrade_agent_subvolume(agent_name).await } PrivRequest::SnapshotAgentSubvolume { ref agent_name, ref snapshot_name, - } => exec_snapshot_agent_subvolume(agent_name, snapshot_name).await, + } => { + validate_agent_name(agent_name)?; + validate_snapshot_name(snapshot_name)?; + snapshot_agent_subvolume(agent_name, snapshot_name).await + } PrivRequest::DeleteAgentSnapshot { ref agent_name, ref snapshot_name, - } => exec_delete_agent_snapshot(agent_name, snapshot_name).await, + } => { + validate_agent_name(agent_name)?; + validate_snapshot_name(snapshot_name)?; + delete_agent_snapshot(agent_name, snapshot_name).await + } PrivRequest::SendAgentSnapshotToFile { ref agent_name, @@ -477,7 +583,13 @@ async fn exec( ref parent_snapshot_name, ref dest_file_name, } => { - exec_send_agent_snapshot_to_file( + validate_agent_name(agent_name)?; + validate_snapshot_name(snapshot_name)?; + if let Some(parent) = parent_snapshot_name { + validate_snapshot_name(parent)?; + } + validate_credential_name(dest_file_name)?; + send_agent_snapshot_to_file( agent_name, snapshot_name, parent_snapshot_name.as_deref(), @@ -491,11 +603,17 @@ async fn exec( ref snapshot_name, ref parent_snapshot_name, } => { - exec_send_agent_snapshot_to_fd( + validate_agent_name(agent_name)?; + validate_snapshot_name(snapshot_name)?; + if let Some(parent) = parent_snapshot_name { + validate_snapshot_name(parent)?; + } + let dest = fd.context("no descriptor to stream into")?; + send_agent_snapshot_to_fd( agent_name, snapshot_name, parent_snapshot_name.as_deref(), - fd, + dest, ) .await } @@ -504,232 +622,6 @@ async fn exec( } } -/// `StartContainer`: clear any start-limit lockout left by earlier failures -/// so a now-correct start isn't blocked (`nixos-container start` does not do -/// this itself). Best-effort — if the unit doesn't exist yet (first-time -/// create), `reset-failed` is a no-op and the start proceeds regardless. -async fn start_container(name: &str) -> Result<(String, String)> { - validate_container_name(name)?; - let machine = container_system_name(name); - let _ = Command::new("systemctl") - .args(["reset-failed", &format!("container@{machine}.service")]) - .status() - .await; - container_run(&["start", &machine]).await -} - -/// `RunForgeAdmin`: every arg must pass [`validate_forge_admin_arg`] before -/// the admin CLI ever sees it. -async fn exec_forge_admin(args: &[String]) -> Result<(String, String)> { - for arg in args { - validate_forge_admin_arg(arg)?; - } - run_forge_admin(args).await -} - -/// `WriteAgentMatrixToken`: `account = None` writes the hive account's -/// `matrix-token`; `Some(a)` writes `matrix-token-`. The account suffix -/// MUST be validated as a plain identifier (no `/`, `.`, `..`) before it goes -/// into the filename, or a crafted account could traverse out of the state -/// dir — `write_agent_state_file` trusts its `filename` argument. When both -/// `account` and `homeserver` are `Some`, also persists a -/// `matrix-account-.json` sidecar so the daemon can auto-discover the -/// extra account without a static `matrixAccounts` config entry. -fn write_matrix_token( - agent_name: &str, - token: &str, - account: Option<&str>, - homeserver: Option<&str>, -) -> Result<(String, String)> { - validate_agent_name(agent_name)?; - let filename = match account { - None => "matrix-token".to_owned(), - Some(a) => { - validate_name_chars(a)?; - format!("matrix-token-{a}") - } - }; - let res = write_agent_state_file(agent_name, &filename, &format!("{token}\n"))?; - if let (Some(a), Some(hs)) = (account, homeserver) { - let meta = serde_json::to_string(&MatrixAccountSidecar { homeserver: hs }) - .context("serialize matrix account sidecar")?; - write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?; - } - Ok(res) -} - -/// `WriteAgentExtraForgeAccount`: writes the token, then a -/// `forge-