From 4828c969579e32553cf2baf86d10a4ee0aa9a2c5 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 12 Aug 2026 21:18:16 +0200 Subject: [PATCH] swarm-controller: OpenAPI spec + gateway swagger UI wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 2 + nix/host-modules/hive-gateway/default.nix | 2 + nix/host-modules/hive-gateway/vhosts.nix | 51 +++++++++++++++++++---- swarm-controller/Cargo.toml | 2 + swarm-controller/src/main.rs | 46 +++++++++++++++++++- 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3deb3509..57e40e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4430,6 +4430,8 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-axum", ] [[package]] diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 9f3b4306..86ed16d3 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -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 diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index f872abb7..4678a025 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -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. diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 877efecd..038587e8 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -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 diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index ddc14a01..cea02ed4 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -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 \"", 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")