//! 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-`. 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 `, where the unit comes from /// [`InfraContainer::service_unit`] — usually `container@.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@.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::ALL.into_iter().find(|c| c.name() == s).ok_or(()) } } /// Host path of the meta flake. The flake ref for agent `` 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: `//state/`. /// 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 `` 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=:` (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=:` so systemd-nspawn /// loads the host secret file into the container's credential store; an /// inner unit then reads it via `LoadCredential=` (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=:`. 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 `) /// 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 ` StartContainer { name: String }, /// `nixos-container stop ` StopContainer { name: String }, /// `machinectl kill --signal=SIGKILL` — force-kills all processes /// in the container. nixos-container has no kill verb. KillContainer { name: String }, /// `nixos-container update --flake ` /// 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 --flake ` /// 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 ` DestroyContainer { name: String }, /// `nixos-container list` ListContainers, // --- Config file writes --- /// Update `/etc/nixos-containers/.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, /// 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=:`. 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, }, /// Create the agent's socket dir `/run/hive-agent/` 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@.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, /// cgroup v2 `io.weight`, 1..=10000. Same `None` = omit rule as /// `cpu_weight`. #[serde(default)] io_weight: Option, }, /// Remove `/run/systemd/system/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 ` 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` 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, }, // --- 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//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 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 /// (`/`) 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 ` 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 `). /// 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 /`). /// 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 <…/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, }, // --- Agent disk usage --- /// Measure an agent's on-disk footprint with one bounded /// `du -s -x -b -c -P` over its `//state` /// and `…/work` dirs and its container rootfs /// (`/var/lib/nixos-containers/h-`). 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 `). 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 /// (`/..snapshot.`, 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 `). 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 ] > /`. /// The local-file half of the inter-hive migration transport: the /// cross-hive leg (piping into `ssh 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, /// 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 ] /// >&`. /// /// 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, }, /// 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, } // --------------------------------------------------------------------------- // 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::().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::(), 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"); } }