Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-15 20:19:23 +02:00 • committed by mara
commit 92559af8e7
47 changed files with 351 additions and 623 deletions

View file

@ -1326,7 +1326,7 @@ let
# naming only its own hive: the policy text in the unit is exactly the
# two `update` grants, and each hive's block names neither the other's
# role nor its subject.
name = "each hive's gateway-leaf identity may issue from its own role alone, for its own domain";
name = "each hive's gateway-leaf identity may issue from its own role alone, for its own domain and dashboard";
ok =
let
s = baoGrantTwoHives.systemd.services.swarm-bao-hive-leaf-policy.script;
@ -1341,7 +1341,7 @@ let
h2 = blockOf "h2" "h1";
issues =
h: dom: blk:
lib.hasInfix "allowed_domains='${dom},*.${dom}'" blk
lib.hasInfix "allowed_domains='${dom},*.${dom},${h}.hives.t.local'" blk
&& lib.hasInfix "allow_ip_sans=false" blk
&& lib.hasInfix "allow_localhost=false" blk
&& lib.hasInfix "allow_any_name=false" blk

View file

@ -386,6 +386,60 @@ let
&& lib.hasInfix "auth_request /__hive_authelia;" l.${longest}.extraConfig
&& !(lib.any (name: lib.hasPrefix "= /api/agents" name) (lib.attrNames l));
}
{
# `auth_request` does not inherit across sibling locations, so each
# gated one is read on its own. `/health/` is the control: the one
# c0re route that must stay open, so a pass means the block is
# per-location rather than everywhere.
name = "the dashboard answers on its own name behind authelia, and the hive domain redirects there";
ok =
let
vh = allLocal.services.nginx.virtualHosts;
l = vh."h1.hives.t.local".locations;
gated = loc: lib.hasInfix "auth_request /__hive_authelia;" loc.extraConfig;
in
allLocal.services.hyperhive.swarm.dashboardDomain == "h1.hives.t.local"
&& vh."h1.hives.t.local".forceSSL
&& lib.all (p: gated l.${p}) [
"/"
"/api/"
"/api/docs/"
]
&& lib.hasInfix "error_page 401 =302 https://auth.t.local/?rd=$target_url;" l."/".extraConfig
&& !(lib.hasInfix "auth_request" (l."/health/".extraConfig or ""))
&& l."= /__hive_authelia".proxyPass == "https://auth.t.local/api/authz/auth-request"
&& lib.hasInfix "agents.conf" vh."h1.hives.t.local".extraConfig
&&
lib.hasInfix "return 301 https://h1.hives.t.local$request_uri;"
vh."h1.t.local".locations."/".extraConfig
&& !(vh."h1.t.local".locations ? "/api/")
&& lib.elem "h1.hives.t.local" allLocal.services.hyperhive.gateway.localNames
&& allLocal.systemd.services.hive-c0re.environment.HYPERHIVE_DASHBOARD_DOMAIN == "h1.hives.t.local";
}
{
# The file held bcrypt hashes at 0644; a host carrying one from before
# gets it deleted, and nothing names it for nginx.
name = "the gateway reads no htpasswd, and tmpfiles removes a leftover one";
ok =
lib.elem "r /var/lib/hive-gateway/conf/gateway.htpasswd" allLocal.systemd.tmpfiles.rules
&& !(lib.any (
v:
lib.any (t: lib.hasInfix "auth_basic" t) (
[ (v.extraConfig or "") ] ++ map (loc: loc.extraConfig or "") (lib.attrValues (v.locations or { }))
)
) (lib.attrValues allLocal.services.nginx.virtualHosts));
}
{
name = "the swarm UI's hive roster carries each hive's dashboard name";
ok =
builtins.fromJSON allLocal.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_HIVES == [
{
name = "h1";
domain = "h1.t.local";
dashboard = "h1.hives.t.local";
}
];
}
{
name = "a hive with matrix off serves no matrix discovery endpoint";
ok =

View file

@ -609,7 +609,7 @@ let
in
lib.hasInfix "pki/issue/swarm-hive-leaf-h1" leafUnit.script
&& lib.hasInfix "common_name=h1.t.local" leafUnit.script
&& lib.hasInfix "alt_names='*.h1.t.local'" leafUnit.script
&& lib.hasInfix "alt_names='*.h1.t.local,h1.hives.t.local'" leafUnit.script
&& lib.hasInfix "bao login -method=cert -token-only" leafUnit.script
&& leafUnit.environment.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/hive-leaf.pem"
&& leafUnit.environment.BAO_CLIENT_KEY == "/var/lib/swarm-bao-pki/hive-leaf-key.pem"

View file

@ -783,10 +783,10 @@ let
&& policy "user:nobody" "forge.t.local" "/" == "one_factor";
}
{
# Authelia on a host that runs no swarm controller, so the swarm UI's
# vhost is elsewhere. That vhost still asks this authelia, so the apex's
# pair renders here regardless.
name = "authelia confines the swarm UI to operators on a host without the swarm controller";
# Authelia on a host that runs no swarm controller and no hive, so the
# swarm UI's vhost and the dashboard's are elsewhere. Both still ask
# this authelia, so their pairs render here regardless.
name = "authelia confines the swarm UI and each hive's dashboard to operators on a host running neither";
ok =
let
h = autheliaOnly.services.hyperhive;
@ -795,6 +795,7 @@ let
in
h.deploy.authelia.enable
&& !h.deploy.swarm-controller.enable
&& !h.deploy.hive-controller.enable
&&
lib.filter (r: r.domain == "t.local") rules == [
{
@ -809,7 +810,9 @@ let
]
&& autheliaSubjectRulesClosed rules
&& policy "user:nobody" "t.local" "/api/agents" == "deny"
&& policy "group:admins" "t.local" "/api/agents" == "one_factor";
&& policy "group:admins" "t.local" "/api/agents" == "one_factor"
&& policy "user:nobody" "h1.hives.t.local" "/api/" == "deny"
&& policy "group:admins" "h1.hives.t.local" "/api/" == "one_factor";
}
{
# Every subject-scoped rule the host renders, read in order. The `elem`

View file

@ -366,7 +366,7 @@ let
!(lib.any (t: lib.any (r: lib.hasInfix r t) hiveLeafRefs) texts)
&& m.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile == null
&& noHiveSurface m;
# No hive-domain vhost, and no vhost on the host carrying the
# No hive-domain or dashboard vhost, and no vhost on the host carrying the
# hive's locations: c0re's `/health/`, the agent routes, swagger,
# or the hive domain's matrix discovery and `/matrix/` redirect.
noHiveSurface =
@ -375,6 +375,7 @@ let
vh = m.services.nginx.virtualHosts;
in
!(vh ? "h1.t.local")
&& !(vh ? "h1.hives.t.local")
&& !(lib.any (
v:
lib.any (l: (v.locations or { }) ? ${l}) [
@ -464,15 +465,16 @@ let
&& !(store.services.nginx.virtualHosts ? "h1.t.local")
&& lib.hasInfix "want_svc=1" store.systemd.services.swarm-services-cert.script
# Control: a hive host with the same services keeps the hive leaf,
# its unit and identity, and the hive-domain vhost.
# its unit and identity, the dashboard's vhost and the hive-domain one.
&& hs ? hive-tls-resign
&& lib.hasInfix "install -m 0644 /var/lib/hive-tls/gateway.pem /var/lib/hive-gateway/tls/cert.pem" hs.hive-gateway-self-signed-cert.script
&& swarmServicesHere.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile != null
&& (
let
l = swarmServicesHere.services.nginx.virtualHosts."h1.t.local".locations;
vh = swarmServicesHere.services.nginx.virtualHosts;
l = vh."h1.hives.t.local".locations;
in
l ? "/api/" && l ? "/health/" && l ? "= /.well-known/matrix/client"
l ? "/api/" && l ? "/health/" && vh."h1.t.local".locations ? "= /.well-known/matrix/client"
)
&& !(noHiveLeafAnywhere swarmServicesHere);
}