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:
parent
8642d4acf6
commit
4828c96957
5 changed files with 94 additions and 9 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -4430,6 +4430,8 @@ dependencies = [
|
|||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"utoipa-axum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue