//! 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, journal reads): 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 `-M` / `nixos-container` call. pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-ci"]; /// 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, Restart, } impl InfraAction { /// The `systemctl` subcommand this action maps to. pub fn systemctl_verb(self) -> &'static str { match self { InfraAction::Start => "start", InfraAction::Stop => "stop", InfraAction::Restart => "restart", } } } /// 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 (`"Ci"`, `"Forge"`, …); 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-ci`), and [`service_unit`](Self::service_unit) the systemd unit /// it actually resolves to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InfraContainer { Ci, 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` and /// readable with `journalctl -M `. Container(&'static str), /// A plain host unit. There is no machine to enter: no `-M` journal, /// 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; 4] = [ InfraContainer::Ci, InfraContainer::Forge, InfraContainer::Gateway, InfraContainer::Matrix, ]; /// Stable identity string, e.g. `hive-ci`. 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 `"Ci"`.) #[must_use] pub fn name(self) -> &'static str { match self { InfraContainer::Ci => "hive-ci", 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(), } } /// Whether an agent holding `infra_admin` may restart this target. /// /// The gateway is excluded by operator ruling: nginx now fronts every /// hive service from the host, so an agent restarting it can take the /// forge, dashboard and matrix down with it — including the path its /// own PR would have to travel to fix it. The operator surface /// (`hivectl`, dashboard) is unaffected. #[must_use] pub fn agent_restartable(self) -> bool { !matches!(self, InfraContainer::Gateway) } } impl std::str::FromStr for InfraContainer { type Err = (); /// Parse an infra name (`hive-ci`, …) into a variant. Recognition /// only — it says the name denotes a hive service, *not* that the /// caller may act on it. The agent restart path additionally checks /// [`agent_restartable`](InfraContainer::agent_restartable). /// `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 (regenerated each boot /// by `hive-priv` tmpfiles.d; not persistent). Used by `hive-priv` when /// creating per-agent subdirs via `nsenter` / tmpfiles. /// 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"; /// Output format for `ReadContainerJournal`. Maps to journalctl /// `--output=<...>`. Restricted to the two formats hive callers use so /// the wire type can't smuggle an arbitrary `--output` value. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum JournalOutput { /// `short` — the journalctl default (syslog-style timestamps). #[default] Short, /// `short-iso` — ISO 8601 timestamps. ShortIso, } impl JournalOutput { /// The string journalctl expects after `--output=`. #[must_use] pub fn as_journalctl(self) -> &'static str { match self { JournalOutput::Short => "short", JournalOutput::ShortIso => "short-iso", } } } /// journalctl knobs for `ReadContainerJournal`. Grouped into one value so /// the read-journal call chain (`priv_client::read_container_journal` → /// hive-priv's executor) and its several hive-c0re callers pass a single /// struct instead of eight positional args that travelled together 1:1. /// `Default` is the common case (last N lines, short format, no filters); /// callers fill `lines` and override only the knobs they need via /// `..Default::default()`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct JournalQuery { /// `-n `. pub lines: u32, /// `-b` — restrict to the current boot. #[serde(default)] pub boot: bool, /// `--output=<...>`. #[serde(default)] pub output: JournalOutput, /// `-u `. #[serde(default)] pub unit: Option, /// `-p `. #[serde(default)] pub priority: Option, /// `--grep=`. #[serde(default)] pub grep: Option, /// `--since=`. #[serde(default)] pub since: Option, /// `--until=`. #[serde(default)] pub until: Option, } /// 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`. When `Some`, /// hive-priv writes `PRIVATE_NETWORK=1` + veth bridge wiring instead /// of the default `PRIVATE_NETWORK=0`. Containers receive their IP /// dynamically via DHCP from the bridge dnsmasq pool (`networking.useDHCP` /// in `harness-base.nix`); no static address is pre-assigned here. #[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, so it's also written into the container's /// `/etc/resolv.conf` (see the isolated-DNS oneshot in `harness-base.nix`, /// gated on the marker hive-priv drops). pub gateway_ip: String, } /// 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, // --- Container journal reads --- /// Read a container's journal via `journalctl -M `. /// Requires root: the machine-bus transport enters the container's /// namespace, so this can't run from the unprivileged hive-c0re /// process. hive-priv validates `container` against the managed- /// container allowlist, then runs journalctl and returns its output. /// /// The filters (`unit` / `priority` / `grep` / `since` / `until`) /// are applied within the already-authorized machine and passed to /// journalctl as plain argument values; they can't widen access /// beyond the validated `container`. ReadContainerJournal { /// System container name (`h-` or a sibling service). container: String, /// journalctl knobs (see [`JournalQuery`]). query: JournalQuery, }, // --- 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`. /// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring; /// when `None`, writes `PRIVATE_NETWORK=0`. WriteNspawnFlags { container: String, binds: Vec, /// `None` = host netns (`PRIVATE_NETWORK=0`). `Some` = private netns with /// veth on the specified bridge (`PRIVATE_NETWORK=1`). #[serde(default)] isolation: Option, /// 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, }, /// 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, }, // --- Agent credential writes --- /// Write `forge-token` into `AGENT_STATE_ROOT//state/forge-token`. /// /// hive-priv validates `agent_name`, creates the state dir if absent, /// writes the file 0600, and chowns it to the state dir's owner so /// the agent process can read it. Required because hive-c0re runs /// unprivileged and cannot write to agent-owned state directories. WriteAgentForgeToken { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, /// Token value. hive-priv appends a trailing newline before writing. token: String, }, /// Write a matrix access token into the agent's state dir. With /// `account: None` it targets the hive-internal `matrix-token`; with /// `account: Some(name)` it targets `matrix-token-` for an extra /// (external) account. hive-priv validates both `agent_name` and the /// `account` suffix as plain identifiers before building the path, so a /// crafted account name cannot traverse out of the state dir. /// /// Same write semantics as `WriteAgentForgeToken` — validates names, /// creates dir, writes 0600, chowns to agent owner. WriteAgentMatrixToken { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, /// Token value. hive-priv appends a trailing newline before writing. token: String, /// Extra-account suffix. `None` → `matrix-token` (the hive account); /// `Some(name)` → `matrix-token-` (validated as a plain ident). account: Option, /// Homeserver URL for an extra account. When `Some` (only meaningful /// alongside `account: Some`), hive-priv also writes the sidecar /// `matrix-account-.json` (`{"homeserver": }`, 0600, /// chowned to the agent) so the daemon can auto-discover the account /// without a config declaration. `None` → no sidecar written. #[serde(default)] homeserver: Option, }, /// Write `github-token` into `AGENT_STATE_ROOT//state/github-token`. /// /// The operator-supplied GitHub personal access token (PAT) for the /// agent's GitHub integration (`hyperhive.github.enable`). Same write /// semantics as /// `WriteAgentForgeToken` — validates `agent_name`, creates the state dir /// if absent, writes the file 0600, and chowns it to the agent so the /// `gh` wrapper / git credential helper can read it. No account suffix /// (single GitHub account per agent). WriteAgentGithubToken { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, /// PAT value. hive-priv appends a trailing newline before writing. token: String, }, /// Write a per-agent account for an external, dashboard-declared forge: /// the access token to /// `AGENT_STATE_ROOT//state/forge-