Watch
0
0
Fork
You've already forked hyperhive
0

gateway: service modules declare their vhost under gateway.vhosts

Ten service modules (forge, matrix chat, authelia, grafana, otel, nats,
swarm-ui, victorialogs, victoriametrics, bao UI) wrote
services.nginx.virtualHosts themselves, each applying the gateway's
listen set, per-name TLS attrs and security headers by hand. They now
declare services.hyperhive.gateway.vhosts.<name> with only their own
locations and options; ./declared-vhosts.nix renders it into the nginx
vhost with listen, TLS and headers added.

An eval assertion refuses a declared vhost that sets listen, addSSL,
onlySSL, enableACME, useACMEHost, sslCertificate or sslCertificateKey,
and one declared while the gateway is off. nats' own leaf is the new
`certificate` field. The rendered nginx vhosts are unchanged.
This commit is contained in:
atlas 2026-10-16 10:20:13 +02:00 • committed by mara
commit ddb583cc22
17 changed files with 652 additions and 513 deletions

View file

@ -22,6 +22,8 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo
| `<queue domain>/metrics` | the queue exporter's `/metrics` on loopback, behind an authelia `auth_request`; every other path, `/` included, returns `403`, so `/varz` and `/connz` are never reachable | `swarm-nats.nix`, the queue's host | | `<queue domain>/metrics` | the queue exporter's `/metrics` on loopback, behind an authelia `auth_request`; every other path, `/` included, returns `403`, so `/varz` and `/connz` are never reachable | `swarm-nats.nix`, the queue's host |
| `grafana.<swarm>`, `metrics.<swarm>`, `logs.<swarm>`, `otel.<swarm>`, `bao.<swarm>` | the matching swarm service | that service's module → [`swarm/services.md`](../swarm/services.md) | | `grafana.<swarm>`, `metrics.<swarm>`, `logs.<swarm>`, `otel.<swarm>`, `bao.<swarm>` | the matching swarm service | that service's module → [`swarm/services.md`](../swarm/services.md) |
A service module declares its vhost as `services.hyperhive.gateway.vhosts.<name>`: the locations, `forceSSL` and any `extraConfig` it needs. The gateway renders it into `services.nginx.virtualHosts.<name>` and adds the `listen` set, the certificate for that name and the [security headers](#security-headers) (ahead of the vhost's own `extraConfig`), and drops `addSSL` from a vhost that sets `forceSSL`. The eval fails for a vhost that sets `listen`, `addSSL`, `onlySSL`, `enableACME`, `useACMEHost`, `sslCertificate` or `sslCertificateKey` itself, and for any vhost declared while `gateway.enable` is false. A name with a certificate from outside the gateway's leaves (the queue's) sets `certificate = { cert; key; }` instead.
**The hive's own vhosts**: the dashboard's, named `<name>.hives.<swarm>` (`swarm.dashboardDomain`), and one named for the hive domain. They exist only on a host that runs a hive (`deploy.hive-controller.enable`); a host without a hive has none of these rows: **The hive's own vhosts**: the dashboard's, named `<name>.hives.<swarm>` (`swarm.dashboardDomain`), and one named for the hive domain. They exist only on a host that runs a hive (`deploy.hive-controller.enable`); a host without a hive has none of these rows:
| URL | upstream | when | | URL | upstream | when |

View file

@ -261,9 +261,8 @@ in
# This service's own gateway surface: the vhost that fronts it and # This service's own gateway surface: the vhost that fronts it and
# the name the hive resolver answers for. Declared here rather than # the name the hive resolver answers for. Declared here rather than
# in the gateway so the forge's public face lives with the forge — # in the gateway so the forge's public face lives with the forge —
# the gateway supplies the primitives (`lib.listen`, `lib.tlsFor`, # the gateway adds `listen`, the certificate and the security headers
# `lib.securityHeaders`) and never needs to know this service by # (`gateway.vhosts`) and never needs to know this service by name.
# name.
services.hyperhive.gateway.localNames = [ cfg.domain ]; services.hyperhive.gateway.localNames = [ cfg.domain ];
services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.enable = lib.mkDefault true;
@ -274,10 +273,8 @@ in
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
# clones). SSH stays direct on `forge.sshPort`. See # clones). SSH stays direct on `forge.sshPort`. See
# `docs/networking/gateway.md`. # `docs/networking/gateway.md`.
services.nginx.virtualHosts = { services.hyperhive.gateway.vhosts = {
"${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { "${cfg.domain}" = {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = { locations = {
"/" = { "/" = {
proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/"; proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/";

View file

@ -0,0 +1,85 @@
# Renders `services.hyperhive.gateway.vhosts` into
# `services.nginx.virtualHosts`: each declared vhost keeps what its module
# wrote and gets the gateway's `listen`, the TLS attributes for its name and
# the security headers.
#
# Its own module rather than part of ./default.nix because the
# "declared while the gateway is off" assertion has to hold when
# `gateway.enable` is false, where ./default.nix defines nothing.
{ lib, config, ... }:
let
cfg = config.services.hyperhive.gateway;
# What the gateway owns on a vhost. A declared vhost naming one of these
# fails the eval.
gatewayOwned = [
"listen"
"addSSL"
"onlySSL"
"enableACME"
"useACMEHost"
"sslCertificate"
"sslCertificateKey"
];
offenders = lib.concatMap (
name: map (attr: "${name}.${attr}") (lib.filter (a: cfg.vhosts.${name} ? ${a}) gatewayOwned)
) (lib.attrNames cfg.vhosts);
render =
name: declared:
let
own = removeAttrs declared [
"_module"
"certificate"
];
base =
if declared.certificate != null then
{
sslCertificate = declared.certificate.cert;
sslCertificateKey = declared.certificate.key;
}
else
cfg.lib.tlsFor name;
# nixos asserts on a vhost declaring both `addSSL` and `forceSSL`.
tls = if own.forceSSL or false then removeAttrs base [ "addSSL" ] else base;
in
tls
// own
// {
listen = cfg.lib.listen;
extraConfig = cfg.lib.securityHeaders + (own.extraConfig or "");
};
in
{
config = lib.mkMerge [
{
assertions = [
{
assertion = cfg.enable || cfg.vhosts == { };
message = ''
services.hyperhive.gateway.vhosts declares ${lib.concatStringsSep ", " (lib.attrNames cfg.vhosts)}
while services.hyperhive.gateway.enable is false, so nothing
would serve them. Enable the gateway, or drop the service that
declares the vhost.
'';
}
{
assertion = offenders == [ ];
message = ''
services.hyperhive.gateway.vhosts sets attributes the gateway owns:
${lib.concatStringsSep ", " offenders}
The gateway sets the listen set and the TLS attributes of every
vhost it fronts. Declare only the vhost's own locations and
options; to serve a certificate the gateway does not pick, set
`certificate` on the vhost.
'';
}
];
}
(lib.mkIf cfg.enable {
services.nginx.virtualHosts = lib.mapAttrs render cfg.vhosts;
})
];
}

View file

@ -6,7 +6,8 @@
# namespace of its own. # namespace of its own.
# Full vhost map + discovery flow + design rationale in # Full vhost map + discovery flow + design rationale in
# `docs/networking/gateway.md`. Layout: ./options.nix (option declarations), # `docs/networking/gateway.md`. Layout: ./options.nix (option declarations),
# ./vhosts.nix (the nginx virtual-host tree), ./error-pages.nix # ./vhosts.nix (the gateway's own nginx virtual hosts), ./declared-vhosts.nix
# (renders the vhosts service modules declare under `gateway.vhosts`), ./error-pages.nix
# (styled static pages), ./dns.nix + ./dnsmasq.nix (the resolver, which # (styled static pages), ./dns.nix + ./dnsmasq.nix (the resolver, which
# has an enable of its own because a host can need hive names to resolve # has an enable of its own because a host can need hive names to resolve
# without serving a vhost). # without serving a vhost).
@ -134,6 +135,7 @@ in
imports = [ imports = [
./options.nix ./options.nix
./dns.nix ./dns.nix
./declared-vhosts.nix
]; ];
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {

View file

@ -136,6 +136,61 @@ in
''; '';
}; };
vhosts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
# Every other attribute is an nginx vhost option, passed through
# to `services.nginx.virtualHosts.<name>` as written.
freeformType = lib.types.attrsOf lib.types.anything;
options.certificate = lib.mkOption {
type = lib.types.nullOr (
lib.types.submodule {
options = {
cert = lib.mkOption {
type = lib.types.str;
description = "Path of the certificate nginx serves for this name.";
};
key = lib.mkOption {
type = lib.types.str;
description = "Path of the private key for `cert`. A path, never a value.";
};
};
}
);
default = null;
description = ''
A certificate pair to serve instead of the one the gateway
picks for this name (`gateway.lib.tlsFor`). For a name whose
certificate is issued outside the gateway's own leaves.
'';
};
}
);
default = { };
internal = true;
description = ''
The nginx vhosts that sit behind this gateway, by server name. A
service module declares its own — locations, `forceSSL`, extra
config — and the gateway renders it into
`services.nginx.virtualHosts.<name>` with the parts it owns:
- `listen`, the gateway's port pair;
- the TLS attributes for that name (`gateway.lib.tlsFor`, or
`certificate` below), with `addSSL` dropped when the vhost sets
`forceSSL`;
- `securityHeaders`, placed ahead of the vhost's own `extraConfig`.
A vhost that sets `listen`, `addSSL`, `onlySSL`, `enableACME`,
`useACMEHost`, `sslCertificate` or `sslCertificateKey` itself fails
the eval: those are the gateway's, and a service that sets them
can bind a port or present a certificate the rest of the gateway
does not.
A vhost declared while the gateway is off fails the eval too: the
name would otherwise be silently unserved.
'';
};
lib = { lib = {
listen = lib.mkOption { listen = lib.mkOption {
type = lib.types.listOf (lib.types.attrsOf lib.types.raw); type = lib.types.listOf (lib.types.attrsOf lib.types.raw);
@ -144,11 +199,10 @@ in
description = '' description = ''
Read-only: the `listen` set every vhost in front of this Read-only: the `listen` set every vhost in front of this
gateway shares (plain http on `port`, TLS on `httpsPort`). gateway shares (plain http on `port`, TLS on `httpsPort`).
Published so a service module can declare its own vhost Applied to every vhost declared under `vhosts`, and read by
without restating the port pair — a vhost that binds a the gateway's own vhosts, so no vhost binds a different
different set is reachable on a port the gateway does not set — one that did would be reachable on a port the gateway
consider its own, which is the kind of drift nobody notices does not consider its own.
until one name behaves differently from the rest.
''; '';
}; };
@ -163,8 +217,8 @@ in
service's: a swarm service's name can sit outside this hive's service's: a swarm service's name can sit outside this hive's
domain, and the hive leaf's role refuses it, so that domain, and the hive leaf's role refuses it, so that
vhost must serve the swarm-services leaf while everything else vhost must serve the swarm-services leaf while everything else
keeps the hive leaf. A service module calls this instead of keeps the hive leaf. The gateway calls this for every vhost it
deciding — deciding is how the vhost and the cert stop fronts — a vhost choosing its own is how the vhost and the cert stop
agreeing. agreeing.
''; '';
}; };

View file

@ -2,18 +2,17 @@
# attrs, and the security headers every vhost in front of this gateway # attrs, and the security headers every vhost in front of this gateway
# needs. # needs.
# #
# Split out of ./vhosts.nix so it has one home and two consumers. Today # Split out of ./vhosts.nix so it has one home and several consumers.
# only ./vhosts.nix reads it; it is published as # ./vhosts.nix and ./declared-vhosts.nix (which renders the vhosts service
# `services.hyperhive.gateway.lib` (see ./options.nix) so a service # modules declare under `gateway.vhosts`) read it through
# module can declare its OWN vhost without the gateway having to know # `services.hyperhive.gateway.lib` (see ./options.nix) rather than calling
# that service by name. `vhosts.nix` reads the published value rather # this file directly — one source, not a copy that agrees by inspection. A
# than calling this file directly — one source, not a copy that agrees # service module reads `verifiedProxyTo`, `errorPages` and `securityHeaders`
# by inspection. # from there for the locations it writes.
# #
# Pure function of the gateway's config + resolved cert paths; returns # Pure function of the gateway's config + resolved cert paths; returns
# an attrset, evaluates no options itself. Everything here is *gateway* # an attrset, evaluates no options itself. Everything here is *gateway*
# knowledge — which port pair to bind, which issuer covers a name — and # knowledge — which port pair to bind, which issuer covers a name.
# stays here even once the service vhosts move out to their own modules.
{ {
lib, lib,
cfg, # services.hyperhive.gateway cfg, # services.hyperhive.gateway

View file

@ -7,10 +7,9 @@
# dashboard's name. # dashboard's name.
# #
# Scope: a swarm service declares its own vhost and dns name in its own # Scope: a swarm service declares its own vhost and dns name in its own
# module, built from the kit this file also consumes # module, as `services.hyperhive.gateway.vhosts.<name>` (rendered by
# (`services.hyperhive.gateway.lib`, ./vhost-lib.nix). That keeps a # ./declared-vhosts.nix). That keeps a service's gateway surface next to
# service's gateway surface next to the service, and keeps this file to # the service, and keeps this file to the surface the hive itself serves.
# the surface the hive itself serves.
# #
# Pure function — called from ./default.nix with the outer-scope config # Pure function — called from ./default.nix with the outer-scope config
# values as arguments; returns `{ virtualHosts }`. # values as arguments; returns `{ virtualHosts }`.

View file

@ -477,10 +477,8 @@ in
# be served at the *server name*, which is the hive domain — it is # be served at the *server name*, which is the hive domain — it is
# the hive answering "where is my homeserver", not the homeserver # the hive answering "where is my homeserver", not the homeserver
# answering for itself. # answering for itself.
services.nginx.virtualHosts = lib.optionalAttrs (cfg.gatewayHost != null) { services.hyperhive.gateway.vhosts = lib.optionalAttrs (cfg.gatewayHost != null) {
"${cfg.gatewayHost}" = (gatewayCfg.lib.tlsFor cfg.gatewayHost) // { "${cfg.gatewayHost}" = {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = { locations = {
"/_matrix/" = { "/_matrix/" = {
# No URI after the socket, so tuwunel gets the request URI as the # No URI after the socket, so tuwunel gets the request URI as the

View file

@ -800,9 +800,7 @@ in
# #
# ⚠️ And deliberately NO `auth_request` here: it would put the login # ⚠️ And deliberately NO `auth_request` here: it would put the login
# page behind the login it serves. # page behind the login it serves.
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { services.hyperhive.gateway.vhosts."${cfg.domain}" = {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations."/" = { locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/"; proxyPass = "http://127.0.0.1:${toString cfg.port}/";
proxyWebsockets = true; proxyWebsockets = true;

View file

@ -2716,40 +2716,35 @@ in
# netns), so no port opens anywhere. `auth_request` only proves a # netns), so no port opens anywhere. `auth_request` only proves a
# session; restricting it to `admins` is ./swarm-authelia.nix's rule # session; restricting it to `admins` is ./swarm-authelia.nix's rule
# for this name. `forceSSL` because authelia answers a plain-http # for this name. `forceSSL` because authelia answers a plain-http
# subrequest with 400, and `removeAttrs` because nixos refuses # subrequest with 400.
# `addSSL` beside it. services.hyperhive.gateway.vhosts.${cfg.ui.domain} = {
services.nginx.virtualHosts.${cfg.ui.domain} = forceSSL = true;
(builtins.removeAttrs (hyperhiveCfg.gateway.lib.tlsFor cfg.ui.domain) [ "addSSL" ]) locations = {
// { "/" = {
forceSSL = true; proxyPass = "http://127.0.0.1:${toString baoDeploy.uiProxyPort}";
listen = hyperhiveCfg.gateway.lib.listen; extraConfig = swarmAuthRequest;
extraConfig = hyperhiveCfg.gateway.lib.securityHeaders; };
locations = {
"/" = {
proxyPass = "http://127.0.0.1:${toString baoDeploy.uiProxyPort}";
extraConfig = swarmAuthRequest;
};
"= /__hive_authelia" = { "= /__hive_authelia" = {
proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request"; proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, # nixpkgs appends its OWN `Host $host` after extraConfig,
# which would override verifiedProxyTo's — see the comment # which would override verifiedProxyTo's — see the comment
# on verifiedProxyTo in hive-gateway/vhost-lib.nix. # on verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false; recommendedProxySettings = false;
extraConfig = '' extraConfig = ''
internal; internal;
${hyperhiveCfg.gateway.lib.verifiedProxyTo autheliaCfg.domain} ${hyperhiveCfg.gateway.lib.verifiedProxyTo autheliaCfg.domain}
proxy_pass_request_body off; proxy_pass_request_body off;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
''; '';
};
}; };
}; };
};
# THE delivery unit for the forwarder's own OIDC client secret, copied # THE delivery unit for the forwarder's own OIDC client secret, copied
# in shape from ./swarm-otel.nix's `swarm-bao-otel-oidc`: a cert login # in shape from ./swarm-otel.nix's `swarm-bao-otel-oidc`: a cert login

View file

@ -558,9 +558,7 @@ in
# Websockets: Grafana Live streams panel updates over one, and without # Websockets: Grafana Live streams panel updates over one, and without
# the upgrade headers dashboards load and then never refresh — which # the upgrade headers dashboards load and then never refresh — which
# reads as stale data rather than as a proxy fault. # reads as stale data rather than as a proxy fault.
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { services.hyperhive.gateway.vhosts."${cfg.domain}" = {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
# Grafana serves its Prometheus exposition to anyone, signed in or not # Grafana serves its Prometheus exposition to anyone, signed in or not
# (`metrics.enabled` below, no `basic_auth_*`), so this location's auth # (`metrics.enabled` below, no `basic_auth_*`), so this location's auth
# is the only gate on it. `=` is enough: nginx matches the decoded, # is the only gate on it. `=` is enough: nginx matches the decoded,

View file

@ -465,65 +465,56 @@ in
# nothing else on this name: the monitoring endpoint (`/varz`, `/connz`) # nothing else on this name: the monitoring endpoint (`/varz`, `/connz`)
# is never proxied, and every other path is 403. # is never proxied, and every other path is 403.
# #
# ⚠️ Not `gateway.lib.tlsFor`, and the name is not in # ⚠️ In self-signed mode this vhost serves the queue's own leaf
# `swarm.serviceDomains`. In self-signed mode this vhost serves the # (`certificate`; issued for exactly this name, `server_flag` set), not
# queue's own leaf (issued for exactly this name, `server_flag` set), so # the gateway's pick for the name, and the name is not in
# a host whose only swarm service is the queue never fronts a services # `swarm.serviceDomains`.
# name and its gateway does not wait for the swarm-services leaf.
# #
# `forceSSL` for the reason ./swarm-victorialogs.nix gives: authelia # `forceSSL` for the reason ./swarm-victorialogs.nix gives: authelia
# answers a plain-http auth subrequest with 400. # answers a plain-http auth subrequest with 400.
services.nginx.virtualHosts."${cfg.domain}" = services.hyperhive.gateway.vhosts."${cfg.domain}" = {
( certificate = lib.mkIf gatewayServesQueueLeaf {
if gatewayServesQueueLeaf then cert = "${gatewayTlsDir}/cert.pem";
{ key = "${gatewayTlsDir}/key.pem";
sslCertificate = "${gatewayTlsDir}/cert.pem"; };
sslCertificateKey = "${gatewayTlsDir}/key.pem"; forceSSL = true;
} locations = {
else "/".return = "403";
builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ]
)
// {
forceSSL = true;
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = {
"/".return = "403";
# The collector requests exactly this URL, declared as # The collector requests exactly this URL, declared as
# `publishedScrapeTargets.nats` in ./swarm-otel-service.nix; the # `publishedScrapeTargets.nats` in ./swarm-otel-service.nix; the
# two must agree, since that string is also the token's audience. # two must agree, since that string is also the token's audience.
# Authelia's rules for it admit the collector's client and deny # Authelia's rules for it admit the collector's client and deny
# every other subject (`metricsRules` in ./swarm-authelia.nix). # every other subject (`metricsRules` in ./swarm-authelia.nix).
"= /metrics" = { "= /metrics" = {
proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics"; proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics";
extraConfig = '' extraConfig = ''
auth_request /__nats_metrics_authz; auth_request /__nats_metrics_authz;
''; '';
}; };
# The subrequest, same shape as the forge's `= /__forge_metrics_authz` # The subrequest, same shape as the forge's `= /__forge_metrics_authz`
# (./hive-forge/default.nix): no `error_page 401 =302`, so a scraper # (./hive-forge/default.nix): no `error_page 401 =302`, so a scraper
# gets the 401 rather than a login page parsed as metrics. # gets the 401 rather than a login page parsed as metrics.
"= /__nats_metrics_authz" = { "= /__nats_metrics_authz" = {
proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request"; proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, which # nixpkgs appends its OWN `Host $host` after extraConfig, which
# would override verifiedProxyTo's — see hive-gateway/vhost-lib.nix. # would override verifiedProxyTo's — see hive-gateway/vhost-lib.nix.
recommendedProxySettings = false; recommendedProxySettings = false;
extraConfig = '' extraConfig = ''
internal; internal;
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain} ${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain}
proxy_pass_request_body off; proxy_pass_request_body off;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
''; '';
};
}; };
}; };
};
# Puts a pair at the paths the vhost above names, before nginx starts: # Puts a pair at the paths the vhost above names, before nginx starts:
# nginx refuses a config naming a missing cert file, and that takes the # nginx refuses a config naming a missing cert file, and that takes the

View file

@ -358,9 +358,7 @@ in
# every sibling swarm service: TLS terminates here, then plain http to # every sibling swarm service: TLS terminates here, then plain http to
# the co-located container over loopback (shared netns, like the store # the co-located container over loopback (shared netns, like the store
# this collector writes to). # this collector writes to).
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { services.hyperhive.gateway.vhosts."${cfg.domain}" = {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = locations =
# One name for the whole collector, and the hive is a path under # One name for the whole collector, and the hive is a path under
# it. The alternative — a vhost per hive — needs a certificate, # it. The alternative — a vhost per hive — needs a certificate,

View file

@ -179,165 +179,161 @@ in
# on. Serving forge or matrix over http is merely insecure rather # on. Serving forge or matrix over http is merely insecure rather
# than broken, so they keep `addSSL` and the asymmetry stays local # than broken, so they keep `addSSL` and the asymmetry stays local
# to the vhost whose correctness depends on the scheme. # to the vhost whose correctness depends on the scheme.
# `removeAttrs` because nixos asserts on a vhost declaring both. # The gateway drops `addSSL` from a vhost that sets `forceSSL`.
services.nginx.virtualHosts."${apex}" = services.hyperhive.gateway.vhosts."${apex}" = {
(builtins.removeAttrs (gatewayCfg.lib.tlsFor apex) [ "addSSL" ]) forceSSL = true;
locations = {
"/" = {
root = "${deployCfg.swarm-controller.ui.package}";
extraConfig = ''
${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;
'';
};
}
// lib.optionalAttrs (stylixThemeColors != null) {
# Stylix theming (see the `let` block above): overrides just
# this one file from `deployCfg.swarm-controller.ui.package`'s own static root, rather
# than copying the whole tree to change one file inside it —
# nginx resolves the more specific `=` exact match over the
# `/` prefix root above, so this simply doesn't exist (falling
# through to the package's own untouched colors.css) when
# there's no active palette. Same `auth_request` gate as every
# other location here — no reason for this one path to have a
# different failure mode than the page that loads it.
"= /static/colors.css" = {
alias = "${themedColorsCss stylixThemeColors}";
extraConfig = swarmAuthRequest;
};
}
// { // {
forceSSL = true; # swarm-controller's whole HTTP surface, including the live
listen = gatewayCfg.lib.listen; # `/api/openapi.json` spec — proxied untouched (no URI segment
extraConfig = gatewayCfg.lib.securityHeaders; # after the socket path, same "pass the request through as-is"
locations = { # shape as the per-hive dashboard's own `/api/` proxy) so the
"/" = { # path swarm-controller registered a route at is the path
root = "${deployCfg.swarm-controller.ui.package}"; # nginx forwards, no prefix-stripping to keep in sync by hand.
extraConfig = '' "/api/" = {
${swarmAuthRequest} proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:";
# SPA: any path the bundle routes client-side is served the # `Remote-User` from authelia's answer, set here so it replaces
# entry document rather than a 404 from the filesystem. # any the client sent: swarm-controller takes it as the sender of
try_files $uri /index.html; # the text the operator types to an agent (`agent_input.rs`).
''; # A refused caller that sent an `Authorization` header (`swarmctl
}; # --swarm`) gets the 401 itself; a browser still gets the login.
} # The bearer token stops here and never reaches the controller.
// lib.optionalAttrs (stylixThemeColors != null) { extraConfig = ''
# Stylix theming (see the `let` block above): overrides just ${authRequest}
# this one file from `deployCfg.swarm-controller.ui.package`'s own static root, rather error_page 401 = @swarm_api_unauthorized;
# than copying the whole tree to change one file inside it — auth_request_set $authelia_user $upstream_http_remote_user;
# nginx resolves the more specific `=` exact match over the proxy_set_header Remote-User $authelia_user;
# `/` prefix root above, so this simply doesn't exist (falling proxy_set_header Authorization "";
# through to the package's own untouched colors.css) when # The SSE streams (terminal, agent state, subagent terminal)
# there's no active palette. Same `auth_request` gate as every # must not sit in nginx's buffer. The controller's 15s
# other location here — no reason for this one path to have a # keep-alive keeps them under nginx's 60s default read timeout.
# different failure mode than the page that loads it. proxy_buffering off;
"= /static/colors.css" = { '';
alias = "${themedColorsCss stylixThemeColors}"; };
extraConfig = swarmAuthRequest; "@swarm_api_unauthorized".extraConfig = ''
}; if ($http_authorization) {
} return 401;
// { }
# swarm-controller's whole HTTP surface, including the live return 302 ${loginRedirect};
# `/api/openapi.json` spec — proxied untouched (no URI segment '';
# after the socket path, same "pass the request through as-is" # ⚠️ THE ONE PROXIED LOCATION ON THIS VHOST WITH NO `auth_request`,
# shape as the per-hive dashboard's own `/api/` proxy) so the # and that is deliberate rather than an omission.
# path swarm-controller registered a route at is the path #
# nginx forwards, no prefix-stripping to keep in sync by hand. # A forge webhook is a machine POST carrying an HMAC signature
"/api/" = { # and no session cookie. An authelia auth-request subrequest
proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:"; # authenticates a *browser session*; there is nothing here for it
# `Remote-User` from authelia's answer, set here so it replaces # to check, so guarding this location would not make it safer, it
# any the client sent: swarm-controller takes it as the sender of # would make it permanently unreachable.
# the text the operator types to an agent (`agent_input.rs`). #
# A refused caller that sent an `Authorization` header (`swarmctl # What replaces it: swarm-controller verifies the
# --swarm`) gets the 401 itself; a browser still gets the login. # `X-Hub-Signature-256` HMAC over the raw body before looking at
# The bearer token stops here and never reaches the controller. # anything else, and answers 401 on any mismatch. That check is
extraConfig = '' # the access control for this path — see the `webhook` module.
${authRequest} #
error_page 401 = @swarm_api_unauthorized; # Scoped to `/webhook/forge/` rather than `/webhook/` so the
auth_request_set $authelia_user $upstream_http_remote_user; # carve-out is exactly as wide as the endpoint that justifies it:
proxy_set_header Remote-User $authelia_user; # a future `/webhook/<something-else>` does not inherit the
proxy_set_header Authorization ""; # bypass by living under a shared prefix.
# The SSE streams (terminal, agent state, subagent terminal) "/webhook/forge/" = {
# must not sit in nginx's buffer. The controller's 15s proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:";
# keep-alive keeps them under nginx's 60s default read timeout. };
proxy_buffering off; # 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`.
"@swarm_api_unauthorized".extraConfig = '' "= /api/docs" = {
if ($http_authorization) { extraConfig = ''
return 401; return 301 /api/docs/;
} '';
return 302 ${loginRedirect}; };
# Session identity for the header's profile menu: initials
# avatar + "who is this" line. `auth_request` tells the
# browser nothing about *who* (only `/api/` passes it on, to the
# controller), so the frontend has no other way to learn this.
# Same-origin proxy straight to authelia's own `GET /api/user/info`
# (session-cookie authenticated) rather than new
# swarm-controller code: the cookie is already valid here (the
# session cookie's domain is the swarm's, shared across every
# vhost under it — see swarm-authelia.nix), so this is a pure
# pass-through with nothing for a daemon to add.
#
# `=` exact match, not a prefix, so proxy_pass's own URI part
# (`/api/user/info`) REPLACES the matched request URI rather
# than being appended to it — same substitution shape as
# `/__hive_authelia` below, just not `internal` since the
# frontend calls this one directly.
"= /api/whoami" = {
proxyPass = "https://${autheliaCfg.domain}/api/user/info";
# nixpkgs appends its OWN `Host $host` after extraConfig,
# which would override verifiedProxyTo's — see the comment
# on verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
${swarmAuthRequest}
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain}
'';
};
"/api/docs/" = {
alias = "${gatewayCfg.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.
#
# Header set measured against the pinned binary (4.39.20), not
# copied: `X-Original-URL` and `X-Original-Method` are present
# as literals and are what this implementation reads —
# `X-Forwarded-Uri` does not appear in it at all, so sending it
# would look like configuration and be dead weight.
"= /__hive_authelia" = {
proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig,
# which would override verifiedProxyTo's — see the comment
# on verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
internal;
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain}
# A subrequest carries no body, and forwarding one here makes
# authelia read a payload it will never use.
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;
''; '';
# ⚠️ THE ONE PROXIED LOCATION ON THIS VHOST WITH NO `auth_request`,
# and that is deliberate rather than an omission.
#
# A forge webhook is a machine POST carrying an HMAC signature
# and no session cookie. An authelia auth-request subrequest
# authenticates a *browser session*; there is nothing here for it
# to check, so guarding this location would not make it safer, it
# would make it permanently unreachable.
#
# What replaces it: swarm-controller verifies the
# `X-Hub-Signature-256` HMAC over the raw body before looking at
# anything else, and answers 401 on any mismatch. That check is
# the access control for this path — see the `webhook` module.
#
# Scoped to `/webhook/forge/` rather than `/webhook/` so the
# carve-out is exactly as wide as the endpoint that justifies it:
# a future `/webhook/<something-else>` does not inherit the
# bypass by living under a shared prefix.
"/webhook/forge/" = {
proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:";
};
# 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`.
"= /api/docs" = {
extraConfig = ''
return 301 /api/docs/;
'';
};
# Session identity for the header's profile menu: initials
# avatar + "who is this" line. `auth_request` tells the
# browser nothing about *who* (only `/api/` passes it on, to the
# controller), so the frontend has no other way to learn this.
# Same-origin proxy straight to authelia's own `GET /api/user/info`
# (session-cookie authenticated) rather than new
# swarm-controller code: the cookie is already valid here (the
# session cookie's domain is the swarm's, shared across every
# vhost under it — see swarm-authelia.nix), so this is a pure
# pass-through with nothing for a daemon to add.
#
# `=` exact match, not a prefix, so proxy_pass's own URI part
# (`/api/user/info`) REPLACES the matched request URI rather
# than being appended to it — same substitution shape as
# `/__hive_authelia` below, just not `internal` since the
# frontend calls this one directly.
"= /api/whoami" = {
proxyPass = "https://${autheliaCfg.domain}/api/user/info";
# nixpkgs appends its OWN `Host $host` after extraConfig,
# which would override verifiedProxyTo's — see the comment
# on verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
${swarmAuthRequest}
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain}
'';
};
"/api/docs/" = {
alias = "${gatewayCfg.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.
#
# Header set measured against the pinned binary (4.39.20), not
# copied: `X-Original-URL` and `X-Original-Method` are present
# as literals and are what this implementation reads —
# `X-Forwarded-Uri` does not appear in it at all, so sending it
# would look like configuration and be dead weight.
"= /__hive_authelia" = {
proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig,
# which would override verifiedProxyTo's — see the comment
# on verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
internal;
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain}
# A subrequest carries no body, and forwarding one here makes
# authelia read a payload it will never use.
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;
'';
};
}; };
}; };
};
}; };
} }

View file

@ -313,157 +313,153 @@ in
# Authenticated front door onto the loopback-only store — see the # Authenticated front door onto the loopback-only store — see the
# file-top comment for why this is safe to add without touching the # file-top comment for why this is safe to add without touching the
# store's own (still unauthenticated, still loopback) listener at all. # store's own (still unauthenticated, still loopback) listener at all.
# `removeAttrs`/`forceSSL`: same asymmetry `swarm-ui.nix` documents — # `forceSSL`: same asymmetry `swarm-ui.nix` documents —
# authelia answers a plain-http auth subrequest with 400, which # authelia answers a plain-http auth subrequest with 400, which
# `auth_request` cannot read as anything but a broken check, so this # `auth_request` cannot read as anything but a broken check, so this
# vhost needs `forceSSL` rather than the `addSSL` every unauthenticated # vhost needs `forceSSL` rather than the `addSSL` every unauthenticated
# sibling vhost uses. # sibling vhost uses.
services.nginx.virtualHosts."${cfg.domain}" = services.hyperhive.gateway.vhosts."${cfg.domain}" = {
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ]) forceSSL = true;
// { locations = {
forceSSL = true; "/" = {
listen = gatewayCfg.lib.listen; proxyPass = "http://127.0.0.1:${toString cfg.port}/";
extraConfig = gatewayCfg.lib.securityHeaders; extraConfig = swarmAuthRequest;
locations = { };
"/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
extraConfig = swarmAuthRequest;
};
# The swarm collector's ingest route. `=` so it outranks the `/` # The swarm collector's ingest route. `=` so it outranks the `/`
# prefix above — without a more specific location it would ride that # prefix above — without a more specific location it would ride that
# catch-all, and that is the whole hazard here. # catch-all, and that is the whole hazard here.
# #
# ⚠️ Deliberately NOT `swarmAuthRequest`. That block ends in # ⚠️ Deliberately NOT `swarmAuthRequest`. That block ends in
# `error_page 401 =302`, which is right for a browser and wrong for a # `error_page 401 =302`, which is right for a browser and wrong for a
# pusher: handed a redirect it follows the redirect and POSTs its # pusher: handed a redirect it follows the redirect and POSTs its
# batch at a login page, which answers 200. Ingest then reports # batch at a login page, which answers 200. Ingest then reports
# healthy while storing nothing. A machine route lets the 401 reach # healthy while storing nothing. A machine route lets the 401 reach
# the client unchanged. # the client unchanged.
# #
# `auth_request` does not inherit across sibling locations (see the # `auth_request` does not inherit across sibling locations (see the
# note on `swarmAuthRequest` above), so naming it here is required # note on `swarmAuthRequest` above), so naming it here is required
# rather than redundant. # rather than redundant.
"= /insert/opentelemetry/v1/logs" = { "= /insert/opentelemetry/v1/logs" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/insert/opentelemetry/v1/logs"; proxyPass = "http://127.0.0.1:${toString cfg.port}/insert/opentelemetry/v1/logs";
extraConfig = '' extraConfig = ''
auth_request /__hive_authelia; auth_request /__hive_authelia;
''; '';
}; };
# The read side of that same split: the route a caller holding a # The read side of that same split: the route a caller holding a
# bearer token queries, as opposed to the `/` above which is the # bearer token queries, as opposed to the `/` above which is the
# operator's browser. Bare `auth_request` for the ingest route's # operator's browser. Bare `auth_request` for the ingest route's
# exact reason — `error_page 401 =302` hands an unauthenticated # exact reason — `error_page 401 =302` hands an unauthenticated
# machine caller authelia's login page as a **200 with an HTML # machine caller authelia's login page as a **200 with an HTML
# body**, so a client that reads the status code records a # body**, so a client that reads the status code records a
# successful query that returned no rows. 401 is the only answer # successful query that returned no rows. 401 is the only answer
# here that a caller cannot mistake for an empty result. # here that a caller cannot mistake for an empty result.
# #
# `^~` so it outranks the `/` catch-all and stays ahead of any # `^~` so it outranks the `/` catch-all and stays ahead of any
# regex location a later change adds. The prefix is the whole # regex location a later change adds. The prefix is the whole
# LogsQL query API and nothing else — `query`, `tail`, `hits`, # LogsQL query API and nothing else — `query`, `tail`, `hits`,
# `facets`, the `stats_query*` and the `field_*`/`stream_*` # `facets`, the `stats_query*` and the `field_*`/`stream_*`
# routes — while the browser UI sits beside it under # routes — while the browser UI sits beside it under
# `/select/vmui`. Both read off the pinned build, not the docs. # `/select/vmui`. Both read off the pinned build, not the docs.
# #
# The query is forwarded unmodified — no scoping parameter is # The query is forwarded unmodified — no scoping parameter is
# injected, so any authenticated caller reads the whole swarm's # injected, so any authenticated caller reads the whole swarm's
# logs. That is the rule in force, not an omission: read # logs. That is the rule in force, not an omission: read
# permissions are a later thing, and this location is where one # permissions are a later thing, and this location is where one
# attaches when it exists. # attaches when it exists.
# #
# Three checks: an agent's own `swarm-agent.` bearer goes to # Three checks: an agent's own `swarm-agent.` bearer goes to
# `swarm-logs-auth`, Grafana's 64-hex bearer to `swarm-metrics-auth`, # `swarm-logs-auth`, Grafana's 64-hex bearer to `swarm-metrics-auth`,
# anything else to authelia (`appendHttpConfig` above picks). # anything else to authelia (`appendHttpConfig` above picks).
"^~ /select/logsql/" = { "^~ /select/logsql/" = {
# No URI part, so the request path and its query string reach # No URI part, so the request path and its query string reach
# the store as the caller sent them. # the store as the caller sent them.
proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyPass = "http://127.0.0.1:${toString cfg.port}";
extraConfig = '' extraConfig = ''
auth_request /__swarm_logs_auth; auth_request /__swarm_logs_auth;
''; '';
}; };
# The store's own health, which it publishes as prometheus metrics # The store's own health, which it publishes as prometheus metrics
# on the listener it serves queries on. The swarm collector requests # on the listener it serves queries on. The swarm collector requests
# exactly this URL, declared as `publishedScrapeTargets.victorialogs` # exactly this URL, declared as `publishedScrapeTargets.victorialogs`
# in ./swarm-otel-service.nix; the two must agree, since that string # in ./swarm-otel-service.nix; the two must agree, since that string
# is also the token's audience. # is also the token's audience.
# #
# Bare `auth_request` to authelia, as on the ingest route above, and # Bare `auth_request` to authelia, as on the ingest route above, and
# never `/__swarm_logs_auth`, which admits an agent's bearer: # never `/__swarm_logs_auth`, which admits an agent's bearer:
# authelia's rules for this URL admit the collector's client and # authelia's rules for this URL admit the collector's client and
# deny every other subject (see `metricsRules` in ./swarm-authelia.nix). # deny every other subject (see `metricsRules` in ./swarm-authelia.nix).
"= /metrics" = { "= /metrics" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/metrics"; proxyPass = "http://127.0.0.1:${toString cfg.port}/metrics";
extraConfig = '' extraConfig = ''
auth_request /__hive_authelia; auth_request /__hive_authelia;
''; '';
}; };
# `rewrite … last` re-matches locations, so the subrequest lands in # `rewrite … last` re-matches locations, so the subrequest lands in
# one of the two internal locations named by the map. # one of the two internal locations named by the map.
"= /__swarm_logs_auth" = { "= /__swarm_logs_auth" = {
extraConfig = '' extraConfig = ''
internal; internal;
rewrite ^ $swarm_logs_auth_target last; rewrite ^ $swarm_logs_auth_target last;
''; '';
}; };
# The `Authorization` header rides the subrequest unchanged. # The `Authorization` header rides the subrequest unchanged.
# #
# Any well-formed agent bearer the responder has not recently granted # Any well-formed agent bearer the responder has not recently granted
# costs a secret store login and read, so the rate per client address # costs a secret store login and read, so the rate per client address
# is capped here, ahead of the responder. 403 when over: the only # is capped here, ahead of the responder. 403 when over: the only
# statuses `auth_request` passes through are 401 and 403, and # statuses `auth_request` passes through are 401 and 403, and
# `swarm-logs` retries a 401. # `swarm-logs` retries a 401.
"= /__swarm_logs_agent_auth" = { "= /__swarm_logs_agent_auth" = {
proxyPass = "http://unix:${logsAuthSocket}:/"; proxyPass = "http://unix:${logsAuthSocket}:/";
extraConfig = '' extraConfig = ''
internal; internal;
limit_req zone=swarm_logs_agent_auth burst=${toString deployCfg.victorialogs.authBurst} nodelay; limit_req zone=swarm_logs_agent_auth burst=${toString deployCfg.victorialogs.authBurst} nodelay;
limit_req_status 403; limit_req_status 403;
proxy_pass_request_body off; proxy_pass_request_body off;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
''; '';
}; };
# Grafana's bearer, 64 hex digits (`openssl rand -hex 32`), checked by # Grafana's bearer, 64 hex digits (`openssl rand -hex 32`), checked by
# `swarm-metrics-auth`. The responder reads the store at most once per # `swarm-metrics-auth`. The responder reads the store at most once per
# its cache TTL whatever the bearers, so no rate limit sits in front. # its cache TTL whatever the bearers, so no rate limit sits in front.
"= /__swarm_logs_grafana_auth" = { "= /__swarm_logs_grafana_auth" = {
proxyPass = "http://unix:${grafanaAuthSocket}:/"; proxyPass = "http://unix:${grafanaAuthSocket}:/";
extraConfig = '' extraConfig = ''
internal; internal;
proxy_pass_request_body off; proxy_pass_request_body off;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
''; '';
}; };
# The subrequest itself — same target, same header set, same # The subrequest itself — same target, same header set, same
# reasoning as `swarm-ui.nix`'s own copy (measured against the # reasoning as `swarm-ui.nix`'s own copy (measured against the
# pinned authelia binary, not copied from an example). # pinned authelia binary, not copied from an example).
"= /__hive_authelia" = { "= /__hive_authelia" = {
proxyPass = "https://${hyperhiveCfg.swarm.authelia.domain}/api/authz/auth-request"; proxyPass = "https://${hyperhiveCfg.swarm.authelia.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, # nixpkgs appends its OWN `Host $host` after extraConfig,
# which would override verifiedProxyTo's — see the comment # which would override verifiedProxyTo's — see the comment
# on verifiedProxyTo in hive-gateway/vhost-lib.nix. # on verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false; recommendedProxySettings = false;
extraConfig = '' extraConfig = ''
internal; internal;
${gatewayCfg.lib.verifiedProxyTo hyperhiveCfg.swarm.authelia.domain} ${gatewayCfg.lib.verifiedProxyTo hyperhiveCfg.swarm.authelia.domain}
proxy_pass_request_body off; proxy_pass_request_body off;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
''; '';
};
}; };
}; };
};
# The agents' check on `/select/logsql/`. On the host rather than in the # The agents' check on `/select/logsql/`. On the host rather than in the
# store's container: it needs nothing of the store, only nginx's reach to # store's container: it needs nothing of the store, only nginx's reach to

View file

@ -252,107 +252,103 @@ in
} }
''; '';
# `removeAttrs`/`forceSSL`: authelia answers a plain-http auth subrequest # `forceSSL`: authelia answers a plain-http auth subrequest
# with 400, which `auth_request` cannot read as anything but a broken # with 400, which `auth_request` cannot read as anything but a broken
# check. Same as ./swarm-victorialogs.nix's vhost. # check. Same as ./swarm-victorialogs.nix's vhost.
services.nginx.virtualHosts."${cfg.domain}" = services.hyperhive.gateway.vhosts."${cfg.domain}" = {
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ]) forceSSL = true;
// { locations =
forceSSL = true; readLocations
listen = gatewayCfg.lib.listen; // uiLocations
extraConfig = gatewayCfg.lib.securityHeaders; // {
locations = # Nothing reaches the store unless a location here names it. The
readLocations # store serves writes and admin calls (`/api/v1/import`,
// uiLocations # `/api/v1/admin/tsdb/delete_series`, ...) on the same listener as
// { # its queries, and authenticates none of them.
# Nothing reaches the store unless a location here names it. The "/".return = "403";
# store serves writes and admin calls (`/api/v1/import`,
# `/api/v1/admin/tsdb/delete_series`, ...) on the same listener as
# its queries, and authenticates none of them.
"/".return = "403";
# The swarm UI's quick link opens the bare domain. The store's own # The swarm UI's quick link opens the bare domain. The store's own
# index page links mostly to paths refused above, so send it to vmui. # index page links mostly to paths refused above, so send it to vmui.
"= /".return = "302 /vmui/"; "= /".return = "302 /vmui/";
# The swarm collector's ingest route. `=` so it outranks the `/` # The swarm collector's ingest route. `=` so it outranks the `/`
# prefix above. # prefix above.
# #
# ⚠️ No `error_page 401 =302` here, and its absence is the point: a # ⚠️ No `error_page 401 =302` here, and its absence is the point: a
# redirect is right for a browser and wrong for a pusher, which would # redirect is right for a browser and wrong for a pusher, which would
# follow it and POST its batch at a login page that answers 200 — # follow it and POST its batch at a login page that answers 200 —
# ingest reporting healthy while storing nothing. # ingest reporting healthy while storing nothing.
"= /opentelemetry/api/v1/push" = { "= /opentelemetry/api/v1/push" = {
proxyPass = upstream "/opentelemetry/api/v1/push"; proxyPass = upstream "/opentelemetry/api/v1/push";
extraConfig = '' extraConfig = ''
auth_request /__metrics_authelia; auth_request /__metrics_authelia;
''; '';
};
# The store's own health, for the swarm collector wherever it runs:
# served on the query listener, so no exporter and no extra port. `=`
# for the same reason as the push route, and no `error_page` for the
# same reason: a scraper would parse a login page as metrics.
#
# The collector requests exactly this URL, declared as
# `publishedScrapeTargets.victoriametrics` in ./swarm-otel-service.nix;
# the two must agree, since that string is also the token's audience.
"= /metrics" = {
proxyPass = upstream "/metrics";
extraConfig = ''
auth_request /__metrics_authelia;
'';
};
# `rewrite … last` re-matches locations, so the subrequest lands in
# one of the two internal locations named by the map.
"= /__metrics_read_auth" = {
extraConfig = ''
internal;
rewrite ^ $swarm_metrics_read_auth_target last;
'';
};
# Grafana's bearer, checked by `swarm-metrics-auth`. The
# `Authorization` header rides the subrequest unchanged. No rate limit
# in front: the responder reads the store at most once per its cache
# TTL, whatever the bearers.
"= /__metrics_grafana_auth" = {
proxyPass = "http://unix:${metricsAuthSocket}:/";
extraConfig = ''
internal;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
'';
};
# The authelia subrequest. For the collector's two locations above it
# admits only the collector's client; for the read and vmui locations
# it admits an operator's session (`metricsStoreRules` in
# ./swarm-authelia.nix). Same target and header set as the sibling log
# store's, which took them from `swarm-ui.nix` — `X-Original-URL` and
# `X-Original-Method` are what authelia's auth-request implementation
# reads, and the address it compares the token's audience against.
"= /__metrics_authelia" = {
proxyPass = "https://${hyperhiveCfg.swarm.authelia.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, which
# would override verifiedProxyTo's — see the comment on
# verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
internal;
${gatewayCfg.lib.verifiedProxyTo hyperhiveCfg.swarm.authelia.domain}
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;
'';
};
}; };
};
# The store's own health, for the swarm collector wherever it runs:
# served on the query listener, so no exporter and no extra port. `=`
# for the same reason as the push route, and no `error_page` for the
# same reason: a scraper would parse a login page as metrics.
#
# The collector requests exactly this URL, declared as
# `publishedScrapeTargets.victoriametrics` in ./swarm-otel-service.nix;
# the two must agree, since that string is also the token's audience.
"= /metrics" = {
proxyPass = upstream "/metrics";
extraConfig = ''
auth_request /__metrics_authelia;
'';
};
# `rewrite … last` re-matches locations, so the subrequest lands in
# one of the two internal locations named by the map.
"= /__metrics_read_auth" = {
extraConfig = ''
internal;
rewrite ^ $swarm_metrics_read_auth_target last;
'';
};
# Grafana's bearer, checked by `swarm-metrics-auth`. The
# `Authorization` header rides the subrequest unchanged. No rate limit
# in front: the responder reads the store at most once per its cache
# TTL, whatever the bearers.
"= /__metrics_grafana_auth" = {
proxyPass = "http://unix:${metricsAuthSocket}:/";
extraConfig = ''
internal;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
'';
};
# The authelia subrequest. For the collector's two locations above it
# admits only the collector's client; for the read and vmui locations
# it admits an operator's session (`metricsStoreRules` in
# ./swarm-authelia.nix). Same target and header set as the sibling log
# store's, which took them from `swarm-ui.nix` — `X-Original-URL` and
# `X-Original-Method` are what authelia's auth-request implementation
# reads, and the address it compares the token's audience against.
"= /__metrics_authelia" = {
proxyPass = "https://${hyperhiveCfg.swarm.authelia.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, which
# would override verifiedProxyTo's — see the comment on
# verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
internal;
${gatewayCfg.lib.verifiedProxyTo hyperhiveCfg.swarm.authelia.domain}
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;
'';
};
};
};
# The socket directory, created host-side before the container starts: # The socket directory, created host-side before the container starts:
# nixos-container refuses to start when a bind source is missing. # nixos-container refuses to start when a bind source is missing.

View file

@ -88,6 +88,8 @@ let
deploy.grafana.plugins = [ ]; deploy.grafana.plugins = [ ];
deploy.grafana.package = pkgs.emptyDirectory; deploy.grafana.package = pkgs.emptyDirectory;
swarm.authelia.url = "https://auth.example.invalid"; swarm.authelia.url = "https://auth.example.invalid";
# Declares a vhost that binds its own `listen`, which the gateway refuses.
gateway.vhosts."own-listen.t.local".listen = [ ];
}; };
# Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its # Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its
@ -479,6 +481,39 @@ let
&& builtins.length selectors == 12 && builtins.length selectors == 12
&& lib.all (e: lib.hasInfix "\"service.name\"=\"${stamped.value}\"" e) selectors; && lib.all (e: lib.hasInfix "\"service.name\"=\"${stamped.value}\"" e) selectors;
} }
{
# A module declares its vhost under `gateway.vhosts` and the gateway adds
# what it owns: the listen set, the cert for the name and the security
# headers ahead of the module's own `extraConfig`.
name = "grafana's vhost is rendered with the gateway's listen set, TLS and security headers";
ok =
let
gw = grafanaOldPath.services.hyperhive.gateway;
v =
grafanaOldPath.services.nginx.virtualHosts.${grafanaOldPath.services.hyperhive.swarm.grafana.domain};
# nginx's `listen` entries come back with defaults filled in
# (`extraParameters`, `proxyProtocol`), so compare the fields set.
listenOf = map (l: {
inherit (l) addr port;
ssl = l.ssl or false;
});
in
listenOf v.listen == listenOf gw.lib.listen
&& lib.hasPrefix gw.lib.securityHeaders v.extraConfig
&& v.addSSL
&& !(v ? certificate);
}
{
# A vhost that sets `listen` itself would bind a port the gateway does
# not consider its own, so the eval refuses it and names the vhost.
name = "a gateway vhost that sets its own listen is refused";
ok = lib.any (
a:
!a.assertion
&& lib.hasInfix "sets attributes the gateway owns" a.message
&& lib.hasInfix "own-listen.t.local.listen" a.message
) grafanaNoIdentity.assertions;
}
]; ];
in in
runGroup "grafana" cases runGroup "grafana" cases