From d275238dc4be03a6b4569eb2e9d18ac5939bc000 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 13 Sep 2026 14:14:47 +0200 Subject: [PATCH 1/2] swarm-victorialogs: add a machine-facing query location on the gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent can reach the log store only through the gateway, and the only location that exists for a reader is `/`, which is the operator's browser route. That route ends in `error_page 401 =302`, and an unauthenticated caller which follows the redirect gets authelia's login page as HTTP 200 with an HTML body — so a client that checks the status code records a query that succeeded and matched no logs. Measured on the live gateway; it is the root cause behind four operator round-trips already. So the query API gets its own location, `^~ /select/logsql/`, with bare `auth_request` and no login fallback: an unauthenticated caller gets a 401 it cannot mistake for an empty result. `^~` keeps it ahead of the `/` catch-all and of any regex location added later, and it stops short of `/select/vmui/`, which is the browser UI and stays on the browser route. The query is forwarded unmodified — no filter parameter is injected, so any authenticated caller reads the whole swarm's logs. That is the rule mara set: read permissions are a later thing, and this location is where one attaches when it exists. Refs #3870 --- nix/host-modules/swarm-victorialogs.nix | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/nix/host-modules/swarm-victorialogs.nix b/nix/host-modules/swarm-victorialogs.nix index ca91ea12..9224c314 100644 --- a/nix/host-modules/swarm-victorialogs.nix +++ b/nix/host-modules/swarm-victorialogs.nix @@ -24,6 +24,10 @@ # ⚠️ That ingest location deliberately does not carry `swarmAuthRequest` — a # pusher handed its `error_page 401 =302` follows the redirect and POSTs at a # login page, which answers 200. See the location itself. +# +# The query API has a machine route of the same shape at `^~ /select/logsql/`, +# for the same reason, and with no filtering of what an authenticated caller +# may read — see that location. { pkgs, lib, @@ -202,6 +206,37 @@ in 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. + "^~ /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 /__hive_authelia; + ''; + }; + # 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). From fde4a36b93cec85cc070cf6b07f0cca1b710724e Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 13 Sep 2026 14:19:18 +0200 Subject: [PATCH 2/2] module-eval: pin the log store's machine query route and its absences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cases beside the existing ingest ones, since the read route has the same trap and one property of its own. - the route exists and carries `auth_request`; - it has no login fallback — with the browser location as the positive control, so a pass means the two routes differ rather than that `error_page` and the login host are absent from the whole vhost; - it injects no filter, on `extraConfig` and on `proxyPass` both, because VictoriaLogs takes its filters as request parameters and those ride an upstream URI as easily as a directive. A filter arriving later is then a visible diff here rather than a quiet change of rule. Each absence arm leads with a clause that proves the location resolved and that `hasInfix` finds what is really in that string, so "not there" cannot be read off an unreadable path. Refs #3870 --- nix/module-eval.nix | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 25208745..2be72a41 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -1785,6 +1785,60 @@ let && !(lib.hasInfix "error_page" l) && lib.hasInfix "error_page" browser; } + { + # The read counterpart to the ingest location: an agent queries the log + # store with a bearer token, and the `/` catch-all is the browser's + # route. Riding it would mean inheriting the login redirect the next + # case is about, so the route has to exist separately to be gated + # separately. + name = "the log store's vhost has an authenticated machine query location"; + ok = + let + q = allLocal.services.nginx.virtualHosts."logs.t.local".locations."^~ /select/logsql/" or null; + in + q != null && lib.hasInfix "auth_request" q.extraConfig; + } + { + # Same trap as the ingest case, on the read side, where it is worse: a + # redirected pusher at least stores nothing visibly, while a redirected + # *reader* is handed a 200 carrying login HTML and records a query that + # succeeded and matched no logs. The browser clause is the positive + # control — that location really does redirect to the login host — so a + # pass means these two routes differ rather than that the strings are + # absent from the whole vhost. + name = "the machine query location answers 401 instead of redirecting to a login page"; + ok = + let + v = allLocal.services.nginx.virtualHosts; + q = v."logs.t.local".locations."^~ /select/logsql/".extraConfig; + browser = v."logs.t.local".locations."/".extraConfig; + in + !(lib.hasInfix "error_page" q) + && !(lib.hasInfix "auth.t.local" q) + && lib.hasInfix "error_page" browser + && lib.hasInfix "auth.t.local" browser; + } + { + # Read access is deliberately unscoped: an authenticated caller reads + # the whole swarm's logs until a permission system exists. Pinned so a + # scoping parameter arriving later is a visible diff here rather than a + # quiet change of rule — and pinned on `proxyPass` too, because + # VictoriaLogs takes its filters as request parameters, which ride an + # upstream URI as easily as a directive. The first clause is the + # control: it proves the location resolved and that `hasInfix` finds + # what is genuinely in this string, so the absences below mean absent + # rather than unreadable. + name = "the machine query location forwards the caller's query unmodified"; + ok = + let + q = allLocal.services.nginx.virtualHosts."logs.t.local".locations."^~ /select/logsql/"; + in + lib.hasInfix "auth_request" q.extraConfig + && !(lib.hasInfix "extra_filters" q.extraConfig) + && !(lib.hasInfix "extra_stream_filters" q.extraConfig) + && !(lib.hasInfix "$args" q.extraConfig) + && !(lib.hasInfix "?" q.proxyPass); + } { # Defining an exporter and REFERENCING it are two separate lists, and # the second is where the original gate also lived. An exporter no