Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-priv-sock/src/lib.rs
atlas 884fb3e08b dashboard: remove the L0GS page and its journal backend
Logs live at swarm level now (VictoriaLogs, read through Grafana
Explore). The hive dashboard's own journal viewer goes, with
everything that existed only to serve it:

- frontend: /logs.html, logs.js, logs.css, the H0M3 "Logs" tile and
  the per-agent menu's "journal logs ->" link. The build-log list
  rules BU1LDS used from logs.css move into builds.css.
- hive-c0re: GET /api/journal/{name} and GET /api/journal-host, and
  priv_client::read_container_journal.
- hive-priv / hive-priv-sock: the ReadContainerJournal request, its
  JournalQuery/JournalOutput wire types and the journalctl -M executor.
- nix: hive-c0re's systemd-journal supplementary group, whose only
  reader was the journal-host handler.
- docs: the L0GS section, the API entry and the security table row.

Closes #5113
Closes #3819
2026-10-12 01:26:33 +02:00

747 lines
34 KiB
Rust

//! Wire types for the `hive-priv` privileged-helper socket
//! (`/run/hive/priv.sock`).
//!
//! Both `hive-priv` (server) and `hive-c0re` (client via `priv_client`)
//! import these so the shapes stay in sync. Split out of `hive-sh4re` so
//! `hive-priv` — the privileged root helper — can depend on just this
//! protocol crate instead of the much larger daemon-shared crate: fewer
//! dependencies in the root-privileged binary's supply chain, and a
//! narrower interface makes the boundary this crate encodes easier to
//! audit. No server/client implementation lives here, only the wire
//! contract (mirrors `hive-host-sock`'s split for the host admin socket).
use serde::{Deserialize, Serialize};
/// Default socket path for the privileged helper.
pub const PRIV_SOCK: &str = "/run/hive/priv.sock";
/// File name of the pause marker inside an agent's harness dir. Defined
/// here — the narrowest crate all three sides already share — because the
/// marker is a two-sided contract with no protocol behind it: hive-priv
/// creates and unlinks it as root, hive-c0re stats it to render the paused
/// indicator, and the in-container harness stats it to gate its turn loop
/// (via the `hive-sh4re::paths` re-export). A private copy on any one side
/// would break pause *silently*, since every reader just sees "no marker" —
/// exactly the failure mode a shared constant exists to prevent.
pub const PAUSED_MARKER_FILE: &str = "paused";
/// Manager logical agent name. The manager's system container name is
/// `h-ruth` (same `h-` prefix convention as every other agent).
pub const MANAGER_NAME: &str = "ruth";
/// Sub-agent container prefix. System container name = `h-<agent_name>`.
pub const AGENT_PREFIX: &str = "h-";
/// Sibling service containers managed by hive-c0re. This doubles as the
/// authoritative allowlist for the requests that name a container as a
/// string (bind-mount edits): only these — or a valid agent
/// name — are accepted. `hive-c0re` is deliberately absent; so is the
/// gateway, whose nginx is a plain host unit rather than a container.
/// hive-priv re-validates against this list root-side, so it's authoritative
/// regardless of what the caller sends.
///
/// ⚠️ This is the *container-name* allowlist, not the lifecycle one:
/// [`InfraContainer`] is what gates
/// [`PrivRequest::ControlInfraContainer`], and it has one variant more than
/// this list (the gateway). Keep the distinction — a name that belongs to
/// no container has no business reaching a `nixos-container` call.
pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix"];
/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly
/// to `systemctl <verb> <unit>`, where the unit comes from
/// [`InfraContainer::service_unit`] — usually `container@<name>.service`,
/// but not always (see [`InfraTarget`]).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InfraAction {
Start,
Stop,
}
impl InfraAction {
/// The `systemctl` subcommand this action maps to.
#[must_use]
pub fn systemctl_verb(self) -> &'static str {
match self {
InfraAction::Start => "start",
InfraAction::Stop => "stop",
}
}
}
/// A hive infrastructure container that can be controlled (start / stop /
/// restart) via [`PrivRequest::ControlInfraContainer`]. The variants ARE
/// the allowlist: serde rejects any other value at the wire boundary, so an
/// unknown or unsafe target — notably `hive-c0re`, which has no variant and
/// would sever the daemon socket — is *unrepresentable* rather than caught
/// by a runtime check. The c0re↔hive-priv wire form uses serde's default
/// variant naming (`"Forge"`, `"Gateway"`, …); it's an internal protocol (both
/// ends rebuild together) so it needn't match the container name.
/// [`name`](Self::name) is the separate stable identity string
/// (`hive-forge`), and [`service_unit`](Self::service_unit) the systemd unit
/// it actually resolves to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InfraContainer {
Forge,
Gateway,
Matrix,
}
/// What an [`InfraContainer`] resolves to on the host — i.e. the thing a
/// lifecycle verb actually acts on.
///
/// The gateway is why this exists: its nginx + dnsmasq are host
/// services, so "restart the gateway" means a plain host unit. Every
/// other variant is a container.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfraTarget {
/// An nspawn container, controlled via `container@<name>.service`.
Container(&'static str),
/// A plain host unit. There is no machine to enter: no
/// `nixos-container` verb.
HostUnit(&'static str),
}
impl InfraContainer {
/// Every controllable infra target. A superset of
/// [`SIBLING_CONTAINERS`] — not all of these are containers (see the
/// test).
pub const ALL: [InfraContainer; 3] = [
InfraContainer::Forge,
InfraContainer::Gateway,
InfraContainer::Matrix,
];
/// Stable identity string, e.g. `hive-forge`. This is what the operator
/// types, what the dashboard displays, and what [`FromStr`](std::str::FromStr)
/// parses — it stays `hive-gateway` even though the gateway is no
/// longer a container, because it names the *service*, not its
/// implementation. (Distinct from the serde wire form, which is the
/// default variant name `"Forge"`.)
#[must_use]
pub fn name(self) -> &'static str {
match self {
InfraContainer::Forge => "hive-forge",
InfraContainer::Gateway => "hive-gateway",
InfraContainer::Matrix => "hive-matrix",
}
}
/// Where this target lives on the host.
#[must_use]
pub fn target(self) -> InfraTarget {
match self {
InfraContainer::Gateway => InfraTarget::HostUnit("nginx.service"),
other => InfraTarget::Container(other.name()),
}
}
/// The systemd unit a lifecycle verb acts on.
#[must_use]
pub fn service_unit(self) -> String {
match self.target() {
InfraTarget::Container(name) => format!("container@{name}.service"),
InfraTarget::HostUnit(unit) => unit.to_owned(),
}
}
}
impl std::str::FromStr for InfraContainer {
type Err = ();
/// Parse an infra name (`hive-forge`, …) into a variant. `Err(())` for
/// anything that isn't one.
fn from_str(s: &str) -> Result<Self, ()> {
Self::ALL.into_iter().find(|c| c.name() == s).ok_or(())
}
}
/// Host path of the meta flake. The flake ref for agent `<name>` is
/// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire.
/// Must stay in sync with `hive-c0re::paths::meta_root()` (`STATE_ROOT +
/// "/meta"`); the privsep boundary prevents importing across the crate.
pub const META_DIR: &str = "/var/lib/hyperhive/meta";
/// Root of per-agent state directories on the host.
/// Subdirectory layout: `<AGENT_STATE_ROOT>/<name>/state/<file>`.
/// Used by `WriteAgentStateFile` to derive the write path so the
/// exact path is never passed over the wire.
/// Must stay in sync with `hive-c0re::paths::AGENTS_ROOT`; the privsep
/// boundary prevents importing across the crate.
pub const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
/// Root of per-agent runtime directories on the host (tmpfs, not
/// persistent). hive-c0re creates each `<name>` subdir itself in the start
/// preamble; `hive-priv` only names it in the limits drop-in's
/// `ConditionPathIsDirectory=`.
/// Must stay in sync with `hive-c0re::paths::agent_runtime_root()`
/// (`RUNTIME_ROOT + "/agents"`); the privsep boundary prevents importing
/// across the crate.
pub const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
/// Root of the local staging area for `btrfs send` archives
/// (`SendAgentSnapshotToFile`). A sibling of `AGENT_STATE_ROOT`, not inside
/// it — these are exported streams, not live/subvolume state, and don't
/// belong in the tree btrfs quota accounting or the subvolume-per-agent
/// layout cares about. Root-owned; hive-priv creates it on first use.
pub const MIGRATE_STAGING_ROOT: &str = "/var/lib/hyperhive/migrate-staging";
/// One bind-mount entry for `WriteNspawnFlags`.
/// hive-priv constructs `--bind=<host_path>:<container_path>` (or `--bind-ro=`)
/// and validates both paths before writing the conf file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BindMount {
pub host_path: String,
pub container_path: String,
pub read_only: bool,
}
/// One credential-forwarding entry for `WriteNspawnFlags`. hive-priv
/// constructs `--load-credential=<name>:<host_path>` so systemd-nspawn
/// loads the host secret file into the container's credential store; an
/// inner unit then reads it via `LoadCredential=<name>` (inherit form).
/// The secret never lands in a bind mount, the nix store, or the
/// generated config — only its host path (validated like a bind path)
/// crosses the wire. Used for the hive-wide OTEL auth-header credential.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialMount {
/// systemd credential id (e.g. `otel-headers`); inner units inherit
/// it by this name. Restricted to `[A-Za-z0-9_-]` (no `.`) by hive-priv.
pub name: String,
/// Host path to the secret file, forwarded via nspawn
/// `--load-credential=<name>:<host_path>`.
pub host_path: String,
}
/// Network isolation parameters for `WriteNspawnFlags`. hive-priv writes
/// `PRIVATE_NETWORK=1` + veth bridge wiring from these; every container
/// is isolated, so they are required rather than a mode selector.
/// No static address is pre-assigned here — see `docs/networking/network.md`
/// § *Container isolation* for how a container gets its address.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkIsolation {
/// Host bridge interface name (e.g. `hive0`).
pub bridge: String,
/// Bridge gateway IP (the host-side bridge address, e.g. `10.42.0.1`).
/// Written as `HOST_ADDRESS=` in the nspawn conf so nixos-container's
/// container-side setup installs a default route (`default via <gw>`)
/// before DHCP completes: without it the container has no route off
/// the bridge subnet until the DHCP lease arrives. The same IP runs the
/// hive dnsmasq resolver — see `docs/networking/network.md` § *How the isolated
/// container gets its resolver*.
pub gateway_ip: String,
}
/// Wall-clock bound hive-priv puts on one [`PrivRequest::MeasureAgentDisk`]
/// walk. A caller's own wait has to be longer than this, so a slow walk
/// reaches it as hive-priv's timeout error rather than as its own.
pub const MEASURE_AGENT_DISK_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
/// A request to the privileged helper.
///
/// Wire format: one JSON object per line over `/run/hive/priv.sock`.
/// Every variant is a specific known operation — no pass-through
/// shell commands or arbitrary paths. New privileged ops get new
/// variants.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum PrivRequest {
// --- Container lifecycle ---
/// `nixos-container start <name>`
StartContainer { name: String },
/// `nixos-container stop <name>`
StopContainer { name: String },
/// `machinectl kill <name> --signal=SIGKILL` — force-kills all processes
/// in the container. nixos-container has no kill verb.
KillContainer { name: String },
/// `nixos-container update <name> --flake <flake_ref>`
/// The flake ref is derived from `name` by hive-priv.
/// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages
/// as the process runs, then a terminal `PrivEvent::Done`.
UpdateContainer {
name: String,
#[serde(default)]
stream: bool,
},
/// `nixos-container create <name> --flake <flake_ref>`
/// The flake ref is derived from `name` by hive-priv.
/// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages
/// as the process runs, then a terminal `PrivEvent::Done`.
CreateContainer {
name: String,
#[serde(default)]
stream: bool,
},
/// `nixos-container destroy <name>`
DestroyContainer { name: String },
/// `nixos-container list`
ListContainers,
// --- Config file writes ---
/// Update `/etc/nixos-containers/<container>.conf`: strip old network-isolation
/// vars, write `PRIVATE_NETWORK` + bridge settings, and set `EXTRA_NSPAWN_FLAGS`
/// from the provided bind-mount list. Written by `lifecycle::set_nspawn_flags`.
/// Always writes `PRIVATE_NETWORK=1` + veth wiring: isolation is the only
/// mode, so there is no request shape that yields a container sharing the
/// host's network namespace.
WriteNspawnFlags {
container: String,
binds: Vec<BindMount>,
/// Private netns with a veth on the given bridge
/// (`PRIVATE_NETWORK=1`). Required: isolation is the only
/// supported mode, so there is no value meaning "host netns".
/// Deliberately **not** `#[serde(default)]` — a request that
/// omits it is rejected rather than quietly configuring a
/// container that shares the host's network namespace.
isolation: NetworkIsolation,
/// Host secrets forwarded into the container's credential store via
/// nspawn `--load-credential=<name>:<host_path>`. Empty for agents
/// with no credentials configured (the common case). `#[serde(default)]`
/// so a hive-priv built before this field deserialises new requests.
#[serde(default)]
load_credentials: Vec<CredentialMount>,
},
/// Create the agent's socket dir `/run/hive-agent/<name>` as `0751
/// root:root` if it is missing; an existing directory is left as it is.
/// It is a bind source, so it has to exist before every container start,
/// and `/run` is a tmpfs. The container's own activation hands it to the
/// agent user. Called from `lifecycle::set_nspawn_flags`.
EnsureAgentSocketDir {
/// Logical agent name (validated by `validate_agent_name`).
name: String,
},
/// Write `/run/systemd/system/container@<container>.service.d/hyperhive-limits.conf`
/// with `[Service]` carrying `MemoryMax=` / `CPUQuota=` (hard caps) and
/// `CPUWeight=` / `IOWeight=` (cgroup v2 relative shares, contention-only).
/// Written by `lifecycle::set_resource_limits`.
WriteResourceLimits {
container: String,
memory_max: String,
cpu_quota: String,
/// cgroup v2 `cpu.weight`, 1..=10000. `None` means "not
/// configured" — the writer omits the line entirely, leaving the
/// kernel default. `#[serde(default)]` so a request from a
/// hive-c0re built before this field existed deserialises to
/// `None` and reproduces the pre-weights drop-in.
#[serde(default)]
cpu_weight: Option<u32>,
/// cgroup v2 `io.weight`, 1..=10000. Same `None` = omit rule as
/// `cpu_weight`.
#[serde(default)]
io_weight: Option<u32>,
},
/// Remove `/run/systemd/system/container@<container>.service.d/` if present.
/// Called by `lifecycle::destroy` to clean up the resource-limits drop-in.
RemoveServiceDropin { container: String },
// --- System ---
/// Run `systemctl daemon-reload`.
DaemonReload,
/// Synchronise the host's nginx unit after an `agents.conf` write.
///
/// hive-priv queries `ActiveState` and dispatches:
/// - `active` → `systemctl reload nginx` (SIGHUP, zero-downtime)
/// - `failed` → `systemctl reset-failed nginx` + `systemctl start nginx`
/// - otherwise → `systemctl start nginx`
///
/// Requires root because hive-c0re runs as the unprivileged
/// `hive-core` user and cannot act on a system unit.
///
/// ⚠️ The unit name is **not** a parameter and must stay that way.
/// nginx is a host unit, so nothing else narrows what this verb can
/// touch: hard-coding `nginx` is the entire containment. A caller
/// cannot name the unit, so this verb cannot be steered at any other
/// service.
ReloadGatewayNginx,
// --- Forge admin CLI ---
/// Run `forgejo admin <args>` inside the `hive-forge` container as the
/// `forgejo` unix user. hive-priv executes:
///
/// nixos-container run hive-forge -- runuser -u forgejo --
/// forgejo --work-path /var/lib/forgejo admin `<args>`
///
/// `args` must not contain null bytes, newlines, or shell metacharacters;
/// hive-priv validates this before spawning the subprocess.
///
/// This operation requires root (to nsenter into the forge container's
/// namespaces); hive-c0re (which runs as `hive-core`) calls it through
/// this route instead of spawning `nixos-container run` directly.
RunForgeAdmin {
/// Argument list appended after `forgejo --work-path /var/lib/forgejo admin`.
/// Each element is a separate argv word — no shell expansion occurs.
args: Vec<String>,
},
// --- Agent turn-loop pause ---
// (marker filename: `PAUSED_MARKER_FILE`, defined at the crate root)
/// Create (`paused: true`) or remove (`paused: false`) the pause marker
/// at `AGENT_STATE_ROOT/<agent_name>/harness/paused`. Its presence parks
/// the agent's turn loop; the harness stats it in-container through the
/// harness bind-mount.
///
/// Required because hive-c0re runs unprivileged: the harness dir is
/// chowned to the agent user on first container boot (mode 0755), so
/// hive-core can stat the marker but cannot create or unlink it. Both
/// directions are idempotent — pausing an already-paused agent rewrites
/// an empty file, and resuming a running one treats `NotFound` as
/// success.
SetAgentPaused {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// `true` creates the marker, `false` removes it.
paused: bool,
},
/// Start / stop a hive infrastructure container on the host via
/// `systemctl <action> container@<container>.service`. The
/// [`InfraContainer`] enum is the allowlist — serde rejects unknown /
/// unsafe names (notably `hive-c0re`, which has no variant) at the wire
/// boundary, so no root-side `.contains()` check is needed. Serves the
/// hive-wide `hivectl stop` / `hivectl start` flow and the dashboard's
/// operator-only infra panel. No agent-facing path exists.
ControlInfraContainer {
container: InfraContainer,
action: InfraAction,
},
// --- Agent state subvolumes (btrfs) ---
/// Ensure the agent's persistent state root
/// (`<AGENT_STATE_ROOT>/<agent_name>`) is a btrfs subvolume — IF the
/// underlying filesystem is btrfs and the root doesn't already exist.
///
/// hive-priv derives the path from `agent_name` (never passed over the
/// wire), validates the name, then:
/// - path already exists (dir or subvol) → no-op (progressive: existing
/// agents are left exactly as they are, never auto-migrated);
/// - parent FS is not btrfs → no-op (hive-c0re's normal `create_dir_all`
/// makes a plain directory, the pre-subvolume behaviour);
/// - else → `btrfs subvolume create <path>` and chown it to the owner of
/// `AGENT_STATE_ROOT` (the `hive-core` user) so hive-c0re can create the
/// `state/` / `claude/` / `harness/` subdirs inside it as before.
///
/// Idempotent and safe to call on every provision. Requires root: btrfs
/// subvolume creation is privileged.
EnsureAgentSubvolume {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
/// Delete the agent's persistent state root if — and only if — it is a
/// btrfs subvolume. Called by hive-c0re on the **purge** path only
/// (never on a plain destroy, which keeps state for revival).
///
/// A subvolume root cannot be removed with `rmdir`/`remove_dir_all`, so
/// this routes through hive-priv to run `btrfs subvolume delete`. If the
/// path is a plain directory (pre-subvolume agent) or doesn't exist, it's
/// a no-op — hive-c0re's own `remove_dir_all` handles the plain-dir case.
/// hive-priv derives + validates the path the same way as
/// [`PrivRequest::EnsureAgentSubvolume`]. Requires root.
DeleteAgentSubvolume {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
// --- btrfs qgroup accounting + quota (operator opt-in) ---
/// Enable btrfs qgroup accounting on the filesystem holding
/// `AGENT_STATE_ROOT` (`btrfs quota enable <AGENT_STATE_ROOT>`).
/// Prerequisite for per-agent usage reads + quotas. **Operator
/// opt-in** — never run automatically: enabling triggers a full
/// rescan with real I/O cost on a large filesystem. Idempotent
/// (already-enabled is success); a no-op on non-btrfs (statfs gate).
/// Requires root.
EnsureBtrfsQuota,
/// Read an agent state subvolume's btrfs qgroup usage
/// (`btrfs qgroup show -f --raw <AGENT_STATE_ROOT>/<agent_name>`).
/// Returns the raw `qgroup show` row in stdout for hive-c0re to parse
/// (referenced + exclusive bytes). Errors with "quota not enabled" when
/// accounting is off — hive-c0re surfaces that gracefully. Requires root
/// (qgroup show on a subvolume needs `CAP_SYS_ADMIN`).
ReadSubvolumeUsage {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
/// Set (or clear) a btrfs qgroup size limit on an agent's state
/// subvolume (`btrfs qgroup limit <bytes|none> <…/agent_name>`).
/// `limit_bytes = Some(n)` caps referenced usage at `n` bytes;
/// `None` clears the limit (`none`). Requires quota enabled first
/// ([`PrivRequest::EnsureBtrfsQuota`]). Requires root.
SetSubvolumeQuota {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Byte cap on referenced usage; `None` clears the limit.
limit_bytes: Option<u64>,
},
// --- Agent disk usage ---
/// Measure an agent's on-disk footprint with one bounded
/// `du -s -x -b -c -P` over its `<AGENT_STATE_ROOT>/<agent_name>/state`
/// and `…/work` dirs and its container rootfs
/// (`/var/lib/nixos-containers/h-<agent_name>`). Returns the total in
/// bytes as a decimal string in `stdout`.
///
/// hive-priv derives all three paths from `agent_name`; the request
/// carries no path. A path that doesn't exist is skipped; an agent with
/// none of the three has no measurable size and gets an error. A timeout, a
/// non-zero `du` exit or unparseable output is an error, never a partial
/// or zero total. Requires root: `hive-core` cannot read the rootfs or
/// the agent-owned files under `state/` and `work/`.
MeasureAgentDisk {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
/// Convert an existing **plain-directory** agent state root into a btrfs
/// subvolume in place. The operator opt-in counterpart to the progressive
/// `EnsureAgentSubvolume` (which only ever makes *new* agents subvolumes):
/// it migrates an already-existing plain dir so the agent gains the
/// subvolume feature set (snapshots, per-subvol usage/quota, send/receive).
///
/// btrfs cannot promote a directory in place, so the helper does the move:
/// create a fresh subvolume, copy the dir's contents into it preserving
/// ownership/permissions/xattrs (`cp -a --reflink=auto`), then atomically
/// rename the original aside and the subvolume into place, and finally
/// remove the original. The caller (hivectl) MUST stop the agent first so
/// its state bind-mount is gone before the host dir moves, and restart it
/// after. Behaviour:
/// - path missing → error (nothing to upgrade);
/// - already a subvolume → no-op success (idempotent);
/// - parent FS not btrfs → error (subvolumes unsupported here);
/// - any failure before the final swap leaves the original dir untouched
/// (no half-migration). Requires root.
UpgradeAgentSubvolume {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
/// Create a read-only snapshot of an agent's state subvolume
/// (`btrfs subvolume snapshot -r <agent_root> <snapshot_path>`). Used as
/// the first step of inter-hive migration (`hivectl migrate`): freezing
/// a consistent point-in-time copy that `btrfs send` can stream from
/// while the source subvolume keeps running underneath the live agent.
///
/// The snapshot is created as a sibling of the agent's state root
/// (`<AGENT_STATE_ROOT>/.<agent_name>.snapshot.<snapshot_name>`, dot-prefixed
/// so it never collides with a real agent name) and its path is returned
/// verbatim in the response's `stdout`. Fails if the agent's state root
/// isn't a btrfs subvolume (nothing to snapshot) or a snapshot with the
/// same name already exists. Requires root.
SnapshotAgentSubvolume {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Snapshot label. Must start with `hive-` — the prefix doubles as
/// an allow-list hive-priv enforces so only hivectl-issued names
/// can reach the `btrfs subvolume snapshot` shellout — and
/// otherwise follows the same charset as a credential name
/// (non-empty `[A-Za-z0-9_-]`, no `.`); becomes part of the
/// snapshot path.
snapshot_name: String,
},
/// Delete a previously-created read-only snapshot
/// (`btrfs subvolume delete <snapshot_path>`). Cleanup counterpart to
/// [`PrivRequest::SnapshotAgentSubvolume`] — called once a migration's
/// `btrfs send` has completed (or aborted) and the frozen copy is no
/// longer needed. No-op if the snapshot path doesn't exist. Requires root.
DeleteAgentSnapshot {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Snapshot label, same validation as `SnapshotAgentSubvolume`.
snapshot_name: String,
},
/// Stream a previously-created read-only snapshot to a local file via
/// `btrfs send [-p <parent>] <snapshot> > <MIGRATE_STAGING_ROOT>/<dest_file_name>`.
/// The local-file half of the inter-hive migration transport: the
/// cross-hive leg (piping into `ssh <peer> btrfs receive`) is a later,
/// separate piece pending the auth/trust design — this variant is
/// useful standalone today as a point-in-time export/backup of a
/// snapshot (full send, no parent) or to validate the incremental
/// (`-p`) path locally before wiring up the network leg.
///
/// `dest_file_name` is a bare filename (not a path) under
/// `MIGRATE_STAGING_ROOT`, which hive-priv creates on first use.
/// Fails if the snapshot doesn't exist, `parent_snapshot_name` is given
/// but doesn't exist, or `dest_file_name` already exists (never
/// silently overwrites an export). Requires root.
SendAgentSnapshotToFile {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Snapshot label to send, same validation as `SnapshotAgentSubvolume`.
snapshot_name: String,
/// Optional parent snapshot label for an incremental
/// (`btrfs send -p`) send — must be an older read-only snapshot of
/// the same agent, still present on disk. `None` sends the full
/// snapshot.
parent_snapshot_name: Option<String>,
/// Bare filename (no path separators) for the exported stream,
/// written under `MIGRATE_STAGING_ROOT`. Same charset as a
/// credential name (`[A-Za-z0-9_-]`).
dest_file_name: String,
},
/// Stream a previously-created read-only snapshot into a file
/// descriptor the caller passes alongside this request (`SCM_RIGHTS`
/// ancillary data on the same socket): `btrfs send [-p <parent>]
/// <snapshot> >&<passed fd>`.
///
/// The network half of the inter-hive migration transport. hive-c0re
/// connects to the peer hive's snapshot store, writes the header
/// itself, and hands the **connected socket** over — so hive-priv
/// never learns an address, a protocol, or that a network is
/// involved, and nobody sits in the data path once the send starts
/// (which is what makes a multi-gigabyte transfer survive a
/// hive-c0re restart).
///
/// Exactly one descriptor must accompany this request. hive-priv
/// rejects the request if none arrived, if more than one did, or if a
/// descriptor arrives alongside any *other* operation — no guessing
/// when the caller didn't say. Requires root.
SendAgentSnapshotToFd {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Snapshot label to send, same validation as `SnapshotAgentSubvolume`.
snapshot_name: String,
/// Optional parent snapshot label for an incremental
/// (`btrfs send -p`) send — must be an older read-only snapshot of
/// the same agent, still present on disk. `None` sends the full
/// snapshot.
parent_snapshot_name: Option<String>,
},
/// Legacy: an older hive-c0re still sends this. hive-priv only unlinks
/// `/etc/tmpfiles.d/hyperhive-agents.conf` and returns `Ok`; the `agents`
/// payload that request carries is ignored on decode. Remove once no
/// deployed hive-c0re sends it, one release after `EnsureAgentSocketDir`.
SyncAgentTmpfiles,
}
/// Response from the privileged helper.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivResponse {
pub ok: bool,
#[serde(default)]
pub stdout: String,
#[serde(default)]
pub stderr: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
// ---------------------------------------------------------------------------
// Streaming protocol
// ---------------------------------------------------------------------------
/// Which output stream a `PrivStreamLine` came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivStream {
Stdout,
Stderr,
}
/// A single line forwarded from a streaming privileged operation.
/// Wire shape: `{"stream":"stdout","data":"..."}` — distinct from
/// `PrivResponse` (which has `ok` but not `stream`/`data`) so that
/// `PrivEvent` can disambiguate with `#[serde(untagged)]`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivStreamLine {
pub stream: PrivStream,
pub data: String,
}
/// An event in the streaming protocol used by long-running priv ops.
///
/// Wire format (multiple JSON lines over one connection):
/// - Zero or more `Line` events as the subprocess runs.
/// - One terminal `Done` event carrying the final status.
///
/// Non-streaming ops (and old hive-priv) send exactly one `Done` line,
/// which is wire-identical to a bare `PrivResponse` — so old `call()`
/// callers that deserialise straight to `PrivResponse` continue to work
/// with new hive-priv's terminal event.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PrivEvent {
/// A line of output from the running subprocess.
Line(PrivStreamLine),
/// Terminal event: the operation has finished.
Done(PrivResponse),
}
#[cfg(test)]
mod tests {
use super::{InfraContainer, InfraTarget, SIBLING_CONTAINERS};
#[test]
fn infra_control_allowlist_excludes_c0re_includes_matrix() {
// SIBLING_CONTAINERS is the authoritative allowlist for the
// requests that name a container as a string. hive-c0re must NEVER
// be in it — stopping the daemon would sever the socket the request
// arrived on.
assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re"));
// hive-matrix IS controllable (operator can stop/start/restart it).
assert!(SIBLING_CONTAINERS.contains(&"hive-matrix"));
}
#[test]
fn infra_container_enum_matches_sibling_containers() {
// The two lists are the same set *minus the things that aren't
// containers*: every `Container` variant must appear in
// SIBLING_CONTAINERS and vice versa, so a `nixos-container` verb can
// never be pointed at a name that has no machine behind it.
let mut from_enum: Vec<&str> = InfraContainer::ALL
.iter()
.filter_map(|c| match c.target() {
InfraTarget::Container(name) => Some(name),
InfraTarget::HostUnit(_) => None,
})
.collect();
from_enum.sort_unstable();
let mut from_slice: Vec<&str> = SIBLING_CONTAINERS.to_vec();
from_slice.sort_unstable();
assert_eq!(from_enum, from_slice);
// The gateway is the one that is not: a host unit, and absent from
// the container-name allowlist.
assert_eq!(
InfraContainer::Gateway.target(),
InfraTarget::HostUnit("nginx.service")
);
assert!(!SIBLING_CONTAINERS.contains(&"hive-gateway"));
// hive-c0re has no variant — unrepresentable, can't be controlled.
assert!("hive-c0re".parse::<InfraContainer>().is_err());
}
#[test]
fn infra_container_name_round_trips() {
// `name` is the single source of truth for the operator-facing form
// (FromStr keys off it), so a name→variant→name round-trip proves
// the mapping is consistent in both directions. It holds for the
// gateway too: the name is still recognised, it's the *permission*
// that differs (see below), not the parse.
for c in InfraContainer::ALL {
assert_eq!(c.name().parse::<InfraContainer>(), Ok(c));
}
}
#[test]
fn service_unit_wraps_containers_but_not_host_units() {
assert_eq!(
InfraContainer::Forge.service_unit(),
"container@hive-forge.service"
);
assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service");
}
}