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:
parent
7b50ae57e4
commit
ddb583cc22
17 changed files with 652 additions and 513 deletions
|
|
@ -261,9 +261,8 @@ in
|
|||
# This service's own gateway surface: the vhost that fronts it and
|
||||
# the name the hive resolver answers for. Declared here rather than
|
||||
# in the gateway so the forge's public face lives with the forge —
|
||||
# the gateway supplies the primitives (`lib.listen`, `lib.tlsFor`,
|
||||
# `lib.securityHeaders`) and never needs to know this service by
|
||||
# name.
|
||||
# the gateway adds `listen`, the certificate and the security headers
|
||||
# (`gateway.vhosts`) and never needs to know this service by name.
|
||||
services.hyperhive.gateway.localNames = [ cfg.domain ];
|
||||
services.hyperhive.gateway.enable = lib.mkDefault true;
|
||||
|
||||
|
|
@ -274,10 +273,8 @@ in
|
|||
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
|
||||
# clones). SSH stays direct on `forge.sshPort`. See
|
||||
# `docs/networking/gateway.md`.
|
||||
services.nginx.virtualHosts = {
|
||||
"${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
services.hyperhive.gateway.vhosts = {
|
||||
"${cfg.domain}" = {
|
||||
locations = {
|
||||
"/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/";
|
||||
|
|
|
|||
85
nix/host-modules/hive-gateway/declared-vhosts.nix
Normal file
85
nix/host-modules/hive-gateway/declared-vhosts.nix
Normal 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;
|
||||
})
|
||||
];
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@
|
|||
# namespace of its own.
|
||||
# Full vhost map + discovery flow + design rationale in
|
||||
# `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
|
||||
# has an enable of its own because a host can need hive names to resolve
|
||||
# without serving a vhost).
|
||||
|
|
@ -134,6 +135,7 @@ in
|
|||
imports = [
|
||||
./options.nix
|
||||
./dns.nix
|
||||
./declared-vhosts.nix
|
||||
];
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
listen = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.attrsOf lib.types.raw);
|
||||
|
|
@ -144,11 +199,10 @@ in
|
|||
description = ''
|
||||
Read-only: the `listen` set every vhost in front of this
|
||||
gateway shares (plain http on `port`, TLS on `httpsPort`).
|
||||
Published so a service module can declare its own vhost
|
||||
without restating the port pair — a vhost that binds a
|
||||
different set is reachable on a port the gateway does not
|
||||
consider its own, which is the kind of drift nobody notices
|
||||
until one name behaves differently from the rest.
|
||||
Applied to every vhost declared under `vhosts`, and read by
|
||||
the gateway's own vhosts, so no vhost binds a different
|
||||
set — one that did would be reachable on a port the gateway
|
||||
does not consider its own.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -163,8 +217,8 @@ in
|
|||
service's: a swarm service's name can sit outside this hive's
|
||||
domain, and the hive leaf's role refuses it, so that
|
||||
vhost must serve the swarm-services leaf while everything else
|
||||
keeps the hive leaf. A service module calls this instead of
|
||||
deciding — deciding is how the vhost and the cert stop
|
||||
keeps the hive leaf. The gateway calls this for every vhost it
|
||||
fronts — a vhost choosing its own is how the vhost and the cert stop
|
||||
agreeing.
|
||||
'';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,18 +2,17 @@
|
|||
# attrs, and the security headers every vhost in front of this gateway
|
||||
# needs.
|
||||
#
|
||||
# Split out of ./vhosts.nix so it has one home and two consumers. Today
|
||||
# only ./vhosts.nix reads it; it is published as
|
||||
# `services.hyperhive.gateway.lib` (see ./options.nix) so a service
|
||||
# module can declare its OWN vhost without the gateway having to know
|
||||
# that service by name. `vhosts.nix` reads the published value rather
|
||||
# than calling this file directly — one source, not a copy that agrees
|
||||
# by inspection.
|
||||
# Split out of ./vhosts.nix so it has one home and several consumers.
|
||||
# ./vhosts.nix and ./declared-vhosts.nix (which renders the vhosts service
|
||||
# modules declare under `gateway.vhosts`) read it through
|
||||
# `services.hyperhive.gateway.lib` (see ./options.nix) rather than calling
|
||||
# this file directly — one source, not a copy that agrees by inspection. A
|
||||
# service module reads `verifiedProxyTo`, `errorPages` and `securityHeaders`
|
||||
# from there for the locations it writes.
|
||||
#
|
||||
# Pure function of the gateway's config + resolved cert paths; returns
|
||||
# an attrset, evaluates no options itself. Everything here is *gateway*
|
||||
# knowledge — which port pair to bind, which issuer covers a name — and
|
||||
# stays here even once the service vhosts move out to their own modules.
|
||||
# knowledge — which port pair to bind, which issuer covers a name.
|
||||
{
|
||||
lib,
|
||||
cfg, # services.hyperhive.gateway
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@
|
|||
# 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
|
||||
# (`services.hyperhive.gateway.lib`, ./vhost-lib.nix). That keeps a
|
||||
# service's gateway surface next to the service, and keeps this file to
|
||||
# the surface the hive itself serves.
|
||||
# module, as `services.hyperhive.gateway.vhosts.<name>` (rendered by
|
||||
# ./declared-vhosts.nix). That keeps a service's gateway surface next to
|
||||
# the service, and keeps this file to the surface the hive itself serves.
|
||||
#
|
||||
# Pure function — called from ./default.nix with the outer-scope config
|
||||
# values as arguments; returns `{ virtualHosts }`.
|
||||
|
|
|
|||
|
|
@ -477,10 +477,8 @@ in
|
|||
# be served at the *server name*, which is the hive domain — it is
|
||||
# the hive answering "where is my homeserver", not the homeserver
|
||||
# answering for itself.
|
||||
services.nginx.virtualHosts = lib.optionalAttrs (cfg.gatewayHost != null) {
|
||||
"${cfg.gatewayHost}" = (gatewayCfg.lib.tlsFor cfg.gatewayHost) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
services.hyperhive.gateway.vhosts = lib.optionalAttrs (cfg.gatewayHost != null) {
|
||||
"${cfg.gatewayHost}" = {
|
||||
locations = {
|
||||
"/_matrix/" = {
|
||||
# No URI after the socket, so tuwunel gets the request URI as the
|
||||
|
|
|
|||
|
|
@ -800,9 +800,7 @@ in
|
|||
#
|
||||
# ⚠️ 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;
|
||||
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
|
||||
proxyWebsockets = true;
|
||||
|
|
|
|||
|
|
@ -2716,40 +2716,35 @@ in
|
|||
# netns), so no port opens anywhere. `auth_request` only proves a
|
||||
# session; restricting it to `admins` is ./swarm-authelia.nix's rule
|
||||
# for this name. `forceSSL` because authelia answers a plain-http
|
||||
# subrequest with 400, and `removeAttrs` because nixos refuses
|
||||
# `addSSL` beside it.
|
||||
services.nginx.virtualHosts.${cfg.ui.domain} =
|
||||
(builtins.removeAttrs (hyperhiveCfg.gateway.lib.tlsFor cfg.ui.domain) [ "addSSL" ])
|
||||
// {
|
||||
forceSSL = true;
|
||||
listen = hyperhiveCfg.gateway.lib.listen;
|
||||
extraConfig = hyperhiveCfg.gateway.lib.securityHeaders;
|
||||
locations = {
|
||||
"/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString baoDeploy.uiProxyPort}";
|
||||
extraConfig = swarmAuthRequest;
|
||||
};
|
||||
# subrequest with 400.
|
||||
services.hyperhive.gateway.vhosts.${cfg.ui.domain} = {
|
||||
forceSSL = true;
|
||||
locations = {
|
||||
"/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString baoDeploy.uiProxyPort}";
|
||||
extraConfig = swarmAuthRequest;
|
||||
};
|
||||
|
||||
"= /__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;
|
||||
${hyperhiveCfg.gateway.lib.verifiedProxyTo autheliaCfg.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;
|
||||
'';
|
||||
};
|
||||
"= /__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;
|
||||
${hyperhiveCfg.gateway.lib.verifiedProxyTo autheliaCfg.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 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
|
||||
|
|
|
|||
|
|
@ -558,9 +558,7 @@ in
|
|||
# Websockets: Grafana Live streams panel updates over one, and without
|
||||
# the upgrade headers dashboards load and then never refresh — which
|
||||
# reads as stale data rather than as a proxy fault.
|
||||
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
|
||||
# Grafana serves its Prometheus exposition to anyone, signed in or not
|
||||
# (`metrics.enabled` below, no `basic_auth_*`), so this location's auth
|
||||
# is the only gate on it. `=` is enough: nginx matches the decoded,
|
||||
|
|
|
|||
|
|
@ -465,65 +465,56 @@ in
|
|||
# nothing else on this name: the monitoring endpoint (`/varz`, `/connz`)
|
||||
# is never proxied, and every other path is 403.
|
||||
#
|
||||
# ⚠️ Not `gateway.lib.tlsFor`, and the name is not in
|
||||
# `swarm.serviceDomains`. In self-signed mode this vhost serves the
|
||||
# queue's own leaf (issued for exactly this name, `server_flag` set), so
|
||||
# a host whose only swarm service is the queue never fronts a services
|
||||
# name and its gateway does not wait for the swarm-services leaf.
|
||||
# ⚠️ In self-signed mode this vhost serves the queue's own leaf
|
||||
# (`certificate`; issued for exactly this name, `server_flag` set), not
|
||||
# the gateway's pick for the name, and the name is not in
|
||||
# `swarm.serviceDomains`.
|
||||
#
|
||||
# `forceSSL` for the reason ./swarm-victorialogs.nix gives: authelia
|
||||
# answers a plain-http auth subrequest with 400.
|
||||
services.nginx.virtualHosts."${cfg.domain}" =
|
||||
(
|
||||
if gatewayServesQueueLeaf then
|
||||
{
|
||||
sslCertificate = "${gatewayTlsDir}/cert.pem";
|
||||
sslCertificateKey = "${gatewayTlsDir}/key.pem";
|
||||
}
|
||||
else
|
||||
builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ]
|
||||
)
|
||||
// {
|
||||
forceSSL = true;
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
locations = {
|
||||
"/".return = "403";
|
||||
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
|
||||
certificate = lib.mkIf gatewayServesQueueLeaf {
|
||||
cert = "${gatewayTlsDir}/cert.pem";
|
||||
key = "${gatewayTlsDir}/key.pem";
|
||||
};
|
||||
forceSSL = true;
|
||||
locations = {
|
||||
"/".return = "403";
|
||||
|
||||
# The collector requests exactly this URL, declared as
|
||||
# `publishedScrapeTargets.nats` in ./swarm-otel-service.nix; the
|
||||
# two must agree, since that string is also the token's audience.
|
||||
# Authelia's rules for it admit the collector's client and deny
|
||||
# every other subject (`metricsRules` in ./swarm-authelia.nix).
|
||||
"= /metrics" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics";
|
||||
extraConfig = ''
|
||||
auth_request /__nats_metrics_authz;
|
||||
'';
|
||||
};
|
||||
# The collector requests exactly this URL, declared as
|
||||
# `publishedScrapeTargets.nats` in ./swarm-otel-service.nix; the
|
||||
# two must agree, since that string is also the token's audience.
|
||||
# Authelia's rules for it admit the collector's client and deny
|
||||
# every other subject (`metricsRules` in ./swarm-authelia.nix).
|
||||
"= /metrics" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics";
|
||||
extraConfig = ''
|
||||
auth_request /__nats_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
|
||||
# gets the 401 rather than a login page parsed as metrics.
|
||||
"= /__nats_metrics_authz" = {
|
||||
proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request";
|
||||
# nixpkgs appends its OWN `Host $host` after extraConfig, which
|
||||
# would override verifiedProxyTo's — see hive-gateway/vhost-lib.nix.
|
||||
recommendedProxySettings = false;
|
||||
extraConfig = ''
|
||||
internal;
|
||||
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.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 subrequest, same shape as the forge's `= /__forge_metrics_authz`
|
||||
# (./hive-forge/default.nix): no `error_page 401 =302`, so a scraper
|
||||
# gets the 401 rather than a login page parsed as metrics.
|
||||
"= /__nats_metrics_authz" = {
|
||||
proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request";
|
||||
# nixpkgs appends its OWN `Host $host` after extraConfig, which
|
||||
# would override verifiedProxyTo's — see hive-gateway/vhost-lib.nix.
|
||||
recommendedProxySettings = false;
|
||||
extraConfig = ''
|
||||
internal;
|
||||
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.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;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -358,9 +358,7 @@ in
|
|||
# every sibling swarm service: TLS terminates here, then plain http to
|
||||
# the co-located container over loopback (shared netns, like the store
|
||||
# this collector writes to).
|
||||
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
|
||||
locations =
|
||||
# One name for the whole collector, and the hive is a path under
|
||||
# it. The alternative — a vhost per hive — needs a certificate,
|
||||
|
|
|
|||
|
|
@ -179,165 +179,161 @@ in
|
|||
# on. Serving forge or matrix over http is merely insecure rather
|
||||
# than broken, so they keep `addSSL` and the asymmetry stays local
|
||||
# to the vhost whose correctness depends on the scheme.
|
||||
# `removeAttrs` because nixos asserts on a vhost declaring both.
|
||||
services.nginx.virtualHosts."${apex}" =
|
||||
(builtins.removeAttrs (gatewayCfg.lib.tlsFor apex) [ "addSSL" ])
|
||||
# The gateway drops `addSSL` from a vhost that sets `forceSSL`.
|
||||
services.hyperhive.gateway.vhosts."${apex}" = {
|
||||
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;
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
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;
|
||||
};
|
||||
}
|
||||
// {
|
||||
# swarm-controller's whole HTTP surface, including the live
|
||||
# `/api/openapi.json` spec — proxied untouched (no URI segment
|
||||
# after the socket path, same "pass the request through as-is"
|
||||
# shape as the per-hive dashboard's own `/api/` proxy) so the
|
||||
# path swarm-controller registered a route at is the path
|
||||
# nginx forwards, no prefix-stripping to keep in sync by hand.
|
||||
"/api/" = {
|
||||
proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:";
|
||||
# `Remote-User` from authelia's answer, set here so it replaces
|
||||
# any the client sent: swarm-controller takes it as the sender of
|
||||
# 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.
|
||||
extraConfig = ''
|
||||
${authRequest}
|
||||
error_page 401 = @swarm_api_unauthorized;
|
||||
auth_request_set $authelia_user $upstream_http_remote_user;
|
||||
proxy_set_header Remote-User $authelia_user;
|
||||
proxy_set_header Authorization "";
|
||||
# The SSE streams (terminal, agent state, subagent terminal)
|
||||
# must not sit in nginx's buffer. The controller's 15s
|
||||
# keep-alive keeps them under nginx's 60s default read timeout.
|
||||
proxy_buffering off;
|
||||
'';
|
||||
};
|
||||
"@swarm_api_unauthorized".extraConfig = ''
|
||||
if ($http_authorization) {
|
||||
return 401;
|
||||
}
|
||||
return 302 ${loginRedirect};
|
||||
# swarm-controller's whole HTTP surface, including the live
|
||||
# `/api/openapi.json` spec — proxied untouched (no URI segment
|
||||
# after the socket path, same "pass the request through as-is"
|
||||
# shape as the per-hive dashboard's own `/api/` proxy) so the
|
||||
# path swarm-controller registered a route at is the path
|
||||
# nginx forwards, no prefix-stripping to keep in sync by hand.
|
||||
"/api/" = {
|
||||
proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:";
|
||||
# `Remote-User` from authelia's answer, set here so it replaces
|
||||
# any the client sent: swarm-controller takes it as the sender of
|
||||
# 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.
|
||||
extraConfig = ''
|
||||
${authRequest}
|
||||
error_page 401 = @swarm_api_unauthorized;
|
||||
auth_request_set $authelia_user $upstream_http_remote_user;
|
||||
proxy_set_header Remote-User $authelia_user;
|
||||
proxy_set_header Authorization "";
|
||||
# The SSE streams (terminal, agent state, subagent terminal)
|
||||
# must not sit in nginx's buffer. The controller's 15s
|
||||
# keep-alive keeps them under nginx's 60s default read timeout.
|
||||
proxy_buffering off;
|
||||
'';
|
||||
};
|
||||
"@swarm_api_unauthorized".extraConfig = ''
|
||||
if ($http_authorization) {
|
||||
return 401;
|
||||
}
|
||||
return 302 ${loginRedirect};
|
||||
'';
|
||||
# ⚠️ 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;
|
||||
'';
|
||||
# ⚠️ 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;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,157 +313,153 @@ in
|
|||
# Authenticated front door onto the loopback-only store — see the
|
||||
# file-top comment for why this is safe to add without touching the
|
||||
# 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
|
||||
# `auth_request` cannot read as anything but a broken check, so this
|
||||
# vhost needs `forceSSL` rather than the `addSSL` every unauthenticated
|
||||
# sibling vhost uses.
|
||||
services.nginx.virtualHosts."${cfg.domain}" =
|
||||
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ])
|
||||
// {
|
||||
forceSSL = true;
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
locations = {
|
||||
"/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
|
||||
extraConfig = swarmAuthRequest;
|
||||
};
|
||||
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
|
||||
forceSSL = true;
|
||||
locations = {
|
||||
"/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
|
||||
extraConfig = swarmAuthRequest;
|
||||
};
|
||||
|
||||
# The swarm collector's ingest route. `=` so it outranks the `/`
|
||||
# prefix above — without a more specific location it would ride that
|
||||
# catch-all, and that is the whole hazard here.
|
||||
#
|
||||
# ⚠️ Deliberately NOT `swarmAuthRequest`. That block ends in
|
||||
# `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
|
||||
# batch at a login page, which answers 200. Ingest then reports
|
||||
# healthy while storing nothing. A machine route lets the 401 reach
|
||||
# the client unchanged.
|
||||
#
|
||||
# `auth_request` does not inherit across sibling locations (see the
|
||||
# note on `swarmAuthRequest` above), so naming it here is required
|
||||
# rather than redundant.
|
||||
"= /insert/opentelemetry/v1/logs" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}/insert/opentelemetry/v1/logs";
|
||||
extraConfig = ''
|
||||
auth_request /__hive_authelia;
|
||||
'';
|
||||
};
|
||||
# The swarm collector's ingest route. `=` so it outranks the `/`
|
||||
# prefix above — without a more specific location it would ride that
|
||||
# catch-all, and that is the whole hazard here.
|
||||
#
|
||||
# ⚠️ Deliberately NOT `swarmAuthRequest`. That block ends in
|
||||
# `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
|
||||
# batch at a login page, which answers 200. Ingest then reports
|
||||
# healthy while storing nothing. A machine route lets the 401 reach
|
||||
# the client unchanged.
|
||||
#
|
||||
# `auth_request` does not inherit across sibling locations (see the
|
||||
# note on `swarmAuthRequest` above), so naming it here is required
|
||||
# rather than redundant.
|
||||
"= /insert/opentelemetry/v1/logs" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}/insert/opentelemetry/v1/logs";
|
||||
extraConfig = ''
|
||||
auth_request /__hive_authelia;
|
||||
'';
|
||||
};
|
||||
|
||||
# The read side of that same split: the route a caller holding a
|
||||
# bearer token queries, as opposed to the `/` above which is the
|
||||
# operator's browser. Bare `auth_request` for the ingest route's
|
||||
# exact reason — `error_page 401 =302` hands an unauthenticated
|
||||
# machine caller authelia's login page as a **200 with an HTML
|
||||
# body**, so a client that reads the status code records a
|
||||
# successful query that returned no rows. 401 is the only answer
|
||||
# here that a caller cannot mistake for an empty result.
|
||||
#
|
||||
# `^~` so it outranks the `/` catch-all and stays ahead of any
|
||||
# regex location a later change adds. The prefix is the whole
|
||||
# LogsQL query API and nothing else — `query`, `tail`, `hits`,
|
||||
# `facets`, the `stats_query*` and the `field_*`/`stream_*`
|
||||
# routes — while the browser UI sits beside it under
|
||||
# `/select/vmui`. Both read off the pinned build, not the docs.
|
||||
#
|
||||
# The query is forwarded unmodified — no scoping parameter is
|
||||
# injected, so any authenticated caller reads the whole swarm's
|
||||
# logs. That is the rule in force, not an omission: read
|
||||
# permissions are a later thing, and this location is where one
|
||||
# attaches when it exists.
|
||||
#
|
||||
# Three checks: an agent's own `swarm-agent.` bearer goes to
|
||||
# `swarm-logs-auth`, Grafana's 64-hex bearer to `swarm-metrics-auth`,
|
||||
# anything else to authelia (`appendHttpConfig` above picks).
|
||||
"^~ /select/logsql/" = {
|
||||
# No URI part, so the request path and its query string reach
|
||||
# the store as the caller sent them.
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||
extraConfig = ''
|
||||
auth_request /__swarm_logs_auth;
|
||||
'';
|
||||
};
|
||||
# The read side of that same split: the route a caller holding a
|
||||
# bearer token queries, as opposed to the `/` above which is the
|
||||
# operator's browser. Bare `auth_request` for the ingest route's
|
||||
# exact reason — `error_page 401 =302` hands an unauthenticated
|
||||
# machine caller authelia's login page as a **200 with an HTML
|
||||
# body**, so a client that reads the status code records a
|
||||
# successful query that returned no rows. 401 is the only answer
|
||||
# here that a caller cannot mistake for an empty result.
|
||||
#
|
||||
# `^~` so it outranks the `/` catch-all and stays ahead of any
|
||||
# regex location a later change adds. The prefix is the whole
|
||||
# LogsQL query API and nothing else — `query`, `tail`, `hits`,
|
||||
# `facets`, the `stats_query*` and the `field_*`/`stream_*`
|
||||
# routes — while the browser UI sits beside it under
|
||||
# `/select/vmui`. Both read off the pinned build, not the docs.
|
||||
#
|
||||
# The query is forwarded unmodified — no scoping parameter is
|
||||
# injected, so any authenticated caller reads the whole swarm's
|
||||
# logs. That is the rule in force, not an omission: read
|
||||
# permissions are a later thing, and this location is where one
|
||||
# attaches when it exists.
|
||||
#
|
||||
# Three checks: an agent's own `swarm-agent.` bearer goes to
|
||||
# `swarm-logs-auth`, Grafana's 64-hex bearer to `swarm-metrics-auth`,
|
||||
# anything else to authelia (`appendHttpConfig` above picks).
|
||||
"^~ /select/logsql/" = {
|
||||
# No URI part, so the request path and its query string reach
|
||||
# the store as the caller sent them.
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||
extraConfig = ''
|
||||
auth_request /__swarm_logs_auth;
|
||||
'';
|
||||
};
|
||||
|
||||
# The store's own health, which it publishes as prometheus metrics
|
||||
# on the listener it serves queries on. The swarm collector requests
|
||||
# exactly this URL, declared as `publishedScrapeTargets.victorialogs`
|
||||
# in ./swarm-otel-service.nix; the two must agree, since that string
|
||||
# is also the token's audience.
|
||||
#
|
||||
# Bare `auth_request` to authelia, as on the ingest route above, and
|
||||
# never `/__swarm_logs_auth`, which admits an agent's bearer:
|
||||
# authelia's rules for this URL admit the collector's client and
|
||||
# deny every other subject (see `metricsRules` in ./swarm-authelia.nix).
|
||||
"= /metrics" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}/metrics";
|
||||
extraConfig = ''
|
||||
auth_request /__hive_authelia;
|
||||
'';
|
||||
};
|
||||
# The store's own health, which it publishes as prometheus metrics
|
||||
# on the listener it serves queries on. The swarm collector requests
|
||||
# exactly this URL, declared as `publishedScrapeTargets.victorialogs`
|
||||
# in ./swarm-otel-service.nix; the two must agree, since that string
|
||||
# is also the token's audience.
|
||||
#
|
||||
# Bare `auth_request` to authelia, as on the ingest route above, and
|
||||
# never `/__swarm_logs_auth`, which admits an agent's bearer:
|
||||
# authelia's rules for this URL admit the collector's client and
|
||||
# deny every other subject (see `metricsRules` in ./swarm-authelia.nix).
|
||||
"= /metrics" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}/metrics";
|
||||
extraConfig = ''
|
||||
auth_request /__hive_authelia;
|
||||
'';
|
||||
};
|
||||
|
||||
# `rewrite … last` re-matches locations, so the subrequest lands in
|
||||
# one of the two internal locations named by the map.
|
||||
"= /__swarm_logs_auth" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
rewrite ^ $swarm_logs_auth_target last;
|
||||
'';
|
||||
};
|
||||
# `rewrite … last` re-matches locations, so the subrequest lands in
|
||||
# one of the two internal locations named by the map.
|
||||
"= /__swarm_logs_auth" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
rewrite ^ $swarm_logs_auth_target last;
|
||||
'';
|
||||
};
|
||||
|
||||
# The `Authorization` header rides the subrequest unchanged.
|
||||
#
|
||||
# Any well-formed agent bearer the responder has not recently granted
|
||||
# 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
|
||||
# statuses `auth_request` passes through are 401 and 403, and
|
||||
# `swarm-logs` retries a 401.
|
||||
"= /__swarm_logs_agent_auth" = {
|
||||
proxyPass = "http://unix:${logsAuthSocket}:/";
|
||||
extraConfig = ''
|
||||
internal;
|
||||
limit_req zone=swarm_logs_agent_auth burst=${toString deployCfg.victorialogs.authBurst} nodelay;
|
||||
limit_req_status 403;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
'';
|
||||
};
|
||||
# The `Authorization` header rides the subrequest unchanged.
|
||||
#
|
||||
# Any well-formed agent bearer the responder has not recently granted
|
||||
# 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
|
||||
# statuses `auth_request` passes through are 401 and 403, and
|
||||
# `swarm-logs` retries a 401.
|
||||
"= /__swarm_logs_agent_auth" = {
|
||||
proxyPass = "http://unix:${logsAuthSocket}:/";
|
||||
extraConfig = ''
|
||||
internal;
|
||||
limit_req zone=swarm_logs_agent_auth burst=${toString deployCfg.victorialogs.authBurst} nodelay;
|
||||
limit_req_status 403;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
'';
|
||||
};
|
||||
|
||||
# Grafana's bearer, 64 hex digits (`openssl rand -hex 32`), checked by
|
||||
# `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.
|
||||
"= /__swarm_logs_grafana_auth" = {
|
||||
proxyPass = "http://unix:${grafanaAuthSocket}:/";
|
||||
extraConfig = ''
|
||||
internal;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
'';
|
||||
};
|
||||
# Grafana's bearer, 64 hex digits (`openssl rand -hex 32`), checked by
|
||||
# `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.
|
||||
"= /__swarm_logs_grafana_auth" = {
|
||||
proxyPass = "http://unix:${grafanaAuthSocket}:/";
|
||||
extraConfig = ''
|
||||
internal;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
'';
|
||||
};
|
||||
|
||||
# The subrequest itself — same target, same header set, same
|
||||
# reasoning as `swarm-ui.nix`'s own copy (measured against the
|
||||
# pinned authelia binary, not copied from an example).
|
||||
"= /__hive_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 subrequest itself — same target, same header set, same
|
||||
# reasoning as `swarm-ui.nix`'s own copy (measured against the
|
||||
# pinned authelia binary, not copied from an example).
|
||||
"= /__hive_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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# check. Same as ./swarm-victorialogs.nix's vhost.
|
||||
services.nginx.virtualHosts."${cfg.domain}" =
|
||||
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ])
|
||||
// {
|
||||
forceSSL = true;
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
locations =
|
||||
readLocations
|
||||
// uiLocations
|
||||
// {
|
||||
# Nothing reaches the store unless a location here names it. The
|
||||
# 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";
|
||||
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
|
||||
forceSSL = true;
|
||||
locations =
|
||||
readLocations
|
||||
// uiLocations
|
||||
// {
|
||||
# Nothing reaches the store unless a location here names it. The
|
||||
# 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
|
||||
# index page links mostly to paths refused above, so send it to vmui.
|
||||
"= /".return = "302 /vmui/";
|
||||
# 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.
|
||||
"= /".return = "302 /vmui/";
|
||||
|
||||
# The swarm collector's ingest route. `=` so it outranks the `/`
|
||||
# prefix above.
|
||||
#
|
||||
# ⚠️ 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
|
||||
# follow it and POST its batch at a login page that answers 200 —
|
||||
# ingest reporting healthy while storing nothing.
|
||||
"= /opentelemetry/api/v1/push" = {
|
||||
proxyPass = upstream "/opentelemetry/api/v1/push";
|
||||
extraConfig = ''
|
||||
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 swarm collector's ingest route. `=` so it outranks the `/`
|
||||
# prefix above.
|
||||
#
|
||||
# ⚠️ 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
|
||||
# follow it and POST its batch at a login page that answers 200 —
|
||||
# ingest reporting healthy while storing nothing.
|
||||
"= /opentelemetry/api/v1/push" = {
|
||||
proxyPass = upstream "/opentelemetry/api/v1/push";
|
||||
extraConfig = ''
|
||||
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 socket directory, created host-side before the container starts:
|
||||
# nixos-container refuses to start when a bind source is missing.
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ let
|
|||
deploy.grafana.plugins = [ ];
|
||||
deploy.grafana.package = pkgs.emptyDirectory;
|
||||
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
|
||||
|
|
@ -479,6 +481,39 @@ let
|
|||
&& builtins.length selectors == 12
|
||||
&& 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
|
||||
runGroup "grafana" cases
|
||||
|
|
|
|||
Loading…
Reference in a new issue