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:
parent
09ad7386b2
commit
92559af8e7
47 changed files with 351 additions and 623 deletions
|
|
@ -355,7 +355,7 @@ in
|
|||
# - nix build worktrees live in PrivateTmp, not /tmp.
|
||||
# - /etc writes (bind-mount edits) go through hive-priv via the
|
||||
# privileged socket; /etc/hyperhive/serve.json is read-only.
|
||||
# - the gateway's nginx include + htpasswd live under
|
||||
# - the gateway's nginx include lives under
|
||||
# /var/lib/hive-gateway/conf, deliberately outside StateDirectory
|
||||
# (see hive-c0re/src/paths.rs::GATEWAY_CONF_DIR) so nginx never
|
||||
# needs access to the rest of c0re's state — that separation
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ in
|
|||
# required (asserted in hive-network.nix), so it's always set.
|
||||
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.swarm.dashboardDomain != null) {
|
||||
# The host the dashboard and the agent UIs answer on, for the URLs c0re
|
||||
# hands out (`hivectl open home`, each agent's UI link).
|
||||
HYPERHIVE_DASHBOARD_DOMAIN = config.services.hyperhive.swarm.dashboardDomain;
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
|
||||
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@ let
|
|||
|
||||
nginxTree = import ./vhosts.nix {
|
||||
gwLib = cfg.lib;
|
||||
inherit (config.services.hyperhive.swarm) dashboardDomain;
|
||||
autheliaDomain = config.services.hyperhive.swarm.authelia.domain;
|
||||
inherit
|
||||
lib
|
||||
cfg
|
||||
|
|
@ -236,19 +238,25 @@ in
|
|||
# /var/lib/hyperhive.
|
||||
"d /var/lib/hive-gateway 0755 root root - -"
|
||||
"d /var/lib/hive-gateway/conf 0755 hive-core hive-core - -"
|
||||
# Pre-created so nginx's config test can open it before c0re has
|
||||
# ever written: nginx names it, and an `include` of a missing file is
|
||||
# a fatal config error, not an empty one. tmpfiles runs before
|
||||
# services, which is the whole ordering guarantee — content arrives
|
||||
# when c0re writes and reloads, which it does on every topology
|
||||
# change. `f` = create-if-absent, never overwrite.
|
||||
"f /var/lib/hive-gateway/conf/agents.conf 0644 hive-core hive-core - # Generated by hive-c0re — do not edit.\n"
|
||||
# Pre-create both files so nginx's config test can open them before
|
||||
# c0re has ever written: nginx names them, and an `include` of a
|
||||
# missing file is a fatal config error, not an empty one. tmpfiles
|
||||
# runs before services, which is the whole ordering guarantee —
|
||||
# content arrives when c0re writes and reloads, which it does on
|
||||
# every topology change. `f` = create-if-absent, never overwrite.
|
||||
#
|
||||
# An empty htpasswd causes all auth checks to return 401 (no valid
|
||||
# credentials), which is the correct no-users behaviour.
|
||||
"f /var/lib/hive-gateway/conf/gateway.htpasswd 0644 hive-core hive-core - -"
|
||||
# Nothing reads or writes this path. A copy left on an existing host
|
||||
# holds bcrypt password hashes at 0644; tmpfiles' `--remove` pass, on
|
||||
# switch and at boot, deletes it.
|
||||
"r /var/lib/hive-gateway/conf/gateway.htpasswd"
|
||||
];
|
||||
|
||||
# The dashboard's name, for agents on the bridge and (with
|
||||
# `localHostsEntry`) the host's own browser.
|
||||
services.hyperhive.gateway.localNames = lib.optional (
|
||||
runsHive && config.services.hyperhive.swarm.dashboardDomain != null
|
||||
) config.services.hyperhive.swarm.dashboardDomain;
|
||||
|
||||
# ACME (Let's Encrypt) integration. nginx vhosts set
|
||||
# `enableACME = true` via the vhost builder; this provides the
|
||||
# shared ACME config (acceptTerms + email).
|
||||
|
|
|
|||
|
|
@ -67,17 +67,4 @@ in
|
|||
<p class="hint">Check the container: <code>journalctl -M swarm-authelia -u authelia-swarm</code>. This page recovers on reload once the provider is up.</p>
|
||||
'';
|
||||
};
|
||||
|
||||
unauthorized = mkPage {
|
||||
name = "unauthorized";
|
||||
title = "unauthorized";
|
||||
accent = "#f38ba8";
|
||||
body = ''
|
||||
<p>This hive is protected by HTTP Basic auth. Valid credentials are required.</p>
|
||||
<p class="hint">Operator: add a user with <code>hivectl gateway create-user</code>:</p>
|
||||
<pre>hivectl gateway create-user \
|
||||
<username> --password-stdin</pre>
|
||||
<p class="hint">Then reload your browser and enter the credentials when prompted.</p>
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ in
|
|||
mode. Remove the setting; configure `tls.certDir` or `tls.acme`
|
||||
to override the self-signed default.
|
||||
'')
|
||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "gateway" "auth" ] ''
|
||||
The dashboard is served on services.hyperhive.swarm.dashboardDomain
|
||||
behind authelia, and only accounts in authelia's `admins` group can
|
||||
open it. There is no HTTP Basic auth and no htpasswd file. Remove
|
||||
the setting; add an operator with `swarmctl user add <name> --group admins`.
|
||||
'')
|
||||
];
|
||||
|
||||
options.services.hyperhive.gateway = {
|
||||
|
|
@ -169,7 +175,7 @@ in
|
|||
readOnly = true;
|
||||
description = ''
|
||||
Read-only: the gateway's styled static error pages, by name
|
||||
(`notFound`, `unreachable`, `unauthorized`, `ssoUnavailable`).
|
||||
(`notFound`, `unreachable`, `ssoUnavailable`).
|
||||
|
||||
Published so a service module can aim an `error_page` at one
|
||||
instead of rendering its own — a service that built its own
|
||||
|
|
@ -343,32 +349,6 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
auth = {
|
||||
enable = lib.mkEnableOption ''
|
||||
HTTP basic auth on the gateway using an htpasswd file. When
|
||||
enabled, every request to the gateway's main vhost requires a
|
||||
valid username and password. nginx's built-in `auth_basic`
|
||||
module validates credentials against
|
||||
`/var/lib/hive-gateway/conf/gateway.htpasswd`. Off by default.
|
||||
|
||||
Manage users with `hivectl gateway create-user`, `delete-user`,
|
||||
and `list-users` — see `hivectl gateway --help` for usage.
|
||||
The htpasswd file is created automatically when auth is enabled;
|
||||
add at least one user before enabling to avoid locking everyone out.
|
||||
'';
|
||||
|
||||
realm = lib.mkOption {
|
||||
type = lib.types.strMatching "[^\"$]*";
|
||||
default = "hyperhive";
|
||||
example = "my-hive";
|
||||
description = ''
|
||||
HTTP Basic auth `realm` value sent in the `WWW-Authenticate`
|
||||
header when credentials are absent or rejected. Must not
|
||||
contain `"` or `$` (nginx string metacharacters).
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
swaggerUiTheme = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swagger-ui-theme";
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
svcCert, # swarm-services leaf, for names the hive leaf cannot carry
|
||||
svcKey,
|
||||
swarmServiceDomains, # which names those are (../swarm.nix derives it)
|
||||
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable }
|
||||
errorPages, # ./error-pages.nix: { notFound, unreachable, ssoUnavailable }
|
||||
caBundle, # hive TLS trust bundle on the host
|
||||
}:
|
||||
let
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# nginx virtual-host tree for the **hive's own** gateway surface: the `_`
|
||||
# default server, which answers unmatched `Host` with a bare 444, and the
|
||||
# vhost named for the hive domain, which carries the dashboard, per-agent
|
||||
# routing, matrix discovery, the swagger UI and the legacy
|
||||
# `<hive>/matrix/*` redirect.
|
||||
# default server, which answers unmatched `Host` with a bare 444; the
|
||||
# dashboard's vhost (`swarm.dashboardDomain`), which carries the dashboard
|
||||
# behind authelia, per-agent routing and the swagger UI; and the vhost
|
||||
# named for the hive domain, which carries matrix discovery, the legacy
|
||||
# `<hive>/matrix/*` redirect and a redirect of everything else to the
|
||||
# dashboard's name.
|
||||
#
|
||||
# Scope: a swarm service declares its own vhost and dns name in its own
|
||||
# module, built from the kit this file also consumes
|
||||
|
|
@ -18,10 +20,12 @@
|
|||
matrixCfg, # services.hyperhive.swarm.matrix — what the homeserver IS
|
||||
matrixDeployCfg, # services.hyperhive.deploy.matrix — what THIS host runs
|
||||
hyperhiveDomain,
|
||||
dashboardDomain, # services.hyperhive.swarm.dashboardDomain
|
||||
autheliaDomain, # services.hyperhive.swarm.authelia.domain
|
||||
runsHive, # deploy.hive-controller.enable — whether hive-c0re runs here
|
||||
dashboardDist,
|
||||
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css
|
||||
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable }
|
||||
errorPages, # ./error-pages.nix: { notFound, unreachable, ssoUnavailable }
|
||||
gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option
|
||||
}:
|
||||
let
|
||||
|
|
@ -126,15 +130,19 @@ let
|
|||
};
|
||||
};
|
||||
|
||||
# Shared auth block — separate locations don't inherit auth_basic, so
|
||||
# each dashboard location (`/`, `/api/`) needs it or that surface is
|
||||
# unauthed.
|
||||
dashboardAuth = lib.optionalString cfg.auth.enable ''
|
||||
auth_basic "${cfg.auth.realm}";
|
||||
auth_basic_user_file /var/lib/hive-gateway/conf/gateway.htpasswd;
|
||||
# `=401` keeps the status 401 so the login dialog shows; the
|
||||
# internal page explains `hivectl gateway create-user`.
|
||||
error_page 401 =401 /__hive_auth_unauthorized;
|
||||
# Repeated verbatim by every operator-gated location (`/`, `/api/`,
|
||||
# `/api/docs/`): nginx's `auth_request` does NOT inherit across sibling
|
||||
# locations, so a location without it is reachable with no session.
|
||||
#
|
||||
# ⚠️ `auth_request` answers "is there a session", not "is this an
|
||||
# operator". authelia's `operatorsOnly` rule for this name
|
||||
# (../swarm-authelia.nix) is what refuses an agent's own account.
|
||||
dashboardAuth = ''
|
||||
auth_request /__hive_authelia;
|
||||
# Captured BEFORE the error_page jump: inside the 401 handler
|
||||
# `$request_uri` is the internal one.
|
||||
auth_request_set $target_url $scheme://$http_host$request_uri;
|
||||
error_page 401 =302 https://${autheliaDomain}/?rd=$target_url;
|
||||
'';
|
||||
|
||||
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
|
||||
|
|
@ -167,8 +175,8 @@ let
|
|||
'';
|
||||
};
|
||||
"/health/" = {
|
||||
# No dashboardAuth here: an external uptime monitor generally can't do
|
||||
# interactive HTTP Basic. The endpoints themselves are scoped to
|
||||
# No dashboardAuth here: an external uptime monitor can't do an
|
||||
# interactive login. The endpoints themselves are scoped to
|
||||
# status + warning kind/message (see hive-c0re/src/dashboard/
|
||||
# health.rs) — no tokens, no agent detail — so exposing them
|
||||
# unauthenticated isn't a new secret surface.
|
||||
|
|
@ -206,6 +214,29 @@ let
|
|||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# The subrequest `dashboardAuth` makes, the same as the swarm UI's
|
||||
# (../swarm-ui.nix), which documents the header set.
|
||||
autheliaLocation = {
|
||||
"= /__hive_authelia" = {
|
||||
proxyPass = "https://${autheliaDomain}/api/authz/auth-request";
|
||||
# nixpkgs appends its OWN `Host $host` after extraConfig, which would
|
||||
# override verifiedProxyTo's — see the comment on verifiedProxyTo in
|
||||
# ./vhost-lib.nix.
|
||||
recommendedProxySettings = false;
|
||||
extraConfig = ''
|
||||
internal;
|
||||
${gwLib.verifiedProxyTo autheliaDomain}
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
'';
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
virtualHosts = {
|
||||
|
|
@ -237,32 +268,26 @@ in
|
|||
'';
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs runsHive {
|
||||
# The hive domain's own vhost, reachable by NAME, so the catch-all can
|
||||
# reject everything else. Only where a hive runs: the dashboard, agent
|
||||
# routes and swagger UI proxy to hive-c0re, which runs only with the hive
|
||||
# controller, and the certificate naming the hive domain is the hive
|
||||
# leaf, which exists only there.
|
||||
${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // {
|
||||
// lib.optionalAttrs (runsHive && dashboardDomain != null) {
|
||||
# The dashboard, reachable by its own NAME under the swarm domain, where
|
||||
# authelia's session cookie reaches. Only where a hive runs: the
|
||||
# dashboard, agent routes and swagger UI proxy to hive-c0re, which runs
|
||||
# only with the hive controller, and the hive leaf carrying this name
|
||||
# exists only there.
|
||||
#
|
||||
# The agent routes live here and not on the hive domain because the
|
||||
# agent page calls the dashboard's `/api/` on its own origin
|
||||
# (frontend/packages/agent/src/lib/dashboardBase.ts), and the dashboard
|
||||
# links to them by path. They carry no `auth_request`.
|
||||
#
|
||||
# `forceSSL` for the reason ../swarm-ui.nix gives: authelia answers the
|
||||
# subrequest for an `http://` target with 400, which `auth_request`
|
||||
# cannot read. `removeAttrs` because nixos asserts on a vhost declaring
|
||||
# both.
|
||||
${dashboardDomain} = (builtins.removeAttrs (vhostTlsFor dashboardDomain) [ "addSSL" ]) // {
|
||||
forceSSL = true;
|
||||
listen = vhostListen;
|
||||
locations =
|
||||
matrixRedirectLocations
|
||||
// wellKnownLocations
|
||||
// agentLocations
|
||||
// dashboardProxyLocation
|
||||
// swaggerUiLocations
|
||||
// lib.optionalAttrs cfg.auth.enable {
|
||||
# Internal-only target for the dashboard's 401 error_page.
|
||||
# `internal` prevents direct client access; `alias` serves
|
||||
# the pre-built HTML from the Nix store.
|
||||
"= /__hive_auth_unauthorized" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
alias ${errorPages.unauthorized};
|
||||
default_type text/html;
|
||||
'';
|
||||
};
|
||||
};
|
||||
locations = agentLocations // dashboardProxyLocation // swaggerUiLocations // autheliaLocation;
|
||||
# Per-agent location blocks, generated at runtime by
|
||||
# hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf
|
||||
# on the host — the same machine nginx runs on. nginx parses
|
||||
|
|
@ -275,5 +300,21 @@ in
|
|||
include /var/lib/hive-gateway/conf/agents.conf;
|
||||
'';
|
||||
};
|
||||
|
||||
# The hive domain's own vhost: matrix discovery, which federation
|
||||
# reads off the server name, and a redirect of every other path to the
|
||||
# dashboard's name, so a bookmarked dashboard or agent link still lands.
|
||||
${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // {
|
||||
listen = vhostListen;
|
||||
locations =
|
||||
matrixRedirectLocations
|
||||
// wellKnownLocations
|
||||
// {
|
||||
"/".extraConfig = ''
|
||||
return 301 ${publicScheme}://${dashboardDomain}${publicPortSuffix}$request_uri;
|
||||
'';
|
||||
};
|
||||
extraConfig = securityHeaders;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,15 +63,23 @@ let
|
|||
|
||||
# The role this hive's gateway leaf is issued through. ./swarm-bao.nix
|
||||
# writes one per hive in the swarm directory, under the same prefix, and
|
||||
# narrows it to that hive's domain and `*.` that domain. So the leaf carries
|
||||
# those two names and no others: a configured service name goes on the
|
||||
# swarm-services leaf below, and asking this role for one is a refusal.
|
||||
# narrows it to that hive's domain, `*.` that domain and its dashboard name.
|
||||
# So the leaf carries those names and no others: a configured service name
|
||||
# goes on the swarm-services leaf below, and asking this role for one is a
|
||||
# refusal.
|
||||
hiveLeafRole =
|
||||
if hyperhiveCfg.hiveName == null then
|
||||
null
|
||||
else
|
||||
"${baoDeploy.hiveLeafPkiRolePrefix}-${hyperhiveCfg.hiveName}";
|
||||
|
||||
# Everything after the first rides as a SAN.
|
||||
hiveLeafNames = [
|
||||
domain
|
||||
"*.${domain}"
|
||||
]
|
||||
++ lib.optional (hyperhiveCfg.swarm.dashboardDomain != null) hyperhiveCfg.swarm.dashboardDomain;
|
||||
|
||||
showPath = p: if p == null then "unset" else p;
|
||||
|
||||
# Fails the unit, naming both options, when an identity is unset or a file
|
||||
|
|
@ -159,7 +167,7 @@ let
|
|||
leafCoverage = ''
|
||||
svcleaf="$d/swarm-services.pem"
|
||||
svcroot="$d/swarm-services-root.pem"
|
||||
hiveNames=${lib.escapeShellArg "${domain} *.${domain}"}
|
||||
hiveNames=${lib.escapeShellArg (lib.concatStringsSep " " hiveLeafNames)}
|
||||
svcNames=${lib.escapeShellArg (lib.concatStringsSep " " localServiceDomains)}
|
||||
|
||||
# A host fronting none of the swarm's service names has no services
|
||||
|
|
@ -526,7 +534,7 @@ in
|
|||
if ! bao write -format=json \
|
||||
${lib.escapeShellArg "${baoServicesPkiMount}/issue/${toString hiveLeafRole}"} \
|
||||
common_name=${lib.escapeShellArg domain} \
|
||||
alt_names=${lib.escapeShellArg "*.${domain}"} \
|
||||
alt_names=${lib.escapeShellArg (lib.concatStringsSep "," (lib.tail hiveLeafNames))} \
|
||||
> "$resp" 2>"$err"; then
|
||||
fail "the store refused to issue the gateway leaf: $(cat "$err")"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -12,10 +12,9 @@
|
|||
# `docs/swarm/sso.md` owns the role model — session provider always, OIDC
|
||||
# provider derived from the client list — and the secrets map.
|
||||
#
|
||||
# Per-service integration — putting authelia's `auth_request` in front
|
||||
# of the gateway's existing `auth_basic` locations — is deliberately NOT
|
||||
# here. Standing an SSO provider up is reversible; cutting every
|
||||
# operator-facing vhost over to it is not.
|
||||
# Per-service integration — the `auth_request` in front of a vhost —
|
||||
# lives in the module declaring that vhost. What lives here is the
|
||||
# `access_control` rule each of them relies on.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
|
|
@ -799,9 +798,8 @@ in
|
|||
# STARTUP, so a mismatch is a container that refuses to boot rather
|
||||
# than a login that misbehaves.
|
||||
#
|
||||
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
|
||||
# gateway's `auth_basic`; applying it to the SSO provider would put
|
||||
# the login page behind the login mechanism it exists to replace.
|
||||
# ⚠️ And deliberately NO `auth_request` here: it would put the login
|
||||
# page behind the login it serves.
|
||||
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
|
|
@ -1215,7 +1213,13 @@ in
|
|||
# one, and without these rules any session passes it.
|
||||
++ lib.optionals (swarmDomain != null) (operatorsOnly {
|
||||
domain = hyperhiveCfg.swarm.bao.ui.domain;
|
||||
});
|
||||
})
|
||||
# Every hive's dashboard, each on its own hive's host.
|
||||
++ lib.optionals (swarmDomain != null) (
|
||||
lib.concatMap (domain: operatorsOnly { inherit domain; }) (
|
||||
lib.attrValues hyperhiveCfg.swarm.dashboardDomains
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
# The cookie domain is the SWARM's domain, NOT authelia's
|
||||
|
|
@ -1227,13 +1231,9 @@ in
|
|||
# setting both to the same host fails validation at startup
|
||||
# rather than at first login.
|
||||
#
|
||||
# ⚠️ Known and accepted consequence while a hive keeps a
|
||||
# domain outside the swarm's tree: this cookie is NOT sent
|
||||
# to that hive's own surfaces (its dashboard), so SSO
|
||||
# covers the swarm's services and not the hive's. It
|
||||
# resolves when the hive domain moves under the swarm
|
||||
# domain; until then it is a scope limit, not a bug to
|
||||
# chase.
|
||||
# Each hive's dashboard answers on `swarm.dashboardDomains`,
|
||||
# under this domain, so the cookie reaches it whatever the
|
||||
# hive's own domain is.
|
||||
session.cookies = [
|
||||
{
|
||||
domain = cookieDomain;
|
||||
|
|
|
|||
|
|
@ -789,6 +789,7 @@ let
|
|||
name = "${baoDeploy.hiveLeafPkiRolePrefix}-${hiveName}";
|
||||
cn = "${baoDeploy.hiveLeafCommonNamePrefix}-${hiveName}";
|
||||
inherit (hive) domain;
|
||||
dashboard = hyperhiveCfg.swarm.dashboardDomains.${hiveName};
|
||||
}) hyperhiveCfg.swarm.hives;
|
||||
|
||||
# The PKI mount agent client certificates are issued from. Its root is
|
||||
|
|
@ -3453,14 +3454,15 @@ in
|
|||
|
||||
# A SIXTH sibling, the nats unit's shape once per hive: each hive's
|
||||
# gateway-leaf role, its policy and its login role. The role allows the
|
||||
# hive's domain and `*.` that domain, and no IP SANs or localhost.
|
||||
# hive's domain, `*.` that domain and the hive's dashboard name, and no
|
||||
# IP SANs or localhost.
|
||||
systemd.services.swarm-bao-hive-leaf-policy =
|
||||
granterPolicyUnit "write the per-hive gateway-leaf pki roles, bao policies and cert-auth roles"
|
||||
(
|
||||
lib.concatMapStrings (obj: ''
|
||||
|
||||
bao write ${lib.escapeShellArg "${servicesPkiMountPath}/roles/${obj.name}"} \
|
||||
allowed_domains=${lib.escapeShellArg "${obj.domain},*.${obj.domain}"} \
|
||||
allowed_domains=${lib.escapeShellArg "${obj.domain},*.${obj.domain},${obj.dashboard}"} \
|
||||
allow_bare_domains=true \
|
||||
allow_subdomains=false \
|
||||
allow_glob_domains=true \
|
||||
|
|
|
|||
|
|
@ -795,6 +795,7 @@ in
|
|||
lib.mapAttrsToList (name: h: {
|
||||
inherit name;
|
||||
inherit (h) domain;
|
||||
dashboard = config.services.hyperhive.swarm.dashboardDomains.${name};
|
||||
}) config.services.hyperhive.swarm.hives
|
||||
);
|
||||
# The merged links list — see `links`' description above for who
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# The swarm-level web UI: a static bundle served by the gateway's nginx,
|
||||
# behind authelia. Distinct from the per-hive dashboard (hive-c0re's, on
|
||||
# the hive domain) — this one answers for the swarm apex and is the
|
||||
# `swarm.dashboardDomain`) — this one answers for the swarm apex and is the
|
||||
# operator's view across hives.
|
||||
#
|
||||
# Options only. The vhost itself is declared in hive-gateway/vhosts.nix
|
||||
|
|
@ -105,19 +105,20 @@ in
|
|||
config = lib.mkIf deployCfg.swarm-controller.enable {
|
||||
assertions = [
|
||||
{
|
||||
# The `_` default server already answers for the hive domain
|
||||
# (dashboard, per-agent routes). A second vhost claiming the same
|
||||
# The hive domain has its own vhost (matrix discovery, the redirect
|
||||
# to the dashboard). A second vhost claiming the same
|
||||
# server_name is not an error to nginx — it picks one and logs a
|
||||
# conflict — so the failure would surface as "the dashboard is
|
||||
# sometimes the swarm UI", which is far harder to read than an
|
||||
# conflict — so the failure would surface as "the swarm UI is
|
||||
# sometimes a redirect", which is far harder to read than an
|
||||
# eval failure naming both options.
|
||||
assertion = apex != hiveDomain;
|
||||
message = ''
|
||||
services.hyperhive.swarm.domain (${apex}) must differ from
|
||||
services.hyperhive.domain (${hiveDomain}) — the hive domain is
|
||||
already served by the gateway's default vhost (dashboard +
|
||||
agent routes), and two vhosts claiming one server_name
|
||||
silently resolve to whichever nginx picks.
|
||||
already served by the gateway's own vhost for it (matrix
|
||||
discovery, the redirect to the dashboard), and two vhosts
|
||||
claiming one server_name silently resolve to whichever nginx
|
||||
picks.
|
||||
|
||||
Set services.hyperhive.swarm.domain to a name distinct from
|
||||
this hive's.
|
||||
|
|
@ -154,8 +155,7 @@ in
|
|||
}
|
||||
];
|
||||
|
||||
# The swarm's front page, and the FIRST `auth_request` anywhere in
|
||||
# this gateway (everything else is `auth_basic` + htpasswd).
|
||||
# The swarm's front page.
|
||||
#
|
||||
# ⚠️ `auth_request` answers "is there a session", not "is this an
|
||||
# operator". The operator-only part is authelia's `access_control`
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@ let
|
|||
# `swarm.nats.domain` is deliberately absent: its `/metrics` vhost names
|
||||
# the queue's own leaf instead (./swarm-nats.nix), so a host running only
|
||||
# the queue fronts no services name and never waits for that leaf.
|
||||
#
|
||||
# So are the hives' `dashboardDomains`: each is a SAN on its own hive's
|
||||
# leaf, which that hive issues with the identity it already holds.
|
||||
serviceDomains' = [
|
||||
swarmCfg.forge.domain
|
||||
swarmCfg.matrix.gatewayHost
|
||||
|
|
@ -264,6 +267,31 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.swarm.dashboardDomains = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
readOnly = true;
|
||||
internal = true;
|
||||
description = ''
|
||||
Read-only: each hive's dashboard hostname, keyed by hive name,
|
||||
`<hive>.hives.<swarm.domain>`. Under the swarm domain so authelia's
|
||||
session cookie reaches it, whatever the hive's own domain is.
|
||||
|
||||
Derived for every hive in `hives`, not only this one: the store's
|
||||
host writes each hive's leaf role from it, and authelia's host writes
|
||||
the operator-only rule for all of them.
|
||||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.swarm.dashboardDomain = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
readOnly = true;
|
||||
internal = true;
|
||||
description = ''
|
||||
Read-only: this hive's entry in `dashboardDomains`, or null where
|
||||
`hiveName` has no entry in `hives` (asserted below).
|
||||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.swarm.serviceDomains = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
readOnly = true;
|
||||
|
|
@ -305,6 +333,19 @@ in
|
|||
};
|
||||
|
||||
config = {
|
||||
# Total on a null `swarm.domain` for the reason `hives.<name>.domain`'s
|
||||
# default gives.
|
||||
services.hyperhive.swarm.dashboardDomains = lib.mapAttrs (
|
||||
name: _:
|
||||
if swarmCfg.domain == null then "${name}.hives.invalid" else "${name}.hives.${swarmCfg.domain}"
|
||||
) swarmCfg.hives;
|
||||
|
||||
services.hyperhive.swarm.dashboardDomain =
|
||||
if cfg.hiveName != null && swarmCfg.dashboardDomains ? ${cfg.hiveName} then
|
||||
swarmCfg.dashboardDomains.${cfg.hiveName}
|
||||
else
|
||||
null;
|
||||
|
||||
services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) (
|
||||
lib.unique (lib.filter (d: d != null && d != "") serviceDomains')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue