Watch
0
0
Fork
You've already forked hyperhive
0

swarm-victoriametrics: forward only the store's read API

The metrics vhost's `/` proxied every path to VictoriaMetrics with no
auth_request, so anything that could reach metrics.<swarm> could write
(`/api/v1/import`, `/api/v1/write`, ...) and delete
(`/api/v1/admin/tsdb/delete_series`) series. Only the OTLP push route
was gated.

`/` now answers 403. The read routes Grafana's Prometheus datasource
and vmui use are forwarded by exact or `^~` prefix location, each with
the matched path as its proxy_pass URI. The bare domain, which the
swarm UI's Metrics quick link opens, redirects to vmui instead of the
store's index page. The OTLP push location is unchanged.

Closes #5186
This commit is contained in:
atlas 2026-10-13 01:45:55 +02:00 • committed by mara
commit 53d0d59be0
3 changed files with 94 additions and 8 deletions

View file

@ -25,6 +25,51 @@ let
# Shared host netns, like every sibling swarm container: the gateway
# reaches this at 127.0.0.1:<port>.
privateNetwork = false;
# The store's read API: the only part of it the vhost serves without a
# credential. Each location's `proxy_pass` carries the path, so the store
# receives the path nginx matched, not the caller's unnormalised spelling.
#
# Grafana's Prometheus datasource reads the query, series, label, metadata,
# buildinfo, exemplar, rules and alerts routes; the store answers the last
# three with fixed placeholders. vmui is the static files under `/vmui/`
# plus the query API and the remaining read-only routes here. Paths read off
# the pinned build's `app/vmselect/main.go` and vmui's `src/`.
readPaths = [
"/api/v1/query"
"/api/v1/query_range"
"/api/v1/query_exemplars"
"/api/v1/series"
"/api/v1/labels"
"/api/v1/metadata"
"/api/v1/rules"
"/api/v1/alerts"
"/api/v1/status/buildinfo"
"/api/v1/export"
"/api/v1/export/csv"
"/api/v1/status/tsdb"
"/api/v1/status/metric_names_stats"
"/api/v1/status/top_queries"
"/api/v1/status/active_queries"
"/expand-with-exprs"
"/prettify-query"
"/metric-relabel-debug"
"/vmui"
];
# The store serves only `<name>/values` under `/api/v1/label/`, and only
# static files and UI config under `/vmui/`.
readPrefixes = [
"/api/v1/label/"
"/vmui/"
];
readLocations =
let
to = path: { proxyPass = "http://127.0.0.1:${toString cfg.port}${path}"; };
in
lib.listToAttrs (
map (p: lib.nameValuePair "= ${p}" (to p)) readPaths
++ map (p: lib.nameValuePair "^~ ${p}" (to p)) readPrefixes
);
in
{
# What the store IS from any hive's point of view, the name it answers on and
@ -90,14 +135,19 @@ in
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = {
"/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
};
locations = readLocations // {
# 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 collector's ingest route, and the only authenticated thing
# on this vhost. `=` so it outranks the `/` prefix above, which would
# otherwise carry these writes with no check at all.
# on this vhost. `=` 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

View file

@ -425,6 +425,38 @@ let
&& !(lib.hasInfix "error_page" l)
&& lib.hasInfix "error_page" browser;
}
{
# The store authenticates nothing itself, so every location that
# forwards without `auth_request` is open to anything that can reach the
# domain. The query location is the positive control: it shows the
# filter finds forwarding locations at all.
name = "the metrics vhost forwards only read paths without a credential";
ok =
let
locs = allLocal.services.nginx.virtualHosts."metrics.t.local".locations;
open = lib.filterAttrs (
_: l:
l.proxyPass != null
&& !(lib.hasInfix "auth_request" l.extraConfig)
&& !(lib.hasInfix "internal;" l.extraConfig)
) locs;
writeWords = [
"import"
"write"
"admin"
"delete"
"insert"
"internal"
"push"
"prometheus"
"influx"
];
in
locs."/".proxyPass == null
&& locs."/".return == "403"
&& open ? "= /api/v1/query"
&& lib.all (n: n != "/" && !(lib.any (w: lib.hasInfix w n) writeWords)) (lib.attrNames open);
}
{
# 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