gateway: serve the hive dashboard on <hive>.hives.<swarm> behind authelia, drop htpasswd
The dashboard, its API, the swagger UI and the agent routes move to their own vhost, `swarm.dashboardDomain` = `<hive>.hives.<swarm.domain>`, under the swarm domain so authelia's session cookie reaches it. `/`, `/api/` and `/api/docs/` ask authelia through `auth_request`, and authelia's `operatorsOnly` rule for each hive's dashboard name lets only the admins group in. The hive domain keeps matrix discovery and redirects every other path to the new name. The name rides as a SAN on the hive's own gateway leaf: the per-hive bao role allows it, and hive-tls asks for it and checks coverage. HTTP Basic auth is gone: the htpasswd writer and its test, the `hivectl gateway` verbs and host-socket requests, the `gateway.auth` options (now a removed-option error), the unauthorized page and the bcrypt dependency. tmpfiles deletes a leftover gateway.htpasswd, which held bcrypt hashes at 0644. c0re hands out dashboard and agent URLs on the new name (HYPERHIVE_DASHBOARD_DOMAIN), and the swarm controller's hive roster carries each hive's dashboard name for the swarm UI's hive list. Closes #3469
This commit is contained in:
parent
09ad7386b2
commit
92559af8e7
47 changed files with 351 additions and 623 deletions
|
|
@ -18,7 +18,6 @@ async-nats.workspace = true
|
|||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
base64.workspace = true
|
||||
bcrypt.workspace = true
|
||||
reqwest.workspace = true
|
||||
forgejo-api.workspace = true
|
||||
url.workspace = true
|
||||
|
|
|
|||
|
|
@ -110,14 +110,14 @@ impl From<ContainerView> for hive_sh4re::container::AgentStatusRow {
|
|||
}
|
||||
}
|
||||
|
||||
/// This agent's own web UI, behind the gateway. `None` when the hive's
|
||||
/// This agent's own web UI, behind the gateway. `None` when the dashboard's
|
||||
/// domain isn't configured — same env var, same "empty string counts as
|
||||
/// unset" filter `server.rs::hive_urls()` uses for `HiveUrls::home`, kept
|
||||
/// in sync with it by reading the one env var directly rather than
|
||||
/// threading a resolved domain through `HiveEnv` for a value nothing else
|
||||
/// needs pre-resolved.
|
||||
fn agent_url(name: &str) -> Option<String> {
|
||||
let domain = std::env::var("HYPERHIVE_HIVE_DOMAIN")
|
||||
let domain = std::env::var("HYPERHIVE_DASHBOARD_DOMAIN")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())?;
|
||||
Some(agent_url_for_domain(&domain, name))
|
||||
|
|
@ -565,7 +565,7 @@ mod tests {
|
|||
// No source for reminders on this side (see the `From` impl's doc
|
||||
// comment) — always the stub, never left uninitialised.
|
||||
assert_eq!(row.pending_reminders, 0);
|
||||
// `url` depends on process-global env (`HYPERHIVE_HIVE_DOMAIN`), so
|
||||
// `url` depends on process-global env (`HYPERHIVE_DASHBOARD_DOMAIN`), so
|
||||
// it isn't asserted here — mutating env vars in a test race with
|
||||
// every other test in this binary. `agent_url_for_domain` below
|
||||
// covers the actual path-formatting logic without touching env.
|
||||
|
|
|
|||
|
|
@ -293,143 +293,11 @@ async fn reload_gateway_nginx() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Gateway HTTP-Basic (htpasswd) user management ──────────────────────────
|
||||
// The daemon owns the write (hivectl drives it over the host socket via the
|
||||
// `Gateway*User` requests, so hivectl never touches the credential file).
|
||||
// Keyed on the canonical `paths::GATEWAY_HTPASSWD` — a socket client doesn't
|
||||
// pick the path.
|
||||
|
||||
fn htpasswd_path() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(crate::paths::GATEWAY_HTPASSWD)
|
||||
}
|
||||
|
||||
/// Read the htpasswd file into lines, or an empty list if it doesn't exist.
|
||||
fn htpasswd_read() -> Result<Vec<String>> {
|
||||
let path = htpasswd_path();
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("read htpasswd file {}", path.display()))?;
|
||||
Ok(content.lines().map(str::to_owned).collect())
|
||||
}
|
||||
|
||||
/// Write lines back atomically (`<path>.tmp` then rename), with a trailing
|
||||
/// newline.
|
||||
fn htpasswd_write(lines: &[String]) -> Result<()> {
|
||||
htpasswd_write_at(&htpasswd_path(), lines)
|
||||
}
|
||||
|
||||
fn htpasswd_write_at(path: &std::path::Path, lines: &[String]) -> Result<()> {
|
||||
let tmp = path.with_extension("htpasswd.tmp");
|
||||
let content = if lines.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let mut s = lines.join("\n");
|
||||
s.push('\n');
|
||||
s
|
||||
};
|
||||
let mut f = std::fs::File::create(&tmp)
|
||||
.with_context(|| format!("create htpasswd tmp {}", tmp.display()))?;
|
||||
std::io::Write::write_all(&mut f, content.as_bytes())
|
||||
.and_then(|()| f.sync_all())
|
||||
.with_context(|| format!("write htpasswd tmp {}", tmp.display()))?;
|
||||
// Mode set on the TMP file for the same reason as in `write()`: the
|
||||
// rename carries it, and nginx reads this file as a different user.
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))
|
||||
.with_context(|| format!("chmod htpasswd tmp {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_htpasswd_username(username: &str) -> Result<()> {
|
||||
if username.is_empty() {
|
||||
anyhow::bail!("username must not be empty");
|
||||
}
|
||||
if username.contains(':') {
|
||||
anyhow::bail!("username must not contain ':' (htpasswd field separator)");
|
||||
}
|
||||
if username.chars().any(char::is_control) {
|
||||
anyhow::bail!("username must not contain control characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add or update a gateway HTTP-Basic user, bcrypt-hashing `password`
|
||||
/// (cost 12). Returns the confirmation line for the operator.
|
||||
pub fn create_user(username: &str, password: &str) -> Result<String> {
|
||||
validate_htpasswd_username(username)?;
|
||||
let raw_hash = bcrypt::hash(password, 12).context("bcrypt hash")?;
|
||||
// nginx auth_basic only recognises $2a$/$2x$/$2y$ — not $2b$. The prefixes
|
||||
// are algorithmically identical; remap so nginx accepts the hash.
|
||||
let hash = raw_hash.replacen("$2b$", "$2y$", 1);
|
||||
let entry = format!("{username}:{hash}");
|
||||
let mut lines = htpasswd_read()?;
|
||||
let prefix = format!("{username}:");
|
||||
let msg = if let Some(pos) = lines.iter().position(|l| l.starts_with(&prefix)) {
|
||||
lines[pos] = entry;
|
||||
format!("gateway: updated password for '{username}'")
|
||||
} else {
|
||||
lines.push(entry);
|
||||
format!("gateway: added user '{username}'")
|
||||
};
|
||||
htpasswd_write(&lines)?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Remove a gateway HTTP-Basic user. Errors when the user isn't present so a
|
||||
/// no-op is detectable.
|
||||
pub fn delete_user(username: &str) -> Result<String> {
|
||||
let mut lines = htpasswd_read()?;
|
||||
let prefix = format!("{username}:");
|
||||
let before = lines.len();
|
||||
lines.retain(|l| !l.starts_with(&prefix));
|
||||
if lines.len() == before {
|
||||
anyhow::bail!("gateway: user '{username}' not found");
|
||||
}
|
||||
htpasswd_write(&lines)?;
|
||||
Ok(format!("gateway: removed user '{username}'"))
|
||||
}
|
||||
|
||||
/// List the gateway HTTP-Basic usernames (skips blank / comment lines).
|
||||
pub fn list_users() -> Result<Vec<String>> {
|
||||
Ok(htpasswd_read()?
|
||||
.iter()
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.filter_map(|l| l.split_once(':').map(|(name, _)| name.to_owned()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lifecycle::MANAGER_NAME;
|
||||
|
||||
/// The htpasswd file is published `0644` even when the temp file it is
|
||||
/// written through already exists at `0600` (opening an existing file
|
||||
/// keeps its mode), holds the lines newline-terminated, and leaves no
|
||||
/// temp file beside it.
|
||||
#[test]
|
||||
fn htpasswd_is_published_0644_over_a_private_leftover_tmp() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("htpasswd");
|
||||
let tmp = path.with_extension("htpasswd.tmp");
|
||||
std::fs::write(&tmp, "leftover\n").expect("seed tmp");
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).expect("chmod");
|
||||
|
||||
htpasswd_write_at(&path, &["a:x".to_owned(), "b:y".to_owned()]).expect("write");
|
||||
|
||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o644);
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("read"), "a:x\nb:y\n");
|
||||
let entries: Vec<_> = std::fs::read_dir(dir.path())
|
||||
.expect("readdir")
|
||||
.map(|e| e.expect("entry").file_name())
|
||||
.collect();
|
||||
assert_eq!(entries, [std::ffi::OsString::from("htpasswd")]);
|
||||
}
|
||||
|
||||
// ── legacy mode (frontend_dir = None) ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ use std::path::{Path, PathBuf};
|
|||
// `hive-host-sock` (the crate hivectl links instead of the whole daemon);
|
||||
// re-exported here so this module stays the daemon's single-source facade for
|
||||
// its own callsites.
|
||||
pub use hive_host_sock::{AGENTS_ROOT, GATEWAY_HTPASSWD, HOST_SOCKET, agent_state_dir};
|
||||
pub use hive_host_sock::{AGENTS_ROOT, HOST_SOCKET, agent_state_dir};
|
||||
|
||||
/// Name of the per-agent working dir under `agents/<name>/`, a sibling of
|
||||
/// `state/`. Bound RW into the container at `/agents/<name>/work`.
|
||||
|
|
|
|||
|
|
@ -193,15 +193,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
HostRequest::ReconcileConfigApply { agent, direction } => {
|
||||
crate::forge::reconcile_config_apply(agent.as_str(), *direction).await?
|
||||
}
|
||||
HostRequest::GatewayCreateUser { username, password } => {
|
||||
HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?])
|
||||
}
|
||||
HostRequest::GatewayDeleteUser { username } => {
|
||||
HostResponse::messages(vec![crate::gateway_nginx::delete_user(username)?])
|
||||
}
|
||||
HostRequest::GatewayListUsers => {
|
||||
HostResponse::messages(crate::gateway_nginx::list_users()?)
|
||||
}
|
||||
HostRequest::QuotaEnable => handle_quota_enable().await?,
|
||||
HostRequest::QuotaLimit { name, limit } => {
|
||||
handle_quota_limit(name.as_str(), *limit).await?
|
||||
|
|
@ -779,7 +770,7 @@ fn hive_urls() -> hive_host_sock::HiveUrls {
|
|||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let domain = env("HYPERHIVE_HIVE_DOMAIN");
|
||||
hive_host_sock::HiveUrls {
|
||||
home: domain.as_ref().map(|d| format!("https://{d}/")),
|
||||
home: env("HYPERHIVE_DASHBOARD_DOMAIN").map(|d| format!("https://{d}/")),
|
||||
forge: env("HIVE_FORGE_PUBLIC_URL"),
|
||||
matrix: env("HIVE_MATRIX_PUBLIC_URL"),
|
||||
domain,
|
||||
|
|
|
|||
Loading…
Reference in a new issue