hyperhive/hivectl/src/main.rs
atlas 170fd817ea fix(hivectl): ask the daemon whether an agent exists
The agents root is 0700 and owned by the daemon's user, so hivectl's
client-side existence guard hit EACCES on traversal for anyone not root.
It reported that as "this command needs root; re-run with sudo", which
turned three verbs' pre-flight check into a permission error about the
wrong thing: `choom`, `subvol upgrade` and `subvol snapshot create` all
failed at the guard rather than at whatever they actually needed.

The daemon runs as the owning user and already answers this question for
its own provisioning paths, so expose it on the host socket as
`AgentExists` and have hivectl ask. Operators reach that socket through
the `hive-admin` group, so the guard now works without sudo.

`choom` still needs root for `machinectl shell` — we ship no polkit rule
granting those actions — so it now checks the effective uid and says so
directly instead of failing later inside systemd's authorisation.
2026-07-27 09:34:43 +02:00

140 lines
5.1 KiB
Rust

//! `hivectl` — operator-facing host CLI for hyperhive.
//!
//! A thin client for the `hive-c0re` daemon: it speaks the host admin
//! socket protocol (`hive-host-sock`) and does NOT link the daemon crate.
//! Container lifecycle + the approval queue (`agents <spawn|kill|rebuild|
//! restart|…>`, `approvals <pending|approve|deny>`, `stop` / `start`) and
//! provisioning (`forge` / `matrix` / `github` / `gateway`) all forward to
//! the daemon, which owns the broker, the credentials, and the provisioning
//! logic — a running daemon is required for those. A couple of verbs work
//! off local host state directly instead (`wg` / `peer-config` read the mesh
//! key + TLS CA), so they don't need the socket. `choom` execs into a
//! container rather than asking the daemon to do anything, but still uses the
//! socket for its "is this an agent?" pre-flight — that answer lives in a
//! directory only the daemon's user can read.
//!
//! One module per subcommand family (see the `mod` list below); `main` is
//! just the clap parse + the top-level dispatch match.
use anyhow::Result;
use clap::Parser;
mod cli;
/// The host admin socket client (`request`), split out so it lives with
/// hivectl rather than in the daemon crate.
mod client;
/// Rebuild-queue DAG progress rendering (`wait_for_dags` + the spinner /
/// plain renderers), split out to keep this file manageable.
mod dag_progress;
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, GithubCmd, WgCmd};
mod completions;
mod quota;
mod util;
use completions::generate_completions;
mod gateway;
use gateway::{gateway_create_user, gateway_delete_user, gateway_list_users};
mod matrix;
use matrix::run_matrix_cmd;
mod open;
use open::open_url;
mod wg;
use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status};
mod choom;
use choom::choom;
mod github;
use github::github_set_token;
mod forge;
use forge::{forge_create_user, forge_reconcile_config};
mod agents;
use agents::run_agents;
mod power;
use power::{restart, start, stop};
mod approvals;
mod subvol;
use approvals::run_approvals;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let socket = cli.socket;
match cli.cmd {
Cmd::Forge { cmd } => match cmd {
ForgeCmd::CreateUser {
name,
password,
password_stdin,
} => forge_create_user(&socket, &name, password.as_deref(), password_stdin).await,
ForgeCmd::ReconcileConfig {
agent,
from,
verbose,
} => forge_reconcile_config(&socket, &agent, from, verbose).await,
},
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await,
Cmd::Github { cmd } => match cmd {
GithubCmd::SetToken {
agent,
token,
token_stdin,
} => github_set_token(&socket, &agent, token, token_stdin).await,
},
Cmd::Gateway { cmd } => match cmd {
GatewayCmd::CreateUser {
username,
password,
password_stdin,
} => gateway_create_user(&socket, &username, password.as_deref(), password_stdin).await,
GatewayCmd::DeleteUser { username } => gateway_delete_user(&socket, &username).await,
GatewayCmd::ListUsers => gateway_list_users(&socket).await,
},
Cmd::Agents { cmd } => run_agents(&socket, cmd).await,
Cmd::Approvals { cmd } => run_approvals(&socket, cmd).await,
Cmd::Wg { cmd } => match cmd {
WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await,
WgCmd::Peer {
domain,
pubkey,
address,
endpoint,
} => {
wg_peer(&domain, &pubkey, &address, endpoint.as_deref());
Ok(())
}
WgCmd::Status => wg_status(),
},
Cmd::PeerConfig {
wg_address,
wg_endpoint,
} => {
let domain = require_hive_domain(&socket).await?;
peer_config(&domain, wg_address.as_deref(), wg_endpoint.as_deref());
Ok(())
}
Cmd::Stop {
scope,
graceful,
no_wait,
} => stop(&socket, scope.to_scope(), graceful, no_wait).await,
Cmd::Start { scope, no_wait } => start(&socket, scope.to_scope(), no_wait).await,
Cmd::Restart { scope, graceful } => restart(&socket, scope.to_scope(), graceful).await,
Cmd::Choom {
name,
resume_session,
} => choom(&socket, &name, resume_session.as_deref()).await,
Cmd::MarkdownDocs => {
print!("{}", clap_markdown::help_markdown::<Cli>());
Ok(())
}
Cmd::Open { target } => open_url(&socket, target).await,
Cmd::Completions { shell } => {
generate_completions(shell);
Ok(())
}
}
}