Commit Graph
40 Commits
Author SHA1 Message Date
ZverGuy f48a94de6d fix(observability-proxy): name the logs time out of range exactly
When both bounds were out of range the answer named one of them at
random, as the check walked a map, and it printed the instant without
its fraction, so a time one nanosecond past the last one vlselect can
hold was shown as a time inside the range. The end is now checked
first and the instant is printed in full; tests pin the inclusive
bound.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy f8ff307f71 fix(observability-proxy): bound only the logs times sent to vlselect
The 1970..2262 check refused times on parsing, including a start that
the window clamps to the maximum range before anything is sent and a
since that only becomes a capped start_offset, so an old start or a
tail resume from before 1970 answered 400 where the documented answer
is to clamp. It had also detached the parseTime doc comment.

The check now applies to the times actually sent, after clamping, and
uses the range vlselect can hold, int64 nanoseconds (1677..2262); 1970
is not a limit of vlselect.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 3722808050 test(observability-proxy): watch the slot instead of probing for it
The slow-body and stalled-client tests checked the slot by sending
requests of their own, which competed for the one slot under test: a
probe that held the slot when the request under test arrived got that
request refused, and the test failed. The tests now read the limiter's
slot count directly.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 109980ffa7 fix(observability-proxy): refuse every empty limit the same way
An empty fields_limit answered 400, but an empty limit or facets cap
fell back to the default, so one malformed parameter got two answers
depending on its name. Every cap now refuses an empty value with 400:
a cap the client sent but left blank is a client bug, and reading it
as the default would hide it.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 15eec1e5b1 fix(observability-proxy): refuse logs times vlselect cannot hold
The proxy sends vlselect RFC3339 times, which it never reads as a
guessed-unit number, but it accepted any RFC3339 year and unix
seconds up to 2286. vlselect keeps times as int64 nanoseconds through
time.UnixNano, which is undefined outside 1678..2262, so such a
window landed anywhere. An absolute time outside 1970..2262 now
answers 400; nothing was logged before 1970.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 429bf5ae76 fix(observability-proxy): answer early refusals without waiting on the body
A request refused before its body was read (401, 403, 405, or a rate
or concurrency 429) had no read deadline yet, and net/http drains up
to 256 KiB of an unread body before it sends the headers. A client
that declared a body and never sent it got no answer, and held the
handler, for as long as it kept the connection; for a second form POST
arriving while the concurrency slot was taken, that was more than five
request timeouts.

The read deadline is now set when the request arrives and lifted once
the body is read, so tails are not cut. A refusal sent with the body
still unread closes the connection instead of draining it, so it is
answered at once.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 5db02ab47e test(observability-proxy): pin the concurrency Retry-After value
The test only checked that a Retry-After was present, while the rate
limit test pins its value, so a change to the promised hint passed.
It now expects the literal value. The note on the empty fields_limit
case now gives the reason it is refused.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 1004f2f2d8 test(observability-proxy): pin the refused forms of the bearer header
Parsing the scheme with a case-insensitive split changed which header
forms reach the token review; a bare scheme, an empty token and a
scheme run into the token must still answer 401.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 38f9837f14 fix(observability-proxy): cap the hits groups when fields_limit is absent
The fields_limit cap applied only when the client sent the parameter.
vlselect returns every group when it is absent, as it does for 0, so
leaving it out bypassed the cap. The capped value is now always sent.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 1ef10537f6 fix(observability-proxy): send Retry-After with the concurrency 429
Only the rate-limit 429 said when to retry. A concurrency or tail 429
came without it, so a client had no hint and could retry at once in a
loop. It now carries a one-second hint; when a slot frees depends on
the user's other requests, which the proxy cannot predict.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 650a99ff27 fix(observability-proxy): accept the bearer scheme in any case
The authorization scheme is case-insensitive, and kube-apiserver
accepts "bearer" as well as "Bearer", so a client that works against
the API server got 401 from the proxy with the same header.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:07 +07:00
ZverGuy 95ed8134e4 fix(observability-proxy): refuse an empty fields_limit on hits
An empty fields_limit fell back to the default of 0 and reached
vlselect as fields_limit=0, instead of being refused like any other
malformed limit.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy 7dc6ac3687 fix(observability-proxy): read a leftover request body under the deadline
ParseForm reads no body on a GET or on a POST that is not a form. When
such a request declared a body and never sent it, net/http drained it
after the handler flushed the headers, with no read deadline, so the
request held its slot for as long as the client kept the connection.

The handler now reads the rest of the body itself, under the same
deadline and size cap as the form. Draining was chosen over refusing a
declared body so that clients which send one stay working. A body
over the cap now answers 413 instead of a generic 400.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy b1108e836a fix(observability-proxy): refuse to resolve a store before the cache syncs
An absent namespace resolves to the root store, and before the
informer's first list every namespace is absent, so a request served
in that window read a labeled namespace's logs from the wrong store.
Readiness keeps the Service from routing there, but a client that
addresses the pod directly is not stopped by it.

The resolver now fails while the cache is unsynced, which answers
503 like any other resolution error.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy cd04c3e6c4 fix(observability-proxy): count the aligned buckets in the logs step
vlselect widens the window to step boundaries and returns a
zero-filled bucket for each one, so a start off a boundary yields one
bucket more than the span divided by the step. Sizing the step from
the unaligned span let hits and stats_query_range return MaxBuckets+1
buckets.

The minimum step now spans MaxBuckets-1 steps, and the smallest
accepted --logs-max-buckets rises to 4 so that this step still fits
the third-of-range cap that keeps alignment inside the window.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy d3e6fd6aa8 fix(observability-proxy): keep the rate-limit buckets under their bound
When the bucket map was full and the sweep freed nothing, the new
user's bucket was added anyway, so the map grew past its bound and
every further newcomer paid a full scan under the limiter lock.

A newcomer is now refused with 429 until the first bucket refills,
and the Retry-After tells it when. Resetting the map instead would
hand every throttled user a fresh burst.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy bb073eec1b fix(observability-proxy): do not cache reviews that failed to decide
A TokenReview whose authenticator failed comes back unauthenticated
with status.error set, and a SubjectAccessReview whose authorizer
failed comes back with an evaluation error and no decision. Both were
cached as refusals, so a valid caller stayed locked out for the cache
TTL after the outage ended.

Such answers are now refused for the current request only. A review
that failed to decide still answers 401 or 403 rather than 503: RBAC
sets an evaluation error for any binding to a missing role, which a
retryable status would turn into a permanent 503 for that user.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy 0dc8a49c19 fix(observability-proxy): cut the connection when a log answer breaks off
Once the status line has gone out, a read error from the store left
the handler returning normally, so the chunked body ended cleanly and
the client took a partial answer for a complete one.

The handler now aborts the response in that case. The request is
counted before the abort, because the abort skips the rest of the
handler.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy 8b8b81013c fix(observability-proxy): bound the wait for tail response headers
The tail idle timer started only once the store had sent its headers,
and a tail request carries no deadline, so a store that accepted the
request and stayed silent held the tail slot until the client left.

The timer now starts before the request, and the same timer bounds the
header wait and the gap between lines. A store that never answers
gets 504, like a non-tail request past its deadline.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy ddc37f9c4f fix(observability-proxy): put a deadline on writes to the client
A client that stopped reading kept its slot for good: the first flush
ran before the tail idle timer started, and cancelling the upstream
request does not interrupt a write that is blocked on the client.

Every response now carries a write deadline. A tail renews it by the
idle timeout before each line, so a tail that keeps receiving stays
open while a stalled reader is cut and frees its slot; any other
response must be written by the request deadline.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy f81aaf4d25 fix(observability-proxy): admit a request before reading its body
The form was parsed before the concurrency slot was taken, and the
listener sets no body-read deadline, so a client that never finished a
POST body held a handler outside every limit for as long as it liked.
Tenant workloads reach the Service directly, so nginx buffering does
not cover this path.

The slot is now taken first and the body is read under the request
timeout; a body that does not arrive in time answers 408 and frees the
slot. The deadline is lifted once the body is read, because the
server's background read would take a later expiry for a client that
went away and cancel a live tail.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy 4101dac9b2 test(observability-proxy): pin gzip on non-tail log requests
Only tails opt out of the transport's gzip negotiation; bounded
requests keep it on purpose. Assert that side too, so a change that
applies identity to every request fails a test instead of silently
dropping compression between the proxy and the store.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy e6e3466b7d fix(observability-proxy): open live tails without waiting for a line
Go's transport asks for gzip on its own when a request sets no
Accept-Encoding. vlselect answers a gzip-negotiated tail through a gzip
writer that holds even the response headers until the first line, so the
proxy could not forward its 200 and the console kept showing a
connecting state for quiet applications; buffered lines were also
delayed.

The tail request now sends Accept-Encoding: identity, which also turns
off the transport's transparent gzip. Other endpoints keep it: they are
bounded responses read to the end, compression saves bandwidth between
the proxy and the store, and the transport decompresses before the body
is copied to the client.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy f19363ec62 fix(observability-proxy): keep Retry-After positive at tiny rates
The wait until the next token was converted from float to Duration
without a range check, so a rate limit below about 1e-10 requests per
second produced a negative Retry-After on amd64. The wait now saturates
at the largest Duration.

The earlier 429 change said range, step, bucket and limit violations
keep 422; on the logs root they are clamped or answer 400, and nothing
answers 422.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy 97a536b89a fix(observability-proxy): refuse durations past the int64 range
parseDuration summed duration parts without an overflow check and
converted out-of-range floats to Duration, which Go leaves
implementation-defined. A start_offset such as
99999999999h99999999999h1s wrapped to 999ms and was accepted, and a
huge step saturated on some architectures and wrapped on others. Such
values now answer 400 like any other invalid duration.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00
ZverGuy 163b079845 fix(observability-proxy): answer 429 to rate and concurrency limits
Clients such as Grafana and browser retry logic back off on 429 Too
Many Requests, not on 422, which they treat as a final rejection of
the query. The per-user rate limit, the per-user concurrency limit and
the open-tail caps are about the caller's pace, not the query, so they
now answer 429. The rate limiter knows when its bucket refills, so that
answer carries Retry-After; the concurrency caps cannot know when a
slot frees and omit it. Range, step, bucket and limit violations keep
422.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy dd96a415a9 test(observability-proxy): make every bucket-cap case need the rounding
Two of the three windows divided into whole milliseconds, so they
passed with the old truncating step and guarded nothing. Each window
now leaves a sub-millisecond step remainder.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 28b391ff17 fix(observability-proxy): round the step up to whole milliseconds
The step is sent to vlselect in milliseconds and was truncated, so a
window that the bucket cap does not divide evenly got one bucket more
than --logs-max-buckets allows. Round it up before the window is
shrunk to fit the step, so both caps still hold.

Bare-seconds steps and start offsets are now tested on the accepting
side too: only the refused forms were, and disabling the digit branch
passed the suite.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 4e8071aa2b fix(observability-proxy): accept bare seconds only as plain digits
Bare seconds were parsed with ParseFloat, which also takes signs,
exponents, Inf and NaN. Such a start fell through to the relative
duration form and silently moved the window, so 1.7937864e9 meant
1970 rather than an error, and NaN converted differently per CPU
architecture. Only digits with an optional fraction are taken now;
everything else is a 400.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy d1c49b38d4 fix(observability-proxy): keep step from widening the logs window
vlselect widens [start, end] outward to whole steps before scanning, so
a step of a hundred years turned the clamped week into the whole
retention. The step is now capped at the window and at a third of the
maximum range, and the window is shrunk from its start until the
widened span still fits the maximum range.

Numeric times are accepted only as unambiguous unix seconds, because
vlselect reads the unit from the digit count and a millisecond stamp or
a bare year would otherwise land in the wrong place. The standalone
ignore_global_time_filter substring check is dropped: the options block
check already covers it, and the substring only refused plain searches
for that word.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 159f90691e fix(observability-proxy): tell a slow store apart and bound tail memory
A request that hits the proxy's own deadline was answered like an
unreachable store, so a heavy query looked like an outage; it now
answers 504, and a client that went away is no longer logged as a
store failure. A tail line is read through a fixed buffer, so a single
huge log line cannot make one stream hold unbounded memory.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 5fc2cba2d5 fix(observability-proxy): refuse a monitoring label that is not a namespace
The label value is spliced into the vlselect host name. A value with a
dot would address a host in some other namespace instead of a tenant's
store, so it is treated as a resolution error rather than followed.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 8fe3c222d3 fix(observability-proxy): reject non-canonical request paths
The console nginx selects its location on the normalized path but
forwards the original request URI. A path such as
namespaces/tenant-b/../tenant-a/logs/ reaches the proxy as written, so
any interpretation the proxy makes of dot segments, empty segments or
percent-encoding could make the namespace in the SubjectAccessReview
differ from the one a normalizer downstream would see. Such paths are
answered 400 instead of being interpreted.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy cbca8ac3c9 fix(observability-proxy): refuse every LogsQL options block
A substring match on ignore_global_time_filter does not see the option
once it is written as a quoted name with an escape, or split by a
comment: the LogsQL lexer unquotes option names and joins words across
a comment, so the option still applies and lifts the time window the
proxy enforces. None of the query options is needed by a tenant, so
the options keyword followed by a parenthesis is refused outright,
skipping whitespace and comments exactly as the lexer does.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 689405127d feat(observability-proxy): serve the namespace logs root
Tenants get a way to read their namespace's logs without Grafana and
without reaching vlselect directly, which would give them neither an
RBAC check nor a forced scope. The handler authenticates, authorizes
from the path, rebuilds the upstream request with fixed tenant headers
and no client headers, and returns VictoriaLogs' native response.

A live tail is flushed after every line, is not subject to the request
timeout, and is closed once idle, so it survives far past the apiserver
request timeout without leaving forgotten browser tabs holding streams
open. An unreachable store answers 502 so the dashboard can tell an
outage from an empty result.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 61275df240 feat(observability-proxy): cap per-user concurrency, rate and open tails
The log stores are shared by every namespace routed to them, and the
root store also serves platform Grafana, so one tenant's dashboard must
not be able to monopolise vlselect. Live tails are counted apart from
request concurrency because they hold their slot for minutes and would
otherwise starve the same user's panels.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy eb0d6e5957 feat(observability-proxy): rebuild logs queries from an allowlist
vlselect has no notion of a tenant: whatever filter reaches it decides
what is returned. The upstream parameters are therefore rebuilt from a
per-endpoint allowlist with the namespace scope as the only
extra_stream_filters value, so a client cannot widen it through
extra_filters, tenant headers or an alternate parameter spelling.

VictoriaLogs ANDs extra_stream_filters into every subquery, so union
and in() queries pass through unparsed. What the query can still do is
cost: a missing start scans the whole retention, a tiny step returns a
bucket per millisecond, query has no default limit, and
ignore_global_time_filter lifts the window altogether. Each of those is
bounded or refused here, and a tail resume point becomes a capped
start_offset.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy e02d0fa16e feat(observability-proxy): resolve a namespace to its log store
Fluent Bit writes a namespace's logs to the store its monitoring label
names, falling back to the root tenant, so exactly one store answers for
a namespace. Reading with any other rule would show an empty log view
or the wrong tenant's store, so the resolver repeats the collector's
rule and is tested against the same namespace set as its chart test.
It reads a namespace lister, keeping store lookups off the apiserver.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 448cd2b5bd feat(observability-proxy): authenticate and authorize with cached reviews
The proxy holds no credentials of its own: kube-apiserver answers who a
bearer token belongs to and whether that identity may read a scope, the
same way an aggregated API server would ask through delegated auth.
Both answers are cached briefly so a dashboard polling several panels
does not turn into a review per request, while transport errors are
left uncached so an apiserver blip does not lock users out for the TTL.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
ZverGuy 8e35ff521c feat(observability-proxy): parse the four tenant observability roots
The proxy derives its single SubjectAccessReview from the request path,
so the path has to be split and validated before anything else runs.
Names are DNS-1123 checked because they end up quoted inside LogsQL and
PromQL scopes, and percent-encoding is refused so a decoded slash
cannot shift the segments authorization is built from.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00