Compare commits
7 changed files with 1 additions and 669 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-matrix-mcp", "hive-priv", "hive-sh4re"]
|
members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-matrix-mcp", "hive-sh4re"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ pub mod matrix;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod migrate;
|
pub mod migrate;
|
||||||
pub mod operator_questions;
|
pub mod operator_questions;
|
||||||
pub mod priv_client;
|
|
||||||
pub mod questions;
|
pub mod questions;
|
||||||
pub mod rebuild_queue;
|
pub mod rebuild_queue;
|
||||||
pub mod reminder_scheduler;
|
pub mod reminder_scheduler;
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
//! Async client for the `hive-priv` privileged-helper socket.
|
|
||||||
//!
|
|
||||||
//! Exposes a standalone async function per operation. Each call opens a
|
|
||||||
//! fresh connection to `/run/hive/priv.sock`, sends one JSON line, reads
|
|
||||||
//! the response, and closes. Connection-per-call is intentional: priv
|
|
||||||
//! calls are infrequent (once per rebuild step), so simplicity wins over
|
|
||||||
//! a persistent connection.
|
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
|
||||||
use hive_sh4re::priv_proto::{PRIV_SOCK, BindMount, PrivRequest, PrivResponse};
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use tokio::net::UnixStream;
|
|
||||||
|
|
||||||
/// Send a single request to `hive-priv` and return the response.
|
|
||||||
pub async fn call(req: &PrivRequest) -> Result<PrivResponse> {
|
|
||||||
let mut stream = UnixStream::connect(PRIV_SOCK)
|
|
||||||
.await
|
|
||||||
.context("connect to hive-priv socket")?;
|
|
||||||
let line = serde_json::to_string(req).context("serialise PrivRequest")? + "\n";
|
|
||||||
stream
|
|
||||||
.write_all(line.as_bytes())
|
|
||||||
.await
|
|
||||||
.context("send request to hive-priv")?;
|
|
||||||
stream.shutdown().await.context("shutdown write half")?;
|
|
||||||
let mut resp_line = String::new();
|
|
||||||
BufReader::new(stream)
|
|
||||||
.read_line(&mut resp_line)
|
|
||||||
.await
|
|
||||||
.context("read response from hive-priv")?;
|
|
||||||
serde_json::from_str(&resp_line).context("parse PrivResponse")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start_container(name: &str) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::StartContainer { name: name.to_owned() }).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop_container(name: &str) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::StopContainer { name: name.to_owned() }).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn kill_container(name: &str) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::KillContainer { name: name.to_owned() }).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_container(name: &str) -> Result<(String, String)> {
|
|
||||||
check(call(&PrivRequest::UpdateContainer { name: name.to_owned() }).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_container(name: &str) -> Result<(String, String)> {
|
|
||||||
check(call(&PrivRequest::CreateContainer { name: name.to_owned() }).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn destroy_container(name: &str) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::DestroyContainer { name: name.to_owned() }).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_containers() -> Result<String> {
|
|
||||||
let (stdout, _) = check(call(&PrivRequest::ListContainers).await?)?;
|
|
||||||
Ok(stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::WriteNspawnFlags {
|
|
||||||
container: container.to_owned(),
|
|
||||||
binds: binds.to_vec(),
|
|
||||||
}).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use hive_sh4re::priv_proto::BindMount;
|
|
||||||
|
|
||||||
pub async fn write_resource_limits(
|
|
||||||
container: &str,
|
|
||||||
memory_max: &str,
|
|
||||||
cpu_quota: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::WriteResourceLimits {
|
|
||||||
container: container.to_owned(),
|
|
||||||
memory_max: memory_max.to_owned(),
|
|
||||||
cpu_quota: cpu_quota.to_owned(),
|
|
||||||
}).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn remove_service_dropin(container: &str) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::RemoveServiceDropin {
|
|
||||||
container: container.to_owned(),
|
|
||||||
}).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn daemon_reload() -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::DaemonReload).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reload_gateway_nginx() -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::ReloadGatewayNginx).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn chown_socket_dir(agent_name: &str, uid: u32, gid: u32) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::ChownSocketDir {
|
|
||||||
agent_name: agent_name.to_owned(),
|
|
||||||
uid,
|
|
||||||
gid,
|
|
||||||
}).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::ChmodSocketDir {
|
|
||||||
agent_name: agent_name.to_owned(),
|
|
||||||
mode,
|
|
||||||
}).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check(resp: PrivResponse) -> Result<(String, String)> {
|
|
||||||
if resp.ok {
|
|
||||||
Ok((resp.stdout, resp.stderr))
|
|
||||||
} else {
|
|
||||||
bail!("{}", resp.error.as_deref().unwrap_or("hive-priv returned error"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ok(resp: PrivResponse) -> Result<()> {
|
|
||||||
check(resp)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "hive-priv"
|
|
||||||
edition.workspace = true
|
|
||||||
version.workspace = true
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow.workspace = true
|
|
||||||
hive-sh4re.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
tokio.workspace = true
|
|
||||||
tracing.workspace = true
|
|
||||||
tracing-subscriber.workspace = true
|
|
||||||
|
|
@ -1,409 +0,0 @@
|
||||||
//! 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}"))
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod priv_proto;
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Host admin socket — /run/hyperhive/host.sock
|
// Host admin socket — /run/hyperhive/host.sock
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
//! Wire types for the `hive-priv` privileged-helper socket.
|
|
||||||
//!
|
|
||||||
//! Both `hive-priv` (server) and `hive-c0re` (client via `priv_client`)
|
|
||||||
//! import these so the shapes stay in sync.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Default socket path for the privileged helper.
|
|
||||||
pub const PRIV_SOCK: &str = "/run/hive/priv.sock";
|
|
||||||
|
|
||||||
/// Manager container name. Used by `hive-priv` to skip the `h-` prefix
|
|
||||||
/// and by `hive-c0re` for identity checks.
|
|
||||||
pub const MANAGER_NAME: &str = "root";
|
|
||||||
|
|
||||||
/// Sub-agent container prefix. System container name = `h-<agent_name>`.
|
|
||||||
pub const AGENT_PREFIX: &str = "h-";
|
|
||||||
|
|
||||||
/// Sibling service containers managed by hive-c0re.
|
|
||||||
pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway"];
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
pub const META_DIR: &str = "/var/lib/hyperhive/meta";
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 },
|
|
||||||
|
|
||||||
/// `nixos-container kill <name>`
|
|
||||||
KillContainer { name: String },
|
|
||||||
|
|
||||||
/// `nixos-container update <name> --flake <flake_ref>`
|
|
||||||
/// The flake ref is derived from `name` by hive-priv.
|
|
||||||
UpdateContainer { name: String },
|
|
||||||
|
|
||||||
/// `nixos-container create <name> --flake <flake_ref>`
|
|
||||||
/// The flake ref is derived from `name` by hive-priv.
|
|
||||||
CreateContainer { name: String },
|
|
||||||
|
|
||||||
/// `nixos-container destroy <name>`
|
|
||||||
DestroyContainer { name: String },
|
|
||||||
|
|
||||||
/// `nixos-container list`
|
|
||||||
ListContainers,
|
|
||||||
|
|
||||||
// --- Config file writes ---
|
|
||||||
|
|
||||||
/// Update `/etc/nixos-containers/<container>.conf`: strip network-isolation
|
|
||||||
/// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS` from the
|
|
||||||
/// provided bind-mount list. Written by `lifecycle::set_nspawn_flags`.
|
|
||||||
WriteNspawnFlags { container: String, binds: Vec<BindMount> },
|
|
||||||
|
|
||||||
/// Write `/run/systemd/system/container@<container>.service.d/hyperhive-limits.conf`
|
|
||||||
/// with `[Service]\nMemoryMax=<memory_max>\nCPUQuota=<cpu_quota>\n`.
|
|
||||||
/// Written by `lifecycle::set_resource_limits`.
|
|
||||||
WriteResourceLimits {
|
|
||||||
container: String,
|
|
||||||
memory_max: String,
|
|
||||||
cpu_quota: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
|
|
||||||
/// Reload nginx inside the `hive-gateway` container via
|
|
||||||
/// `systemd-run --machine=hive-gateway nginx -s reload`.
|
|
||||||
ReloadGatewayNginx,
|
|
||||||
|
|
||||||
// --- Socket dir ownership ---
|
|
||||||
|
|
||||||
/// Set ownership of `/run/hive-agent/<agent_name>/` to `uid:gid`.
|
|
||||||
/// Called by `lifecycle::set_nspawn_flags` after `create_dir_all`.
|
|
||||||
ChownSocketDir { agent_name: String, uid: u32, gid: u32 },
|
|
||||||
|
|
||||||
/// Set mode of `/run/hive-agent/<agent_name>/`.
|
|
||||||
/// Fallback when uid lookup returns `None` on first spawn.
|
|
||||||
ChmodSocketDir { agent_name: String, mode: u32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>,
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue