fix(702): narrow all PrivRequest handlers to specific ops
This commit is contained in:
parent
af23047970
commit
ec12ba4b1a
1 changed files with 154 additions and 107 deletions
|
|
@ -9,13 +9,14 @@
|
||||||
//! **Security model**: every request is validated against a strict
|
//! **Security model**: every request is validated against a strict
|
||||||
//! container-name allowlist before any filesystem or process operation.
|
//! container-name allowlist before any filesystem or process operation.
|
||||||
//! Only containers whose names match the hive convention (`h-*`,
|
//! Only containers whose names match the hive convention (`h-*`,
|
||||||
//! the manager container, or known sibling services) are accepted.
|
//! 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
|
//! **Socket activation**: when systemd passes the listener socket via
|
||||||
//! `LISTEN_FDS=1` + `LISTEN_PID=<self>`, the inherited fd 3 is used
|
//! `LISTEN_FDS=1` + `LISTEN_PID=<self>`, the inherited fd 3 is used
|
||||||
//! instead of binding a fresh socket.
|
//! instead of binding a fresh socket.
|
||||||
|
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
|
|
@ -28,13 +29,13 @@ use tokio::process::Command;
|
||||||
const AGENT_PREFIX: &str = "h-";
|
const AGENT_PREFIX: &str = "h-";
|
||||||
|
|
||||||
/// Manager container name (mirrors `lifecycle::MANAGER_NAME`).
|
/// Manager container name (mirrors `lifecycle::MANAGER_NAME`).
|
||||||
const MANAGER_NAME: &str = "hm1nd";
|
const MANAGER_NAME: &str = "root";
|
||||||
|
|
||||||
/// Sibling service containers managed by hive-c0re.
|
/// Sibling service containers managed by hive-c0re.
|
||||||
const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway"];
|
const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway"];
|
||||||
|
|
||||||
/// Allowed path prefixes for `Chown` / `Chmod` operations.
|
/// Root of the per-agent unix-socket dirs on the host.
|
||||||
const SAFE_PATH_PREFIXES: &[&str] = &["/run/hive-agent/", "/var/lib/hyperhive/"];
|
const SOCKET_DIR_ROOT: &str = "/run/hive-agent";
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
|
@ -94,9 +95,9 @@ fn socket_listener() -> Result<UnixListener> {
|
||||||
.with_context(|| format!("create {}", parent.display()))?;
|
.with_context(|| format!("create {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
let listener =
|
let listener = UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?;
|
||||||
UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?;
|
|
||||||
// Mode 0660: only the hive-core group can connect.
|
// 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))
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
|
||||||
.context("chmod priv.sock")?;
|
.context("chmod priv.sock")?;
|
||||||
tracing::info!(path = PRIV_SOCK, "bound priv socket");
|
tracing::info!(path = PRIV_SOCK, "bound priv socket");
|
||||||
|
|
@ -147,32 +148,69 @@ async fn dispatch(line: &str) -> PrivResponse {
|
||||||
/// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success.
|
/// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success.
|
||||||
async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
||||||
match req {
|
match req {
|
||||||
PrivRequest::ContainerRun { ref args } => {
|
PrivRequest::StartContainer { ref name } => {
|
||||||
validate_container_args(args)?;
|
validate_container_name(name)?;
|
||||||
let out = Command::new("nixos-container")
|
container_run(&["start", &container_system_name(name)]).await
|
||||||
.args(args)
|
}
|
||||||
.output()
|
|
||||||
.await
|
PrivRequest::StopContainer { ref name } => {
|
||||||
.context("invoke nixos-container")?;
|
validate_container_name(name)?;
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
container_run(&["stop", &container_system_name(name)]).await
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
}
|
||||||
// Log each line so progress is visible in journald even
|
|
||||||
// without a streaming protocol.
|
PrivRequest::KillContainer { ref name } => {
|
||||||
for line in stdout.lines() {
|
validate_container_name(name)?;
|
||||||
tracing::info!(target: "nixos-container", "{line}");
|
container_run(&["kill", &container_system_name(name)]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
PrivRequest::UpdateContainer { ref name, ref flake_ref } => {
|
||||||
|
validate_container_name(name)?;
|
||||||
|
validate_flake_ref(flake_ref)?;
|
||||||
|
container_run(&["update", &container_system_name(name), "--flake", flake_ref]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
PrivRequest::CreateContainer { ref name, ref flake_ref } => {
|
||||||
|
validate_container_name(name)?;
|
||||||
|
validate_flake_ref(flake_ref)?;
|
||||||
|
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::WriteNspawnConf { ref container, ref content } => {
|
||||||
|
validate_container_system_name(container)?;
|
||||||
|
let path = format!("/etc/nixos-containers/{container}.conf");
|
||||||
|
std::fs::write(&path, content).with_context(|| format!("write {path}"))?;
|
||||||
|
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}"))?;
|
||||||
}
|
}
|
||||||
for line in stderr.lines() {
|
Ok((String::new(), String::new()))
|
||||||
tracing::warn!(target: "nixos-container", "{line}");
|
|
||||||
}
|
|
||||||
if !out.status.success() {
|
|
||||||
bail!(
|
|
||||||
"nixos-container {} failed ({}): {}",
|
|
||||||
args.join(" "),
|
|
||||||
out.status,
|
|
||||||
stderr.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok((stdout, stderr))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PrivRequest::DaemonReload => {
|
PrivRequest::DaemonReload => {
|
||||||
|
|
@ -191,58 +229,6 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
||||||
Ok((String::new(), String::new()))
|
Ok((String::new(), String::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
PrivRequest::WriteNspawnConf { ref container, ref content } => {
|
|
||||||
validate_container_name(container)?;
|
|
||||||
let path = format!("/etc/nixos-containers/{container}.conf");
|
|
||||||
std::fs::write(&path, content).with_context(|| format!("write {path}"))?;
|
|
||||||
Ok((String::new(), String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
PrivRequest::WriteSystemdDropin {
|
|
||||||
ref container,
|
|
||||||
ref filename,
|
|
||||||
ref content,
|
|
||||||
} => {
|
|
||||||
validate_container_name(container)?;
|
|
||||||
validate_dropin_filename(filename)?;
|
|
||||||
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}/{filename}");
|
|
||||||
std::fs::write(&path, content).with_context(|| format!("write {path}"))?;
|
|
||||||
Ok((String::new(), String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
PrivRequest::RemoveSystemdDropin { ref container } => {
|
|
||||||
validate_container_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::Chown { ref path, uid, gid } => {
|
|
||||||
validate_safe_path(path)?;
|
|
||||||
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::Chmod { ref path, mode } => {
|
|
||||||
validate_safe_path(path)?;
|
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
|
||||||
.with_context(|| {
|
|
||||||
format!("chmod {:o} {}", mode, path.display())
|
|
||||||
})?;
|
|
||||||
Ok((String::new(), String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
PrivRequest::ReloadGatewayNginx => {
|
PrivRequest::ReloadGatewayNginx => {
|
||||||
let out = Command::new("systemd-run")
|
let out = Command::new("systemd-run")
|
||||||
.args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"])
|
.args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"])
|
||||||
|
|
@ -258,27 +244,78 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
||||||
}
|
}
|
||||||
Ok((String::new(), String::new()))
|
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()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that nixos-container args reference only hive-managed containers.
|
/// Invoke `nixos-container` with the given args, log output to journald.
|
||||||
fn validate_container_args(args: &[String]) -> Result<()> {
|
async fn container_run(args: &[&str]) -> Result<(String, String)> {
|
||||||
if args.is_empty() {
|
let out = Command::new("nixos-container")
|
||||||
bail!("ContainerRun: args must not be empty");
|
.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}");
|
||||||
}
|
}
|
||||||
// Verbs whose second positional argument is a container name.
|
for line in stderr.lines() {
|
||||||
const CONTAINER_ARG_VERBS: &[&str] =
|
tracing::warn!(target: "nixos-container", "{line}");
|
||||||
&["start", "stop", "kill", "restart", "destroy", "update", "run", "create"];
|
|
||||||
if CONTAINER_ARG_VERBS.contains(&args[0].as_str()) {
|
|
||||||
if let Some(name) = args.get(1) {
|
|
||||||
validate_container_name(name)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// `list` and unknown verbs take no container arg - no further validation needed.
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that a container name is managed by hive.
|
/// Validate a logical agent name and check it maps to a hive-managed container.
|
||||||
fn validate_container_name(name: &str) -> Result<()> {
|
fn validate_container_name(name: &str) -> Result<()> {
|
||||||
if name == MANAGER_NAME {
|
if name == MANAGER_NAME {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -286,30 +323,40 @@ fn validate_container_name(name: &str) -> Result<()> {
|
||||||
if SIBLING_CONTAINERS.contains(&name) {
|
if SIBLING_CONTAINERS.contains(&name) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if name.starts_with(AGENT_PREFIX) {
|
validate_name_chars(name)?;
|
||||||
let suffix = &name[AGENT_PREFIX.len()..];
|
Ok(())
|
||||||
if !suffix.is_empty()
|
}
|
||||||
&& suffix.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
|
||||||
{
|
/// Validate a system-level container name (already has `h-` prefix for
|
||||||
return Ok(());
|
/// 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");
|
bail!("container name {name:?} is not managed by hive");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_dropin_filename(filename: &str) -> Result<()> {
|
fn validate_name_chars(name: &str) -> Result<()> {
|
||||||
if filename.is_empty() || filename.contains('/') || filename.contains("..") {
|
if name.is_empty()
|
||||||
bail!("invalid drop-in filename: {filename:?}");
|
|| !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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_safe_path(path: &Path) -> Result<()> {
|
fn validate_flake_ref(flake_ref: &str) -> Result<()> {
|
||||||
let s = path.to_string_lossy();
|
if flake_ref.is_empty() || flake_ref.contains('\n') || flake_ref.contains('\0') {
|
||||||
for prefix in SAFE_PATH_PREFIXES {
|
bail!("invalid flake_ref {flake_ref:?}");
|
||||||
if s.starts_with(prefix) && !s.contains("..") {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
bail!("path {} is outside allowed prefixes", path.display());
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue