hyperhive/hive-priv/src/main.rs

409 lines
15 KiB
Rust

//! Minimal privileged helper for hive-c0re.
//!
//! Runs as root. Exposes a narrow unix socket at `/run/hive/priv.sock`
//! that accepts `PrivRequest` JSON lines and executes only the
//! operations that genuinely require root. All coordination logic,
//! broker, HTTP, and scheduling stay in the unprivileged hive-c0re
//! process.
//!
//! **Security model**: every request is validated against a strict
//! container-name allowlist before any filesystem or process operation.
//! Only containers whose names match the hive convention (`h-*`,
//! the manager container, or known sibling service containers) are
//! accepted. Every variant maps to a single known operation — no
//! arbitrary command pass-through.
//!
//! **Socket activation**: when systemd passes the listener socket via
//! `LISTEN_FDS=1` + `LISTEN_PID=<self>`, the inherited fd 3 is used
//! instead of binding a fresh socket.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use hive_sh4re::priv_proto::{AGENT_PREFIX, MANAGER_NAME, META_DIR, PRIV_SOCK, SIBLING_CONTAINERS, BindMount, PrivRequest, PrivResponse};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::process::Command;
/// Root of the per-agent unix-socket dirs on the host.
const SOCKET_DIR_ROOT: &str = "/run/hive-agent";
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let listener = socket_listener()?;
tracing::info!("hive-priv listening");
loop {
match listener.accept().await {
Ok((stream, _)) => {
tokio::spawn(handle(stream));
}
Err(e) => {
tracing::error!(error = %e, "accept failed");
}
}
}
}
fn socket_listener() -> Result<UnixListener> {
// Socket activation: systemd passes the socket as fd 3 when
// LISTEN_FDS >= 1 and LISTEN_PID matches our pid.
let listen_fds: Option<i32> = std::env::var("LISTEN_FDS")
.ok()
.and_then(|s| s.parse().ok());
let listen_pid: Option<u32> = std::env::var("LISTEN_PID")
.ok()
.and_then(|s| s.parse().ok());
if let (Some(n), Some(p)) = (listen_fds, listen_pid) {
if n >= 1 && p == std::process::id() {
// SAFETY: systemd has passed us a ready UnixListener on fd 3.
let std_listener = unsafe {
use std::os::unix::io::FromRawFd;
std::os::unix::net::UnixListener::from_raw_fd(3)
};
std_listener
.set_nonblocking(true)
.context("set socket non-blocking")?;
let listener =
tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?;
tracing::info!("using systemd-activated socket");
return Ok(listener);
}
}
// Fallback: bind the socket ourselves.
let path = Path::new(PRIV_SOCK);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?;
// Mode 0660: only the hive-core group can connect.
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
.context("chmod priv.sock")?;
tracing::info!(path = PRIV_SOCK, "bound priv socket");
Ok(listener)
}
async fn handle(stream: UnixStream) {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
let resp = dispatch(&line).await;
let mut json = serde_json::to_string(&resp).unwrap_or_else(|e| {
format!("{{\"ok\":false,\"stdout\":\"\",\"stderr\":\"\",\"error\":\"serialise failed: {e}\"}}")
});
json.push('\n');
if let Err(e) = writer.write_all(json.as_bytes()).await {
tracing::warn!(error = %e, "write response failed");
break;
}
}
}
async fn dispatch(line: &str) -> PrivResponse {
match serde_json::from_str::<PrivRequest>(line) {
Ok(req) => match exec(req).await {
Ok((stdout, stderr)) => PrivResponse {
ok: true,
stdout,
stderr,
error: None,
},
Err(e) => PrivResponse {
ok: false,
stdout: String::new(),
stderr: String::new(),
error: Some(format!("{e:#}")),
},
},
Err(e) => PrivResponse {
ok: false,
stdout: String::new(),
stderr: String::new(),
error: Some(format!("parse request: {e}")),
},
}
}
/// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success.
async fn exec(req: PrivRequest) -> Result<(String, String)> {
match req {
PrivRequest::StartContainer { ref name } => {
validate_container_name(name)?;
container_run(&["start", &container_system_name(name)]).await
}
PrivRequest::StopContainer { ref name } => {
validate_container_name(name)?;
container_run(&["stop", &container_system_name(name)]).await
}
PrivRequest::KillContainer { ref name } => {
validate_container_name(name)?;
container_run(&["kill", &container_system_name(name)]).await
}
PrivRequest::UpdateContainer { ref name } => {
validate_container_name(name)?;
let flake_ref = agent_flake_ref(name);
container_run(&["update", &container_system_name(name), "--flake", &flake_ref]).await
}
PrivRequest::CreateContainer { ref name } => {
validate_container_name(name)?;
let flake_ref = agent_flake_ref(name);
container_run(&["create", &container_system_name(name), "--flake", &flake_ref]).await
}
PrivRequest::DestroyContainer { ref name } => {
validate_container_name(name)?;
container_run(&["destroy", &container_system_name(name)]).await
}
PrivRequest::ListContainers => container_run(&["list"]).await,
PrivRequest::WriteNspawnFlags { ref container, ref binds } => {
validate_container_system_name(container)?;
for bind in binds {
validate_bind_path(&bind.host_path)?;
validate_bind_path(&bind.container_path)?;
}
write_nspawn_flags(container, binds)?;
Ok((String::new(), String::new()))
}
PrivRequest::WriteResourceLimits {
ref container,
ref memory_max,
ref cpu_quota,
} => {
validate_container_system_name(container)?;
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={memory_max}\nCPUQuota={cpu_quota}\n");
std::fs::write(&path, content).with_context(|| format!("write {path}"))?;
Ok((String::new(), String::new()))
}
PrivRequest::RemoveServiceDropin { ref container } => {
validate_container_system_name(container)?;
let dir = format!("/run/systemd/system/container@{container}.service.d");
if Path::new(&dir).exists() {
std::fs::remove_dir_all(&dir)
.with_context(|| format!("remove {dir}"))?;
}
Ok((String::new(), String::new()))
}
PrivRequest::DaemonReload => {
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((String::new(), String::new()))
}
PrivRequest::ReloadGatewayNginx => {
let out = Command::new("systemd-run")
.args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"])
.output()
.await
.context("invoke systemd-run for gateway nginx reload")?;
if !out.status.success() {
bail!(
"gateway nginx reload failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok((String::new(), String::new()))
}
PrivRequest::ChownSocketDir { ref agent_name, uid, gid } => {
validate_agent_name(agent_name)?;
let path = socket_dir_path(agent_name);
std::os::unix::fs::chown(&path, Some(uid), Some(gid))
.with_context(|| format!("chown {} to {uid}:{gid}", path.display()))?;
Ok((String::new(), String::new()))
}
PrivRequest::ChmodSocketDir { ref agent_name, mode } => {
validate_agent_name(agent_name)?;
let path = socket_dir_path(agent_name);
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("chmod {:o} {}", mode, path.display()))?;
Ok((String::new(), String::new()))
}
}
}
/// Invoke `nixos-container` with the given args, log output to journald.
async fn container_run(args: &[&str]) -> Result<(String, String)> {
let out = Command::new("nixos-container")
.args(args)
.output()
.await
.context("invoke nixos-container")?;
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
for line in stdout.lines() {
tracing::info!(target: "nixos-container", "{line}");
}
for line in stderr.lines() {
tracing::warn!(target: "nixos-container", "{line}");
}
if !out.status.success() {
bail!(
"nixos-container {} failed ({}): {}",
args.join(" "),
out.status,
stderr.trim()
);
}
Ok((stdout, stderr))
}
/// Return the system container name for a logical agent name.
/// Manager (`MANAGER_NAME`) passes through; sub-agents get `h-` prefix.
fn container_system_name(name: &str) -> String {
if name == MANAGER_NAME {
name.to_owned()
} else {
format!("{AGENT_PREFIX}{name}")
}
}
/// Path of the per-agent unix-socket dir on the host.
fn socket_dir_path(agent_name: &str) -> PathBuf {
PathBuf::from(format!("{SOCKET_DIR_ROOT}/{agent_name}"))
}
/// Validate a logical agent name (the name hive-c0re uses internally,
/// before the `h-` container prefix is applied).
fn validate_agent_name(name: &str) -> Result<()> {
if name == MANAGER_NAME {
return Ok(());
}
validate_name_chars(name)?;
Ok(())
}
/// Validate a logical agent name and check it maps to a hive-managed container.
fn validate_container_name(name: &str) -> Result<()> {
if name == MANAGER_NAME {
return Ok(());
}
if SIBLING_CONTAINERS.contains(&name) {
return Ok(());
}
validate_name_chars(name)?;
Ok(())
}
/// Validate a system-level container name (already has `h-` prefix for
/// sub-agents, or is the manager name / sibling service name).
fn validate_container_system_name(name: &str) -> Result<()> {
if name == MANAGER_NAME {
return Ok(());
}
if SIBLING_CONTAINERS.contains(&name) {
return Ok(());
}
if let Some(suffix) = name.strip_prefix(AGENT_PREFIX) {
validate_name_chars(suffix)?;
return Ok(());
}
bail!("container name {name:?} is not managed by hive");
}
fn validate_name_chars(name: &str) -> Result<()> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
bail!("invalid name {name:?}: must be non-empty lowercase ascii + digits + hyphens");
}
Ok(())
}
/// Derive the meta-flake ref for an agent by name.
fn agent_flake_ref(name: &str) -> String {
format!("{META_DIR}#{name}")
}
/// Validate a bind-mount path: must be absolute, non-empty, and contain
/// no newlines, null bytes, or double-quotes (which would break the
/// `EXTRA_NSPAWN_FLAGS="..."` conf line format).
fn validate_bind_path(path: &str) -> Result<()> {
if path.is_empty()
|| !path.starts_with('/')
|| path.bytes().any(|b| b == 0 || b == b'\n' || b == b'"' || b == b':')
{
bail!(
"invalid bind path {path:?}: must be an absolute path with no colons, newlines, null bytes, or double-quotes"
);
}
Ok(())
}
/// Update `/etc/nixos-containers/<container>.conf`: strips network-isolation
/// vars (`PRIVATE_NETWORK`, `HOST_ADDRESS*`, `LOCAL_ADDRESS*`, `HOST_BRIDGE`,
/// `EXTRA_NSPAWN_FLAGS`), forces `PRIVATE_NETWORK=0` and blank network vars,
/// then appends `EXTRA_NSPAWN_FLAGS="<flags>"`.
fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> {
let path = format!("/etc/nixos-containers/{container}.conf");
let original = std::fs::read_to_string(&path)
.with_context(|| format!("read {path}"))?;
let mut lines: Vec<&str> = original
.lines()
.filter(|line| {
let t = line.trim_start();
!t.starts_with("EXTRA_NSPAWN_FLAGS=")
&& !t.starts_with("PRIVATE_NETWORK=")
&& !t.starts_with("HOST_ADDRESS=")
&& !t.starts_with("LOCAL_ADDRESS=")
&& !t.starts_with("HOST_ADDRESS6=")
&& !t.starts_with("LOCAL_ADDRESS6=")
&& !t.starts_with("HOST_BRIDGE=")
})
.collect();
let mut out = lines.join("\n");
if !out.is_empty() {
out.push('\n');
}
out.push_str("PRIVATE_NETWORK=0\n");
out.push_str("HOST_ADDRESS=\n");
out.push_str("LOCAL_ADDRESS=\n");
out.push_str("HOST_ADDRESS6=\n");
out.push_str("LOCAL_ADDRESS6=\n");
out.push_str("HOST_BRIDGE=\n");
let flags: Vec<String> = binds.iter().map(|b| {
let flag = if b.read_only { "--bind-ro" } else { "--bind" };
format!("{flag}={}:{}", b.host_path, b.container_path)
}).collect();
let flags_joined = flags.join(" ");
out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"\n"));
std::fs::write(&path, out).with_context(|| format!("write {path}"))
}