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.
150 lines
5.8 KiB
Rust
150 lines
5.8 KiB
Rust
//! Shared helpers used across hivectl's command handling: daemon
|
|
//! request/response plumbing (`daemon_request`, `render`,
|
|
//! `render_lifecycle`) and password input (`resolve_password`).
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
|
|
/// Parse a CLI-supplied agent/account name into a validated
|
|
/// [`hive_types::Ident`], mapping the parse error to an `anyhow` error that
|
|
/// names the offending input. Used at hivectl's `HostRequest` construction
|
|
/// sites so the wire `Ident` fields are built from validated names (the daemon
|
|
/// re-validates on deserialize; parsing here gives the operator an immediate,
|
|
/// local error instead of a round-trip rejection).
|
|
pub(crate) fn parse_ident(name: &str) -> Result<hive_types::Ident> {
|
|
hive_types::Ident::parse(name).map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))
|
|
}
|
|
|
|
/// Send a provisioning request to the daemon and print its result lines.
|
|
/// The daemon owns the provisioning logic; hivectl just relays the outcome,
|
|
/// prefixing any error with `label` (e.g. `forge` / `github`).
|
|
pub(crate) async fn daemon_request(
|
|
socket: &Path,
|
|
req: hive_host_sock::HostRequest,
|
|
label: &str,
|
|
) -> Result<()> {
|
|
// No extra context on the request: `client::request` already names the
|
|
// socket and classifies the failure, and wrapping it here would put a
|
|
// vaguer line on top of the actionable one.
|
|
let resp = crate::client::request(socket, req).await?;
|
|
if !resp.ok {
|
|
bail!(
|
|
"{label}: {}",
|
|
resp.error.as_deref().unwrap_or("unknown error")
|
|
);
|
|
}
|
|
for line in &resp.messages {
|
|
println!("{line}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the password the caller asked for, or return `None` to fall
|
|
/// back to a random throwaway. `--password <PW>` wins outright;
|
|
/// `--password-stdin` reads one line off stdin (trailing newline
|
|
/// stripped). Empty stdin is treated as an error so the caller doesn't
|
|
/// silently provision an empty-password account.
|
|
pub(crate) fn resolve_password(
|
|
password: Option<&str>,
|
|
password_stdin: bool,
|
|
) -> Result<Option<String>> {
|
|
if let Some(p) = password {
|
|
return Ok(Some(p.to_owned()));
|
|
}
|
|
if password_stdin {
|
|
use std::io::BufRead as _;
|
|
let stdin = std::io::stdin();
|
|
let mut line = String::new();
|
|
stdin
|
|
.lock()
|
|
.read_line(&mut line)
|
|
.context("read password from stdin")?;
|
|
let trimmed = line.trim_end_matches(['\r', '\n']).to_owned();
|
|
if trimmed.is_empty() {
|
|
bail!("--password-stdin: empty input");
|
|
}
|
|
return Ok(Some(trimmed));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the
|
|
/// agent-lifecycle + approval verbs that just relay a daemon result verbatim.
|
|
pub(crate) fn render(resp: hive_host_sock::HostResponse) -> Result<()> {
|
|
println!("{}", serde_json::to_string_pretty(&resp)?);
|
|
if !resp.ok {
|
|
bail!(resp.error.unwrap_or_else(|| "request failed".to_owned()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Render a hive-wide stop/start/restart response: one `<verb>: <name>`
|
|
/// line per touched container, then surface any aggregated per-target
|
|
/// failure as a non-zero exit. `verb` is the past-tense word printed per
|
|
/// item (`stopped` / `started`).
|
|
pub(crate) fn render_lifecycle(resp: &hive_host_sock::HostResponse, verb: &str) -> Result<()> {
|
|
let items = resp.agents.as_deref().unwrap_or(&[]);
|
|
if items.is_empty() {
|
|
println!("{verb}: nothing matched the requested scope");
|
|
} else {
|
|
for item in items {
|
|
println!("{verb}: {item}");
|
|
}
|
|
}
|
|
if !resp.ok {
|
|
bail!(
|
|
"{verb}: {}",
|
|
resp.error.as_deref().unwrap_or("unknown error")
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Query this hive's domain + browser-facing web URLs (`HostRequest::Urls`).
|
|
///
|
|
/// `Ok(None)` = the daemon answered and has nothing to report; `Err` = it was
|
|
/// never reached, and the error carries the actionable connect hint (see
|
|
/// [`crate::client::request`]). Keep the two apart: collapsing the error into
|
|
/// `None` here is what made a permission problem on the socket read as "is
|
|
/// hive-c0re running?", sending the operator to fix the wrong thing.
|
|
pub(crate) async fn query_hive_urls(socket: &Path) -> Result<Option<hive_host_sock::HiveUrls>> {
|
|
Ok(
|
|
crate::client::request(socket, hive_host_sock::HostRequest::Urls)
|
|
.await?
|
|
.urls,
|
|
)
|
|
}
|
|
|
|
/// True when `name` matches an existing hyperhive agent — i.e. it has a
|
|
/// persistent state dir under the agents root. The state dir (not the
|
|
/// live container list) is the test, so kept-state tombstones still
|
|
/// resolve as agents: re-provisioning a destroyed-but-kept agent should
|
|
/// drop its token into the existing state tree.
|
|
///
|
|
/// Asks the daemon rather than stat-ing the path. hivectl used to check
|
|
/// locally, but the agents root is `0700` and owned by the daemon's
|
|
/// user, so an operator without root got EACCES on traversal — which
|
|
/// this function then had to report as "needs root", turning every
|
|
/// caller's existence guard into a permission error. The daemon owns
|
|
/// that directory and answers the same question over the socket, which
|
|
/// operators can already reach via the `hive-admin` group, so the guard
|
|
/// works sudoless and the remaining root requirements (if any) surface
|
|
/// where they actually are.
|
|
pub(crate) async fn agent_exists(socket: &Path, name: &str) -> Result<bool> {
|
|
let resp = crate::client::request(
|
|
socket,
|
|
hive_host_sock::HostRequest::AgentExists {
|
|
name: parse_ident(name)?,
|
|
},
|
|
)
|
|
.await?;
|
|
if !resp.ok {
|
|
bail!(
|
|
"check whether {name:?} is an agent: {}",
|
|
resp.error.as_deref().unwrap_or("unknown error")
|
|
);
|
|
}
|
|
resp.agent_exists
|
|
.context("daemon answered the agent-exists check without a result")
|
|
}
|