diff --git a/nix/host-modules/swarm-victorialogs.nix b/nix/host-modules/swarm-victorialogs.nix index 3e1e4781..4787b43e 100644 --- a/nix/host-modules/swarm-victorialogs.nix +++ b/nix/host-modules/swarm-victorialogs.nix @@ -11,12 +11,16 @@ # It is the collector that feeds this, not the services directly: one ingest # point per swarm, same shape as the metrics path. # -# ⚠️ NO GATEWAY VHOST, and that omission is deliberate rather than unfinished. -# VictoriaLogs' ingest and query endpoints are unauthenticated, exactly like -# the metrics store's — and the metrics store *is* published under a -# resolvable name, which is an open security question rather than a settled -# design. Publishing this one the same way would repeat that before the first -# instance is decided. The reader is Grafana, which is on this host. +# Gateway vhost is authenticated, unlike the metrics store's: an operator +# reaches this at `https://${cfg.domain}/` gated by the same `auth_request` +# check against authelia that swarm-ui's own vhost uses (`swarmAuthRequest` +# below, same shape as `swarm-ui.nix`'s) — see that file's copy for the full +# rationale (forceSSL is load-bearing there too: authelia answers a plain-http +# auth subrequest with 400, which `auth_request` cannot interpret as anything +# but a broken check). The store's own listener stays loopback-only and +# unauthenticated exactly as before — the collector still writes to it +# directly, never through this vhost — so this adds a new authenticated front +# door without touching the existing write path at all. { pkgs, lib, @@ -27,6 +31,25 @@ let cfg = config.services.hyperhive.swarm.victorialogs; networkCfg = config.services.hyperhive.network; hyperhiveCfg = config.services.hyperhive; + gatewayCfg = hyperhiveCfg.gateway; + autheliaCfg = hyperhiveCfg.swarm.authelia; + swarmDomain = hyperhiveCfg.swarm.domain; + + # Total on a null swarm domain for the same reason every sibling module is: + # the required-domain assertion in hive-network.nix should be what an + # operator sees, not a coercion error from here. + domainBase = if swarmDomain == null then "invalid" else swarmDomain; + + # Copy of `swarm-ui.nix`'s own `swarmAuthRequest` — not shared code because + # this vhost needs exactly the two locations that reference it (`/` and the + # internal auth-request target below) and pulling in swarm-ui.nix for one + # string would couple this module to swarm-ui existing at all, which it + # need not. Same three lines, same reasoning as that file's own comment. + swarmAuthRequest = '' + auth_request /__hive_authelia; + auth_request_set $target_url $scheme://$http_host$request_uri; + error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url; + ''; in { options.services.hyperhive.swarm.victorialogs = { @@ -59,6 +82,21 @@ in ''; }; + domain = lib.mkOption { + type = lib.types.str; + default = "logs.${domainBase}"; + defaultText = lib.literalExpression ''"logs.''${services.hyperhive.swarm.domain}"''; + description = '' + Name the gateway serves this on, behind the same authelia + `auth_request` gate as the swarm UI's own vhost. A sibling of the + swarm's other service names, so the swarm-services sub-CA can + issue for it — see `hive-tls.nix` for why a service name being a + sibling rather than a child decides which CA may sign it, and + `swarm.nix`'s `serviceDomains'` for where this name has to be + registered for that to actually happen. + ''; + }; + port = lib.mkOption { type = lib.types.port; default = 9428; @@ -103,6 +141,58 @@ in # hosts are separate evaluations. services.hyperhive.swarm.otel.scrapeTargets.victorialogs = "127.0.0.1:${toString cfg.port}"; + # The gateway name and the quick-link, both inside `cfg.enable` — same + # "only the host that runs the service may claim the name" guard every + # sibling swarm-service module uses (`swarm-grafana.nix`, + # `swarm-victoriametrics.nix`). + services.hyperhive.gateway.localNames = [ cfg.domain ]; + + services.hyperhive.swarm.controller.links = [ + { + label = "Logs"; + icon = "📜"; + url = "https://${cfg.domain}/"; + } + ]; + + # 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 — + # 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; + }; + # 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 = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; + extraConfig = '' + internal; + 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; + ''; + }; + }; + }; + containers.${cfg.machine} = { autoStart = true; ephemeral = false; diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 05324efd..aad8691a 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -59,7 +59,14 @@ let # failed every POST and dropped the samples. ++ lib.optional swarmCfg.grafana.enable swarmCfg.grafana.domain ++ lib.optional swarmCfg.victoriametrics.enable swarmCfg.victoriametrics.domain - ++ lib.optional swarmCfg.otel.enable swarmCfg.otel.domain; + ++ lib.optional swarmCfg.otel.enable swarmCfg.otel.domain + # VictoriaLogs' vhost is new (was previously unpublished entirely — see + # swarm-victorialogs.nix's file-top comment) and needs the same + # membership every gateway-published swarm service needs: absent from + # this list, `gateway.lib.tlsFor` falls back to the hive leaf, which + # cannot cover a name under a different apex — see the ⚠️ above this + # list for what that looked like the last time a name was missed here. + ++ lib.optional swarmCfg.victorialogs.enable swarmCfg.victorialogs.domain; # Hives whose entry still carries the removed `certFingerprint`. Scanned # here, at top level, because that is the only place an assertion about a