swarm-controller: OpenAPI spec + gateway swagger UI wiring

Rust half mirrors hive-c0re/src/dashboard/mod.rs's utoipa pattern
exactly: an ApiDoc root, #[utoipa::path(...)] on /health (the one
existing route), and a raw JSON route at /api/openapi.json served
via OpenApiRouter::split_for_parts(). Only annotated routes appear
in the spec.

Gateway wiring extends the swarm-UI vhost (the only vhost swarm-
controller is reachable from) with:
- /api/ — proxied to the controller's unix socket untouched (no URI
  segment after the socket path), so a route swarm-controller
  registers is the path nginx forwards, no prefix-stripping to keep
  in sync by hand.
- /api/docs/ (+ the bare /api/docs redirect) — the same
  swagger-ui-theme dist the per-hive dashboard already serves at its
  own /api/docs/, reused as-is since it's generic.

Both new locations reuse the same auth_request block the vhost's own
'/' already applies, factored into a shared swarmAuthRequest string —
auth_request does not inherit across sibling nginx locations, so
without this the page itself would be gated while its own API and
API docs sat open.

cargo test -p swarm-controller + cargo clippy --all-targets both
clean. Verified the new nginx wiring evaluates correctly with a
throwaway nixosSystem eval (services.hyperhive.swarm.{controller,ui}
enabled): /api/ proxies to the socket, /api/docs redirects, and both
require auth_request the same as the vhost root.

Fixes hyperhive#3212
This commit is contained in:
iris 2026-08-12 21:18:16 +02:00 committed by mara
commit 4828c96957
5 changed files with 94 additions and 9 deletions

2
Cargo.lock generated
View file

@ -4430,6 +4430,8 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"utoipa",
"utoipa-axum",
]
[[package]]

View file

@ -24,6 +24,7 @@ let
matrixCfg = config.services.hyperhive.swarm.matrix;
autheliaCfg = config.services.hyperhive.swarm.authelia;
uiCfg = config.services.hyperhive.swarm.ui;
controllerCfg = config.services.hyperhive.swarm.controller;
forgeCfg = config.services.hyperhive.swarm.forge;
networkCfg = config.services.hyperhive.network;
@ -72,6 +73,7 @@ let
matrixCfg
autheliaCfg
uiCfg
controllerCfg
hyperhiveDomain
dashboardDist
swaggerUiTheme

View file

@ -11,6 +11,7 @@
matrixCfg,
autheliaCfg, # services.hyperhive.swarm.authelia
uiCfg, # services.hyperhive.swarm.ui
controllerCfg, # services.hyperhive.swarm.controller
hyperhiveDomain,
dashboardDist,
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css
@ -196,6 +197,21 @@ let
# merely insecure rather than broken, so they keep `addSSL` and the
# asymmetry stays local to the vhost whose correctness depends on the
# scheme. `removeAttrs` because nixos asserts on a vhost declaring both.
# Shared with every swarm-UI-vhost location below (`/`, `/api/`,
# `/api/docs/`) — auth_request does not inherit across sibling
# locations, so each one that should be operator-gated repeats this
# verbatim rather than only the page itself being protected while its
# own API and API docs are reachable unauthenticated.
swarmAuthRequest = ''
auth_request /__hive_authelia;
# Captured BEFORE the error_page jump: inside the 401 handler
# `$request_uri` is the internal one, so building the return
# link there sends the operator back to the auth subrequest
# instead of the page they asked for.
auth_request_set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
'';
swarmUiVhost = lib.optionalAttrs uiCfg.enable {
"${uiCfg.domain}" = (builtins.removeAttrs (vhostTlsFor uiCfg.domain) [ "addSSL" ]) // {
forceSSL = true;
@ -205,18 +221,39 @@ let
"/" = {
root = "${uiCfg.package}";
extraConfig = ''
auth_request /__hive_authelia;
# Captured BEFORE the error_page jump: inside the 401 handler
# `$request_uri` is the internal one, so building the return
# link there sends the operator back to the auth subrequest
# instead of the page they asked for.
auth_request_set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
${swarmAuthRequest}
# SPA: any path the bundle routes client-side is served the
# entry document rather than a 404 from the filesystem.
try_files $uri /index.html;
'';
};
# swarm-controller's whole HTTP surface, including the live
# `/api/openapi.json` spec — proxied untouched (no URI segment
# after the socket path, same "pass the request through as-is"
# shape as the per-hive dashboard's own `/api/` proxy) so the
# path swarm-controller registered a route at is the path
# nginx forwards, no prefix-stripping to keep in sync by hand.
"/api/" = {
proxyPass = "http://unix:${controllerCfg.socketPath}:";
extraConfig = swarmAuthRequest;
};
# Swagger UI: same "nginx hosts the themed dist straight from
# the store, only /api/openapi.json is dynamic" shape as the
# per-hive gateway's `swaggerUiLocations` — see that block's
# comment for why core-equivalent (here, swarm-controller)
# does not also mount its own copy.
"= /api/docs" = {
extraConfig = ''
return 301 /api/docs/;
'';
};
"/api/docs/" = {
alias = "${swaggerUiTheme}/";
extraConfig = ''
index index.html;
${swarmAuthRequest}
'';
};
# The subrequest itself. `auth-request` is the implementation
# name authelia exposes under `/api/authz/`; `/api/verify` is the
# LEGACY path every older example shows.

View file

@ -14,6 +14,8 @@ axum.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
utoipa.workspace = true
utoipa-axum.workspace = true
[lints]
workspace = true

View file

@ -11,12 +11,23 @@
//!
//! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on
//! one host, this owns what is true across hives.
//!
//! `OpenAPI` spec generation mirrors `hive-c0re/src/dashboard/mod.rs`
//! exactly: `#[utoipa::path(...)]` per handler, an `ApiDoc` root, and a
//! raw JSON route at `/api/openapi.json`. Swagger UI itself is
//! nginx-hosted from the nix store, same shape as the per-hive
//! dashboard's (`nix/host-modules/hive-gateway/vhosts.nix`'s
//! `swarmUiVhost` — a swagger-ui-theme dist under `/api/docs/`, no
//! fallback to this daemon). Only annotated routes appear in the spec;
//! an unannotated one just doesn't show up, nothing breaks.
use std::os::unix::fs::PermissionsExt as _;
use std::path::PathBuf;
use anyhow::{Context, Result};
use axum::{Router, routing::get};
use axum::{Json, routing::get};
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
///
@ -38,8 +49,29 @@ fn socket_path() -> PathBuf {
.map_or_else(|| PathBuf::from(DEFAULT_SOCKET), PathBuf::from)
}
/// Root of the auto-generated `OpenAPI` spec, served raw at
/// `/api/openapi.json` — see the module doc comment above. One tag today
/// (`health`); grows alongside the swarm-level surfaces this daemon picks
/// up, same as `hive-c0re::dashboard::ApiDoc`'s tag list did.
#[derive(OpenApi)]
#[openapi(
info(
title = "hyperhive swarm-controller API",
description = "swarm-controller's HTTP surface, served over its unix \
socket behind the gateway's swarm-UI vhost."
),
tags((name = "health", description = "liveness probe"))
)]
struct ApiDoc;
/// Liveness probe. Returns the build's version so an operator can tell
/// *which* controller answered without shelling onto the host.
#[utoipa::path(
get,
path = "/health",
responses((status = 200, description = "process is up, body is \"swarm-controller <version>\"", body = String)),
tag = "health"
)]
async fn health() -> &'static str {
concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n")
}
@ -82,7 +114,17 @@ async fn main() -> Result<()> {
.with_context(|| format!("chmod {}", path.display()))?;
tracing::info!(socket = %path.display(), "swarm-controller listening");
let app = Router::new().route("/health", get(health));
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(health))
.split_for_parts();
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
// the nix store (see the module doc comment above). `api` is
// `Clone`; each request gets its own owned copy for `Json` to
// serialize, same as `hive-c0re::dashboard::serve`.
let app = router.route(
"/api/openapi.json",
get(move || async move { Json(api.clone()) }),
);
axum::serve(listener, app)
.await
.context("serving swarm-controller")