feat(gateway): hivectl gateway user management + fix htpasswdFile assertion
Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.
Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.
Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
This commit is contained in:
parent
25d2951d1e
commit
4bff450343
61 changed files with 1084 additions and 547 deletions
|
|
@ -17,11 +17,13 @@
|
|||
//! `LISTEN_FDS=1` + `LISTEN_PID=<self>`, the inherited fd 3 is used
|
||||
//! instead of binding a fresh socket.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
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 hive_sh4re::priv_proto::{
|
||||
AGENT_PREFIX, BindMount, MANAGER_NAME, META_DIR, PRIV_SOCK, PrivRequest, PrivResponse,
|
||||
SIBLING_CONTAINERS,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::process::Command;
|
||||
|
|
@ -33,8 +35,7 @@ const SOCKET_DIR_ROOT: &str = "/run/hive-agent";
|
|||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
|
|
@ -54,7 +55,6 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
fn socket_listener() -> Result<UnixListener> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
// 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")
|
||||
|
|
@ -64,32 +64,32 @@ fn socket_listener() -> Result<UnixListener> {
|
|||
.ok()
|
||||
.and_then(|s| s.parse().ok());
|
||||
|
||||
if let (Some(n), Some(p)) = (listen_fds, listen_pid)
|
||||
&& 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);
|
||||
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()))?;
|
||||
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");
|
||||
|
|
@ -138,7 +138,6 @@ async fn dispatch(line: &str) -> PrivResponse {
|
|||
}
|
||||
|
||||
/// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
||||
match req {
|
||||
PrivRequest::StartContainer { ref name } => {
|
||||
|
|
@ -159,13 +158,25 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
|||
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
|
||||
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
|
||||
container_run(&[
|
||||
"create",
|
||||
&container_system_name(name),
|
||||
"--flake",
|
||||
&flake_ref,
|
||||
])
|
||||
.await
|
||||
}
|
||||
|
||||
PrivRequest::DestroyContainer { ref name } => {
|
||||
|
|
@ -175,7 +186,10 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
|||
|
||||
PrivRequest::ListContainers => container_run(&["list"]).await,
|
||||
|
||||
PrivRequest::WriteNspawnFlags { ref container, ref binds } => {
|
||||
PrivRequest::WriteNspawnFlags {
|
||||
ref container,
|
||||
ref binds,
|
||||
} => {
|
||||
validate_container_system_name(container)?;
|
||||
for bind in binds {
|
||||
validate_bind_path(&bind.host_path)?;
|
||||
|
|
@ -203,8 +217,7 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
|||
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}"))?;
|
||||
std::fs::remove_dir_all(&dir).with_context(|| format!("remove {dir}"))?;
|
||||
}
|
||||
Ok((String::new(), String::new()))
|
||||
}
|
||||
|
|
@ -227,7 +240,14 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
|||
|
||||
PrivRequest::ReloadGatewayNginx => {
|
||||
let out = Command::new("systemd-run")
|
||||
.args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"])
|
||||
.args([
|
||||
"--machine=hive-gateway",
|
||||
"--quiet",
|
||||
"--",
|
||||
"nginx",
|
||||
"-s",
|
||||
"reload",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("invoke systemd-run for gateway nginx reload")?;
|
||||
|
|
@ -241,7 +261,11 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
|||
Ok((String::new(), String::new()))
|
||||
}
|
||||
|
||||
PrivRequest::ChownSocketDir { ref agent_name, uid, gid } => {
|
||||
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))
|
||||
|
|
@ -249,10 +273,13 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
|
|||
Ok((String::new(), String::new()))
|
||||
}
|
||||
|
||||
PrivRequest::ChmodSocketDir { ref agent_name, mode } => {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
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()))
|
||||
|
|
@ -361,7 +388,9 @@ fn agent_flake_ref(name: &str) -> String {
|
|||
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':')
|
||||
|| 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"
|
||||
|
|
@ -376,8 +405,7 @@ fn validate_bind_path(path: &str) -> Result<()> {
|
|||
/// 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 original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
|
||||
let lines: Vec<&str> = original
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
|
|
@ -401,11 +429,14 @@ fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> {
|
|||
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: 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(" ");
|
||||
writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"").unwrap();
|
||||
out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"\n"));
|
||||
std::fs::write(&path, out).with_context(|| format!("write {path}"))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue