feat(#702): drop hive-c0re from root to hive-core user (privsep phase 2)

This commit is contained in:
damocles 2026-06-02 22:14:07 +02:00 committed by mara
commit 0bec3af933
2 changed files with 128 additions and 261 deletions

View file

@ -3,6 +3,7 @@
use std::path::Path;
use anyhow::{Context, Result, bail};
use hive_sh4re::priv_proto::BindMount;
use tokio::process::Command;
/// Sub-agent container prefix. `nixos-container` caps the total container name
@ -206,12 +207,11 @@ pub async fn spawn(
)
.await?;
let container = container_name(name);
let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display());
run(&["create", &container, "--flake", &flake_ref]).await?;
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
set_resource_limits(&container)?;
priv_run("create", name).await?;
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?;
set_resource_limits(&container).await?;
systemd_daemon_reload().await?;
run(&["start", &container]).await
priv_run("start", name).await
}
/// Build the `AgentSpec` list for the meta flake from `nixos-container
@ -280,14 +280,12 @@ pub async fn container_exists(name: &str) -> bool {
pub async fn kill(name: &str) -> Result<()> {
validate(name)?;
let container = container_name(name);
run(&["stop", &container]).await
priv_run("stop", name).await
}
pub async fn start(name: &str) -> Result<()> {
validate(name)?;
let container = container_name(name);
run(&["start", &container]).await
priv_run("start", name).await
}
/// Stop + start without regenerating any config. For "kick the container"
@ -317,12 +315,12 @@ pub async fn destroy(name: &str) -> Result<()> {
let container = container_name(name);
// nixos-container destroy handles stop + removal of /var/lib/nixos-containers/<C>
// and /etc/nixos-containers/<C>.conf. Tolerate "no such container".
if let Err(e) = run(&["destroy", &container]).await {
if let Err(e) = priv_run("destroy", name).await {
tracing::warn!(error = ?e, "nixos-container destroy returned an error; continuing cleanup");
}
let dropin_dir = format!("/run/systemd/system/container@{container}.service.d");
if std::path::Path::new(&dropin_dir).exists() {
std::fs::remove_dir_all(&dropin_dir).with_context(|| format!("remove {dropin_dir}"))?;
// Remove the systemd resource-limits drop-in via hive-priv.
if let Err(e) = crate::priv_client::remove_service_dropin(&container).await {
tracing::warn!(error = ?e, "remove service drop-in failed (non-fatal)");
}
Ok(())
}
@ -399,43 +397,43 @@ pub async fn rebuild_no_meta(
// Rebuild strategy: stop-before-update + pre-build.
// See `docs/coordinator.md::Container lifecycle`.
let was_running = is_running(name).await;
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
set_resource_limits(&container)?;
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?;
set_resource_limits(&container).await?;
systemd_daemon_reload().await?;
if was_running {
on_step("nix build");
prebuild_toplevel(name, &flake_ref).await?;
on_step("nixos-container stop");
run(&["stop", &container]).await?;
priv_run("stop", name).await?;
}
on_step("nixos-container update");
run(&["update", &container, "--flake", &flake_ref]).await?;
priv_run("update", name).await?;
if was_running {
// Cold-start fallback on activation errors.
// See `docs/coordinator.md::Cold-start fallback`.
on_step("nixos-container start");
if let Err(start_err) = run(&["start", &container]).await {
if let Err(start_err) = priv_run("start", name).await {
tracing::warn!(
container = %container,
error = %start_err,
"start after rebuild failed (possible activation error); \
retrying via stop + kill + start"
);
run(&["stop", &container]).await.unwrap_or_else(|e| {
priv_run("stop", name).await.unwrap_or_else(|e| {
tracing::warn!(
container = %container,
error = %e,
"stop before cold-start retry failed (ignored)"
);
});
run(&["kill", &container]).await.unwrap_or_else(|e| {
priv_run("kill", name).await.unwrap_or_else(|e| {
tracing::warn!(
container = %container,
error = %e,
"kill before cold-start retry failed (ignored)"
);
});
run(&["start", &container]).await.map_err(|e| {
priv_run("start", name).await.map_err(|e| {
anyhow::anyhow!(
"cold-start fallback also failed: {e:#} \
(original start error: {start_err:#})"
@ -451,12 +449,12 @@ pub async fn rebuild_no_meta(
// Spawn path: create is atomic, no prebuild needed.
// See `docs/coordinator.md::Spawn path`.
on_step("nixos-container create");
run(&["create", &container, "--flake", &flake_ref]).await?;
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
set_resource_limits(&container)?;
priv_run("create", name).await?;
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?;
set_resource_limits(&container).await?;
systemd_daemon_reload().await?;
on_step("nixos-container start");
run(&["start", &container]).await
priv_run("start", name).await
}
}
@ -570,21 +568,8 @@ async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> {
}
pub async fn list() -> Result<Vec<String>> {
let out = Command::new("nixos-container")
.arg("list")
.output()
.await
.context("invoke nixos-container list")?;
if !out.status.success() {
bail!(
"nixos-container list exited with status {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
let stdout = crate::priv_client::list_containers().await?;
Ok(stdout.lines().map(str::trim)
.filter(|line| line.starts_with(AGENT_PREFIX))
.map(str::to_owned)
.collect())
@ -983,36 +968,12 @@ pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<(
/// Write a systemd drop-in for `container@<container>.service` that applies
/// our default resource caps. Goes under `/run/systemd/system/...` so it's
/// ephemeral (regenerated on every spawn / rebuild).
fn set_resource_limits(container: &str) -> Result<()> {
let dir = format!("/run/systemd/system/container@{container}.service.d");
std::fs::create_dir_all(&dir).with_context(|| format!("create {dir}"))?;
let path = format!("{dir}/hyperhive-limits.conf");
let content =
format!("[Service]\nMemoryMax={DEFAULT_MEMORY_MAX}\nCPUQuota={DEFAULT_CPU_QUOTA}\n");
std::fs::write(&path, content).with_context(|| format!("write {path}"))?;
tracing::info!(
%path,
memory_max = DEFAULT_MEMORY_MAX,
cpu_quota = DEFAULT_CPU_QUOTA,
"wrote resource limits drop-in"
);
Ok(())
async fn set_resource_limits(container: &str) -> Result<()> {
crate::priv_client::write_resource_limits(container, DEFAULT_MEMORY_MAX, DEFAULT_CPU_QUOTA).await
}
async fn systemd_daemon_reload() -> Result<()> {
let out = Command::new("systemctl")
.arg("daemon-reload")
.output()
.await
.context("invoke systemctl daemon-reload")?;
if !out.status.success() {
bail!(
"systemctl daemon-reload failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
crate::priv_client::daemon_reload().await
}
/// Idempotently rewrite the lines in `/etc/nixos-containers/<container>.conf`
@ -1052,35 +1013,28 @@ const HOST_SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
/// `binds`. All three are RW so the parent can read/write state and
/// submit config-change requests. Creates missing host-side directories
/// so nspawn doesn't refuse to start; missing dirs are non-fatal.
fn bind_child_agent_dirs(child: &str, binds: &mut String) {
use std::fmt::Write as _;
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let state_dir = format!("{HOST_AGENTS_ROOT}/{child}/state");
let harness_dir = format!("{HOST_AGENTS_ROOT}/{child}/harness");
let config_dir = format!("{HOST_AGENTS_ROOT}/{child}/config");
for dir in [&state_dir, &harness_dir, &config_dir] {
let _ = std::fs::create_dir_all(dir);
}
let _ = write!(binds, " --bind={state_dir}:/agents/{child}/state");
let _ = write!(binds, " --bind={harness_dir}:/agents/{child}/harness");
let _ = write!(binds, " --bind={config_dir}:/agents/{child}/config");
binds.push(BindMount { host_path: state_dir, container_path: format!("/agents/{child}/state"), read_only: false });
binds.push(BindMount { host_path: harness_dir, container_path: format!("/agents/{child}/harness"), read_only: false });
binds.push(BindMount { host_path: config_dir, container_path: format!("/agents/{child}/config"), read_only: false });
}
#[allow(clippy::too_many_lines)]
fn set_nspawn_flags(
async fn set_nspawn_flags(
container: &str,
runtime_dir: &Path,
claude_dir: &Path,
notes_dir: &Path,
) -> Result<()> {
use std::fmt::Write as _;
// Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist.
std::fs::create_dir_all(HOST_SHARED_ROOT)
.with_context(|| format!("create {HOST_SHARED_ROOT}"))?;
let path = format!("/etc/nixos-containers/{container}.conf");
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
// Logical agent name — strip the `h-` prefix.
// For the manager: `h-ruth` → `ruth`. For sub-agents: `h-iris` → `iris`.
let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container);
@ -1092,37 +1046,26 @@ fn set_nspawn_flags(
// is needed here — the bind alone is enough.
let claude_mount = container_claude_mount(agent_name);
let mut binds = format!(
"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{claude_mount} --bind={shared}:{CONTAINER_SHARED_MOUNT}",
runtime = runtime_dir.display(),
claude = claude_dir.display(),
shared = HOST_SHARED_ROOT,
);
let mut binds: Vec<BindMount> = vec![
BindMount { host_path: runtime_dir.to_string_lossy().into_owned(), container_path: CONTAINER_RUNTIME_MOUNT.to_owned(), read_only: false },
BindMount { host_path: claude_dir.to_string_lossy().into_owned(), container_path: claude_mount, read_only: false },
BindMount { host_path: HOST_SHARED_ROOT.to_owned(), container_path: CONTAINER_SHARED_MOUNT.to_owned(), read_only: false },
];
// Own state, harness, and config dirs — same for every agent including
// root. Config is RO: an agent must not edit its own config; changes
// the manager. Config is RO: an agent must not edit its own config; changes
// only ever flow through the approval queue.
{
let _ = write!(
binds,
" --bind={notes}:/agents/{agent_name}/state",
notes = notes_dir.display(),
);
if let Some(state_parent) = notes_dir.parent() {
let harness_dir = state_parent.join("harness");
if !harness_dir.exists() {
let _ = std::fs::create_dir_all(&harness_dir);
}
let _ = write!(
binds,
" --bind={harness}:/agents/{agent_name}/harness",
harness = harness_dir.display(),
);
binds.push(BindMount { host_path: notes_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/state"), read_only: false });
if let Some(state_parent) = notes_dir.parent() {
let harness_dir = state_parent.join("harness");
if !harness_dir.exists() {
let _ = std::fs::create_dir_all(&harness_dir);
}
let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?;
let _ = write!(binds, " --bind-ro={own_config}:/agents/{agent_name}/config");
binds.push(BindMount { host_path: harness_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/harness"), read_only: false });
}
let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?;
binds.push(BindMount { host_path: own_config, container_path: format!("/agents/{agent_name}/config"), read_only: true });
// Topology-driven child mounts: every direct child of this agent gets
// its state, harness, and config dirs bind-mounted RW so the parent
@ -1153,16 +1096,10 @@ fn set_nspawn_flags(
// fires first (e.g. cold start with no agents).
std::fs::create_dir_all(HOST_META_ROOT)
.with_context(|| format!("create {HOST_META_ROOT}"))?;
let _ = write!(
binds,
" --bind-ro={HOST_APPLIED_ROOT}:{CONTAINER_MANAGER_APPLIED_MOUNT}",
);
let _ = write!(
binds,
" --bind-ro={HOST_META_ROOT}:{mount}",
mount = crate::meta::CONTAINER_MANAGER_META_MOUNT,
);
binds.push(BindMount { host_path: HOST_APPLIED_ROOT.to_owned(), container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(), read_only: true });
binds.push(BindMount { host_path: HOST_META_ROOT.to_owned(), container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(), read_only: true });
}
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
// container so the harness can bind `web.sock` there and the host-side
// gateway sees it. Subdir bind (not socket file) keeps the inode
@ -1175,159 +1112,74 @@ fn set_nspawn_flags(
// Falls back to 0777 on first spawn when uid lookup returns None
// (container /etc/passwd not yet rendered).
if let Some((uid, gid)) = agent_uid_gid(agent_name) {
std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid))
.with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?;
} else {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777))
.with_context(|| format!("chmod 0777 {}", socket_dir.display()))?;
if let Err(e) = crate::priv_client::chown_socket_dir(agent_name, uid, gid).await {
tracing::warn!(%agent_name, error = ?e, "chown socket dir failed");
}
} else if let Err(e) = crate::priv_client::chmod_socket_dir(agent_name, 0o777).await {
tracing::warn!(%agent_name, error = ?e, "chmod socket dir failed");
}
let _ = write!(
binds,
" --bind={socket_dir}:{socket_dir}",
socket_dir = socket_dir.display(),
);
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
let mut lines: Vec<String> = original
.lines()
.filter(|line| {
let trimmed = line.trim_start();
// Strip any network-namespace knobs nixos-container's create
// might have populated. The start script adds `--network-veth`
// whenever HOST_ADDRESS / LOCAL_ADDRESS (or their IPv6 cousins)
// are non-empty — and veth implies a private netns, hiding our
// web-UI port from the host. Force host netns.
!trimmed.starts_with("EXTRA_NSPAWN_FLAGS=")
&& !trimmed.starts_with("PRIVATE_NETWORK=")
&& !trimmed.starts_with("HOST_ADDRESS=")
&& !trimmed.starts_with("LOCAL_ADDRESS=")
&& !trimmed.starts_with("HOST_ADDRESS6=")
&& !trimmed.starts_with("LOCAL_ADDRESS6=")
&& !trimmed.starts_with("HOST_BRIDGE=")
})
.map(str::to_owned)
.collect();
lines.push("PRIVATE_NETWORK=0".to_owned());
lines.push("HOST_ADDRESS=".to_owned());
lines.push("LOCAL_ADDRESS=".to_owned());
lines.push("HOST_ADDRESS6=".to_owned());
lines.push("LOCAL_ADDRESS6=".to_owned());
lines.push("HOST_BRIDGE=".to_owned());
lines.push(bind_flag);
let mut content = lines.join("\n");
content.push('\n');
std::fs::write(&path, content).with_context(|| format!("write {path}"))?;
tracing::info!(%path, "set PRIVATE_NETWORK=0 + EXTRA_NSPAWN_FLAGS");
Ok(())
binds.push(BindMount { host_path: socket_dir.to_string_lossy().into_owned(), container_path: socket_dir.to_string_lossy().into_owned(), read_only: false });
// Delegate the actual conf-file rewrite to hive-priv (runs as root).
crate::priv_client::write_nspawn_flags(container, &binds).await
}
/// Spawn `nixos-container <args>` and pipe its stdout + stderr into
/// `tracing` one line at a time so a long-running command (most
/// notably `update`, which kicks off a full nix build that can run
/// for minutes on a stale flake) shows progress in journald as it
/// happens. The buffered `.output()` we used before only flushed the
/// summary at exit, which made "slow" and "stuck" look identical to
/// the operator watching `journalctl -u hive-c0re -f`.
///
/// stdout lines log at INFO, stderr at WARN. The same lines are
/// captured per-attempt into `build_logs.sqlite` so the dashboard
/// can surface the full stream to the operator; on failure we bail
/// with a `see build log #<id>` pointer instead of the legacy
/// 32-line ring-buffer tail that routinely truncated eval errors.
async fn run(args: &[&str]) -> Result<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let cmdline = args.join(" ");
// Convention: `nixos-container <verb> <container> ...` — the
// verb is `args[0]` (kind) and the container is `args[1]`
// (h-<name> | root | hive-matrix | ...) for every long-running
// case we care about. Strip the `h-` prefix for sub-agents so the
// build_logs row's `agent` column matches the agent's bare name
// (`alice` rather than `h-alice`) — that's what the dashboard
// groups by. Manager + sibling containers pass through as-is.
let kind = args.first().copied().unwrap_or("nixos-container");
let agent = args
.get(1)
.copied()
.map_or_else(|| "<unknown>".to_string(), |c| c.strip_prefix(AGENT_PREFIX).unwrap_or(c).to_string());
/// Execute a container operation via hive-priv and integrate with
/// build_logs.sqlite. hive-priv runs as root and logs output to
/// journald as it arrives; this function captures the final stdout +
/// stderr into build_logs for the dashboard after the operation
/// completes. For `create` and `update` (the long-running ops)
/// hive-priv already logs each line to its own journald stream —
/// streaming into build_logs is deferred to a follow-up that adds a
/// streaming mode to the priv protocol.
async fn priv_run(kind: &str, name: &str) -> Result<()> {
let container = container_name(name);
let cmdline = format!("nixos-container {kind} {container}");
let logs = crate::build_logs::global();
let log_id = logs.as_ref().and_then(|h| {
h.start(&agent, kind, &cmdline)
h.start(name, kind, &cmdline)
.map_err(|e| {
tracing::warn!(error = ?e, "build_logs: start failed (nixos-container log dropped)");
tracing::warn!(error = ?e, "build_logs: start failed (priv_run log dropped)");
})
.ok()
});
let mut child = Command::new("nixos-container")
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.with_context(|| format!("invoke nixos-container {cmdline}"))?;
let result: Result<(String, String)> = match kind {
"create" => crate::priv_client::create_container(name).await,
"update" => crate::priv_client::update_container(name).await,
"start" => crate::priv_client::start_container(name).await.map(|()| (String::new(), String::new())),
"stop" => crate::priv_client::stop_container(name).await.map(|()| (String::new(), String::new())),
"kill" => crate::priv_client::kill_container(name).await.map(|()| (String::new(), String::new())),
"destroy" => crate::priv_client::destroy_container(name).await.map(|()| (String::new(), String::new())),
other => Err(anyhow::anyhow!("unknown container op: {other}")),
};
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let stdout_cmdline = cmdline.clone();
let stdout_logs = logs.clone();
let pump_stdout = tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::info!(target: "nixos-container", cmdline = %stdout_cmdline, "{line}");
if let (Some(h), Some(id)) = (&stdout_logs, log_id) {
h.append_stdout(id, &line);
}
}
});
let stderr_cmdline = cmdline.clone();
let stderr_logs = logs.clone();
let pump_stderr = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!(target: "nixos-container", cmdline = %stderr_cmdline, "{line}");
if let (Some(h), Some(id)) = (&stderr_logs, log_id) {
h.append_stderr(id, &line);
}
}
});
let status = child
.wait()
.await
.with_context(|| format!("wait nixos-container {cmdline}"))?;
let _ = pump_stdout.await;
let _ = pump_stderr.await;
let ok = status.success();
let ok = result.is_ok();
if let (Some(h), Some(id)) = (&logs, log_id) {
h.finish(
id,
if ok {
crate::build_logs::BuildStatus::Ok
} else {
crate::build_logs::BuildStatus::Fail
},
);
if let Ok((ref stdout, ref stderr)) = result {
for line in stdout.lines() {
tracing::info!(target: "nixos-container", cmdline = %cmdline, "{line}");
h.append_stdout(id, line);
}
for line in stderr.lines() {
tracing::warn!(target: "nixos-container", cmdline = %cmdline, "{line}");
h.append_stderr(id, line);
}
}
h.finish(id, if ok { crate::build_logs::BuildStatus::Ok } else { crate::build_logs::BuildStatus::Fail });
}
if !ok {
// `container_journal_tail` is best-effort + only fires on
// `update`; the captured build log holds the full host-side
// stderr regardless, so the bail message can stay terse: a
// pointer to the log id + the journal tail (when available)
// is enough for the operator to drill in without flooding
// every notification with the eval-error verbatim.
let journal = container_journal_tail(args).await;
match log_id {
Some(id) => {
bail!("nixos-container {cmdline} failed ({status}); see build log #{id}{journal}")
match result {
Ok(_) => Ok(()),
Err(e) => {
let journal = if kind == "update" { container_journal_tail(&container).await } else { String::new() };
match log_id {
Some(id) => bail!("{e:#}; see build log #{id}{journal}"),
None => bail!("{e:#}{journal}"),
}
None => bail!("nixos-container {cmdline} failed ({status}){journal}"),
}
}
Ok(())
}
/// On a failed `nixos-container update`, the stderr nixos-container
@ -1342,13 +1194,7 @@ async fn run(args: &[&str]) -> Result<()> {
/// `journalctl -M` works. Best-effort — returns "" for other verbs
/// or when the journal can't be read (machine gone, journalctl
/// missing); it never produces an error of its own.
async fn container_journal_tail(args: &[&str]) -> String {
if args.first().copied() != Some("update") {
return String::new();
}
let Some(container) = args.get(1) else {
return String::new();
};
async fn container_journal_tail(container: &str) -> String {
let out = Command::new("journalctl")
.args(["-M", container, "-n", "40", "--no-pager", "--output=short"])
.output()

View file

@ -315,6 +315,17 @@ in
managerToplevel
];
# Unprivileged coordinator user. hive-c0re runs as this user
# (privsep phase 2); privileged operations are delegated to
# hive-priv which runs as root, socket-activated at
# /run/hive/priv.sock.
users.users.hive-core = {
isSystemUser = true;
group = "hive-core";
description = "hive-c0re coordinator daemon user";
};
users.groups.hive-core = { };
# Open the per-agent web-port range when the gateway is *off* —
# otherwise the gateway nginx is the sole external entry point.
# See `docs/gateway.md::Firewall posture (host-level)`.
@ -412,12 +423,22 @@ in
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake ${cfg.nixpkgsFlake} --nixpkgs-unstable-flake ${cfg.nixpkgsUnstableFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}";
# One-time migration: chown existing state tree to the service
# user after upgrading from a root-run install. The `+` prefix
# runs this step as root even though User = hive-core. systemd's
# StateDirectory chowns the top-level dir at every start, but
# pre-existing files inside may still be root-owned.
ExecStartPre = "+${pkgs.coreutils}/bin/sh -c 'chown -R hive-core:hive-core /var/lib/hyperhive || true'";
Restart = "on-failure";
RestartSec = 2;
User = "hive-core";
Group = "hive-core";
SupplementaryGroups = [ "systemd-journal" ];
RuntimeDirectory = "hyperhive";
RuntimeDirectoryMode = "0750";
RuntimeDirectoryPreserve = "yes";
StateDirectory = "hyperhive";
StateDirectoryMode = "0750";
};
};
@ -451,18 +472,18 @@ in
# privileged operations on behalf of hive-c0re. Systemd creates and
# holds `/run/hive/priv.sock` before the first connection arrives.
#
# Mode 0660 root:root is correct for phase 1 (hive-c0re still runs as
# root and is the only caller). Phase 2 (privsep: hive-c0re drops to a
# non-root user) will add `SocketGroup = hive-core` so the unprivileged
# hive-c0re process can still connect.
# Mode 0660 hive-core:hive-core: only the hive-c0re service user can
# connect. hive-priv (server) runs as root and validates every request
# against a strict allowlist before executing any privileged op.
systemd.sockets.hive-priv = {
description = "hive-priv privileged helper socket";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/hive/priv.sock";
SocketMode = "0660";
# Create /run/hive/ if absent; 0755 so future unprivileged callers
# can traverse into it to reach the socket.
SocketGroup = "hive-core";
# Create /run/hive/ if absent; 0755 so the hive-core user can
# traverse into it to reach the socket.
DirectoryMode = "0755";
};
};