Per review: docs represent current state. Every "used to" / "no longer" clause this branch introduced is gone — including the History section in network.md, which was a whole subsection about a sync mechanism that doesn't exist. Where the removed clause was carrying a real constraint, the constraint stays and is stated in the present tense instead of as a delta: nothing narrows what the gateway's nginx can reach except the directory permissions in front of a socket, and nothing bounds `ReloadGatewayNginx` except the hard-coded unit name. Those read as rules now rather than as the story of how they came to be rules.
117 lines
5.1 KiB
Rust
117 lines
5.1 KiB
Rust
//! Swarm-level controller daemon. Runs as the unprivileged
|
|
//! `swarm-controller` user on whichever host the operator flips
|
|
//! `services.hyperhive.swarm.controller.enable` on, and serves HTTP over a
|
|
//! unix socket that the hive-gateway's nginx proxies to.
|
|
//!
|
|
//! **Today it serves one endpoint and owns no state.** That is deliberate:
|
|
//! this slice exists to make the *unit* real — service user, runtime and
|
|
//! state directories, socket, nginx reachability — so the swarm-level
|
|
//! surfaces that follow have somewhere to land. Guessing those surfaces
|
|
//! now would bake in a shape nobody has agreed to.
|
|
//!
|
|
//! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on
|
|
//! one host, this owns what is true across hives.
|
|
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result};
|
|
use axum::{Router, routing::get};
|
|
|
|
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
|
///
|
|
/// A compiled-in default is legitimate here and is *not* the mistake that
|
|
/// a hardcoded remote address would be: this is a path this process
|
|
/// **creates**, not an address it hopes to find something at. systemd's
|
|
/// `RuntimeDirectory=swarm-controller` makes the parent exist before
|
|
/// `ExecStart`, so the default names a directory the unit just produced.
|
|
///
|
|
/// The directory is its own — deliberately not shared with hive-c0re's
|
|
/// `/run/hyperhive`. The socket is `0666`, so its directory is the only
|
|
/// access control it has; co-locating it with c0re's admin socket would
|
|
/// put both within reach of whatever can reach either. nginx runs on the
|
|
/// host, so nothing narrows its reach for you.
|
|
const DEFAULT_SOCKET: &str = "/run/swarm-controller/controller.sock";
|
|
|
|
fn socket_path() -> PathBuf {
|
|
std::env::var_os("SWARM_CONTROLLER_SOCKET")
|
|
.map_or_else(|| PathBuf::from(DEFAULT_SOCKET), PathBuf::from)
|
|
}
|
|
|
|
/// Liveness probe. Returns the build's version so an operator can tell
|
|
/// *which* controller answered without shelling onto the host.
|
|
async fn health() -> &'static str {
|
|
concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n")
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
)
|
|
.init();
|
|
|
|
let path = socket_path();
|
|
|
|
// `RuntimeDirectoryPreserve=yes` keeps the directory across a restart,
|
|
// so a socket file from the previous run can outlive the process that
|
|
// owned it and `bind` would fail with EADDRINUSE. Unlinking a stale
|
|
// socket is safe precisely because the directory is ours alone: nothing
|
|
// else can have put a file at this path.
|
|
if let Err(e) = std::fs::remove_file(&path)
|
|
&& e.kind() != std::io::ErrorKind::NotFound
|
|
{
|
|
return Err(e).with_context(|| format!("clearing stale socket at {}", path.display()));
|
|
}
|
|
|
|
let listener = tokio::net::UnixListener::bind(&path)
|
|
.with_context(|| format!("binding {}", path.display()))?;
|
|
|
|
// `bind` leaves the socket 0755, and connecting needs write — the
|
|
// gateway's nginx is a different user, so it would be locked out.
|
|
// 0666 matches how hive-c0re publishes the per-agent sockets
|
|
// (`socket_server::start`), and rests on the same argument: **the
|
|
// containing directory is the access control, not the socket mode.**
|
|
// This directory holds one socket and is bind-mounted into exactly
|
|
// one container. That is also why it must not be shared with
|
|
// hive-c0re's `/run/hyperhive` — with a 0666 socket, a directory
|
|
// that carries more than it should is the whole vulnerability.
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666))
|
|
.with_context(|| format!("chmod {}", path.display()))?;
|
|
tracing::info!(socket = %path.display(), "swarm-controller listening");
|
|
|
|
let app = Router::new().route("/health", get(health));
|
|
axum::serve(listener, app)
|
|
.await
|
|
.context("serving swarm-controller")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::DEFAULT_SOCKET;
|
|
use std::path::Path;
|
|
|
|
/// The socket must not share a directory with anything else, because
|
|
/// the socket is `0666` and the directory is therefore the only access
|
|
/// control it has. `/run/hyperhive` in particular holds hive-c0re's
|
|
/// **admin** socket, and nginx — a host service — is bounded by
|
|
/// nothing but the directory itself.
|
|
///
|
|
/// A test rather than a comment: the failure this guards against is a
|
|
/// one-word edit that looks tidier and reads fine in review.
|
|
#[test]
|
|
fn socket_lives_in_its_own_runtime_dir() {
|
|
let parent = Path::new(DEFAULT_SOCKET)
|
|
.parent()
|
|
.expect("socket path has a parent directory");
|
|
assert_eq!(
|
|
parent,
|
|
Path::new("/run/swarm-controller"),
|
|
"the socket's directory is its access control — moving it under a shared \
|
|
directory (notably /run/hyperhive, which holds the host admin socket) \
|
|
exposes everything else in that directory to the gateway's nginx"
|
|
);
|
|
}
|
|
}
|