fix(freesailarr): redirect the bare /qbittorrent prefix in Gateway mode #119

Merged
jklein merged 1 commits from fix/freesailarr-118-qbittorrent-slash-redirect into main 2026-10-12 22:27:12 +02:00
Member

Fixes #118

What was wrong

freesailarr serves qBittorrent under a prefix and strips that prefix, because qBittorrent has no base-path setting. For the slashed form that is correct. For the bare form — /qbittorrent, no trailing slash — the request is stripped to /, qBittorrent answers 200 with its login page, and the browser's document URL keeps the prefixless form. Every relative link in that page therefore resolves against the host root, and on the shared host the root is seerr: the login form comes up unstyled and does nothing while a different application answers its assets, XHRs and form posts. Nothing reports an error at any level — not the chart, not the render, not the schema, not the install.

The issue describes this behaviour correctly. The one detail that moved on investigation is where the existing redirect lives: it is not in this chart and never was (git log -S 'configuration-snippet' -- freesailarr/ and git log --all -S 'return 301' -- freesailarr/ both return zero commits). It is an operator-supplied nginx.ingress.kubernetes.io/configuration-snippet on the live Ingress objects, managed by Fleet from jk-cluster-config. That is why the chart looked correct and the running system worked at the same time.

What this PR does, and what it deliberately does not

Gateway mode gets the redirect, as a rule of the route. The shared-domain HTTPRoute now carries a second rule for qBittorrent: an Exact match on qbittorrent.ingress.path with a RequestRedirect filter replacing the full path with the slashed form, statusCode: 301.

  • Precedence, not position. The Gateway API ranks Exact path match above Prefix path match with largest number of characters, with list order consulted only when all earlier criteria tie (gateway-api v1.6.3, apis/v1/httproute_types.go). So this rule wins over both qBittorrent's own PathPrefix rule and seerr's PathPrefix / wherever it is emitted, and the rules' length-descending sort stays what it was — a rendering convention.
  • 301 and not 308. Only 301 and 302 are Core in that API; everything else is Extended, and an implementation that does not support the value must set Accepted: False with reason UnsupportedValue on the whole route — which on the shared host would take prowlarr, radarr, sonarr and seerr down with qBittorrent, in a status field no controller writes in CI. It would also contradict decision 1 of #114 (render only the implementation-neutral subset). 308's method preservation is not needed: this rule fires on exactly one path, the prefixless UI entry point, which a browser reaches with GET, while qBittorrent's API lives under <path>/api/... — slashed, longer, and served by the PathPrefix rule, which this rule never sees. statusCode is written out because it defaults to 302.
  • One named asymmetry to the nginx snippet: HTTPRequestRedirectFilter has exactly five fields (scheme, hostname, path, port, statusCode) and says nothing about the query string, while ReplaceFullPath replaces the full path. The snippet carries the query over explicitly with $is_args$args. It costs nothing on the UI entry path, which takes no query parameters, but it is a real difference and not a translation.
  • The path comes from the value via $app.path, never a literal. The rule is guarded on qbittorrent and ne $app.path "/"; being inside the shared list already excludes the own-host case. Both guards were exercised.

The Ingress branch is untouched, and freesailarr.ingress.yaml is not modified. No third Ingress object. Instead freesailarr/values.yaml now documents:

  • the trap, and that the path must be called with a trailing slash in Ingress mode;
  • the working remedy — nginx.ingress.kubernetes.io/configuration-snippet: if ($uri = "/qbittorrent") { return 301 $scheme://$host/qbittorrent/$is_args$args; } through ingress.annotations — with its price: snippets are honoured only on a controller started with allow-snippet-annotations=true, off by default since ingress-nginx 1.9, and switching it on means accepting annotation risk level Critical. A chart that shipped it would render a silent no-op on every cluster keeping the default, which is worse than naming the gap;
  • why a third Ingress object with pathType: Exact is not a substitute. In ingress-nginx controller-v1.14.5, internal/ingress/controller/template/template.go, buildLocation() returns a plain regex location (~* "^<path>") in its enforceRegex branch and returns there, before ever reaching the PathTypeExact branch that would emit the = "<path>" location nginx gives precedence to. And enforceRegex is computed once per nginx server: rootfs/etc/nginx/template/nginx.tmpl evaluates enforceRegexModifier over every location of the hostname and it returns true as soon as one needs a rewrite. qBittorrent's own object carries rewrite-target on that very hostname, so enforceRegex is true for the whole server and an Exact path renders as an ordinary regex location — the exact-match precedence never comes into being. The unsuitability follows from that short-circuit alone; what an unanchored regex location would then do to ordering (shadow the app, or loop the redirect, depending on emission order) is inferred from the same sources and has not been observed on a running nginx.

The operator's snippet on freesailarr.jk-dev.de stays, and stays necessary. That host runs in Ingress mode — it was deliberately not migrated — so the snippet remains the only thing performing the redirect there. This PR changes nothing about it. It stops mattering only if and when that host moves to Gateway mode, at which point ingress.annotations fall away anyway. No follow-up issue, and nothing here makes it redundant. jk-cluster-config is not touched.

The new render assurance

ci/run-httproute-match-test.sh gains a third assertion, per HTTPRoute object: a rule matching a PathPrefix other than / and carrying a URLRewrite filter with urlRewrite.path.type: ReplacePrefixMatch requires a rule in the same route whose match is type: Exact on that same path and which carries a RequestRedirect filter. Per object because Gateway-API precedence is a property of one route; a different route is a different hostname. Precedence also makes it a question of existence, not order.

Same mechanism as the two sibling tests — Helm parses the YAML through ci/yaml2json, the comparison is an inline python3 heredoc on the standard library only, emitting OK|/FAIL|/SKIP| lines the bash loop counts. No pyyaml, no yq, no Docker. Documented in the script header, including in the "WHAT IS DELIBERATELY NOT CHECKED" list that this proves the object carries a redirect and not that any redirect is ever served.

ci/httproute/freesailarr.yaml already existed and already switches the chart into Gateway mode with qBittorrent on the shared host, so the new check really fires rather than covering nothing. It is unchanged — bazarr is still the app moved onto a host of its own.

Measured: 37 assertions over 15 charts, up from 36, plus one additional "not checked" line for freesailarr — the redirect rule has no backendRefs, which the script already reports as a legal filter-only rule. Both verdicts are correct at once and the gap is not counted as passing.

Both fail paths exercised, not assumed.

Redirect pointed at a path other than the one stripped:

  FAIL   freesailarr: HTTPRoute httproute-freesailarr-httproute rules[0] strips the prefix
  '/qbittorrent' with a ReplacePrefixMatch rewrite, but NO rule of this route redirects the
  bare prefix: there is no match of type Exact on '/qbittorrent' carrying a RequestRedirect
  filter. [...] Gitea #118
freesailarr: FAILED
ERROR: HTTPRoute findings in: freesailarr
EXIT CODE = 1

The rule deleted outright: same FAIL, assertions held: 8, EXIT CODE = 1. Restored, the full run is failed: 0, assertions held: 37, exit 0.

Version: minor, and the burden of proof

3.2.0+up1.0.0 → 3.3.0+up1.0.0, appVersion stays "1.0.0". With the Ingress branch unchanged and httpRoute.enabled defaulting to false, no existing release's render changes by a byte and no value becomes required — additive, backward-compatible, unchanged defaults.

Empty helm template diff against origin/main, both Ingress shapes:

###### Ingress mode, shared-domain (ci/freesailarr/test-values.yaml) ######
DIFF EMPTY (exit 0)

###### Ingress mode, per-app hosts ######
DIFF EMPTY (exit 0)

Non-empty diff in Gateway mode (with the ci/httproute/freesailarr.yaml overlay) — exactly 11 added lines, nothing else:

@@ -1129,6 +1129,17 @@
           port: 8080
     - matches:
         - path:
+            type: Exact
+            value: "/qbittorrent"
+      filters:
+        - type: RequestRedirect
+          requestRedirect:
+            path:
+              type: ReplaceFullPath
+              replaceFullPath: "/qbittorrent/"
+            statusCode: 301
+    - matches:
+        - path:
             type: PathPrefix
             value: "/prowlarr"
       backendRefs:

What stays unproven

No Gateway controller runs in CI and no HTTPRoute ever reaches Accepted: True, so "it redirects" is not demonstrable here. What is demonstrable, and is: a schema-valid object against the real Gateway-API CRDs (install-test), the render assertions above, and that the rule matches the path the value sets. Whether an implementation honours statusCode: 301 — and the query-string behaviour noted above — is outside what a render can see.

Also in this PR

  • freesailarr/templates/NOTES.txt: the qBittorrent section now says "open it with the trailing slash" in both modes, with the Ingress-mode limitation and the Gateway-mode redirect each stated in their own branch. A header comment records that nothing in CI reads this file (helm template does not emit it; no workflow or script under ci/ reads it — the three NOTES hits under ci/ are comments in fixtures), so its claims are unverified by any stage and carry review-only assurance.
  • values.schema.json needed no change — no values are introduced. Confirmed rather than assumed: both renders above pass schema validation, and helm lint --strict is clean.
  • README.md: the third finding in the failure-mode table, the per-object scope and precedence reasoning, the two new "not checked" entries, the corrected assertion figures, and the freesailarr documented-exception paragraph.

Out of scope, still open

freesailarr/Chart.yaml has no icon:. That is a deliberate, documented omission with an eight-line rationale in the file (the stack has no single upstream), so helm lint prints [INFO] icon is recommended. Left alone.

Local verification

  • helm lint --strict freesailarr — 1 chart(s) linted, 0 chart(s) failed (only the documented icon INFO)
  • helm package freesailarr — freesailarr-3.3.0+up1.0.0.tgz
  • ci/run-httproute-match-test.sh — failed: 0, assertions held: 37, exit 0
  • ci/run-selector-match-test.sh — failed: 0, exit 0
  • ci/run-quantity-render-test.sh — failed: 0, exit 0
  • Guards exercised: qbittorrent.ingress.path: / renders no redirect rule; qBittorrent on its own host renders no redirect rule; --set qbittorrent.ingress.path=/torrents renders Exact /torrents → /torrents/

Commit is signed. Opened under the bot account via tea.

Fixes #118 ## What was wrong `freesailarr` serves qBittorrent under a prefix and strips that prefix, because qBittorrent has no base-path setting. For the slashed form that is correct. For the **bare** form — `/qbittorrent`, no trailing slash — the request is stripped to `/`, qBittorrent answers **200** with its login page, and the browser's document URL keeps the prefixless form. Every relative link in that page therefore resolves against the host root, and on the shared host the root is **seerr**: the login form comes up unstyled and does nothing while a *different application* answers its assets, XHRs and form posts. Nothing reports an error at any level — not the chart, not the render, not the schema, not the install. **The issue describes this behaviour correctly.** The one detail that moved on investigation is where the existing redirect lives: it is not in this chart and never was (`git log -S 'configuration-snippet' -- freesailarr/` and `git log --all -S 'return 301' -- freesailarr/` both return zero commits). It is an operator-supplied `nginx.ingress.kubernetes.io/configuration-snippet` on the live Ingress objects, managed by Fleet from `jk-cluster-config`. That is why the chart looked correct and the running system worked at the same time. ## What this PR does, and what it deliberately does not **Gateway mode gets the redirect, as a rule of the route.** The shared-domain `HTTPRoute` now carries a second rule for qBittorrent: an `Exact` match on `qbittorrent.ingress.path` with a `RequestRedirect` filter replacing the full path with the slashed form, `statusCode: 301`. - **Precedence, not position.** The Gateway API ranks `Exact` path match above `Prefix path match with largest number of characters`, with list order consulted only when all earlier criteria tie (gateway-api v1.6.3, `apis/v1/httproute_types.go`). So this rule wins over both qBittorrent's own `PathPrefix` rule and seerr's `PathPrefix /` wherever it is emitted, and the rules' length-descending sort stays what it was — a rendering convention. - **301 and not 308.** Only 301 and 302 are **Core** in that API; everything else is **Extended**, and an implementation that does not support the value must set `Accepted: False` with reason `UnsupportedValue` on the **whole route** — which on the shared host would take prowlarr, radarr, sonarr and seerr down with qBittorrent, in a status field no controller writes in CI. It would also contradict decision 1 of #114 (render only the implementation-neutral subset). 308's method preservation is not needed: this rule fires on exactly one path, the prefixless UI entry point, which a browser reaches with GET, while qBittorrent's API lives under `<path>/api/...` — slashed, longer, and served by the `PathPrefix` rule, which this rule never sees. `statusCode` is written out because it **defaults to 302**. - **One named asymmetry** to the nginx snippet: `HTTPRequestRedirectFilter` has exactly five fields (`scheme`, `hostname`, `path`, `port`, `statusCode`) and says nothing about the query string, while `ReplaceFullPath` replaces the full *path*. The snippet carries the query over explicitly with `$is_args$args`. It costs nothing on the UI entry path, which takes no query parameters, but it is a real difference and not a translation. - The path comes from the value via `$app.path`, never a literal. The rule is guarded on `qbittorrent` **and** `ne $app.path "/"`; being inside the shared list already excludes the own-host case. Both guards were exercised. **The Ingress branch is untouched, and `freesailarr.ingress.yaml` is not modified.** No third Ingress object. Instead `freesailarr/values.yaml` now documents: - the trap, and that the path must be called **with a trailing slash** in Ingress mode; - the working remedy — `nginx.ingress.kubernetes.io/configuration-snippet: if ($uri = "/qbittorrent") { return 301 $scheme://$host/qbittorrent/$is_args$args; }` through `ingress.annotations` — **with its price**: snippets are honoured only on a controller started with `allow-snippet-annotations=true`, **off by default since ingress-nginx 1.9**, and switching it on means accepting annotation risk level `Critical`. A chart that shipped it would render a silent no-op on every cluster keeping the default, which is worse than naming the gap; - **why a third Ingress object with `pathType: Exact` is not a substitute.** In ingress-nginx `controller-v1.14.5`, `internal/ingress/controller/template/template.go`, `buildLocation()` returns a plain regex location (`~* "^<path>"`) in its `enforceRegex` branch and **returns there**, before ever reaching the `PathTypeExact` branch that would emit the `= "<path>"` location nginx gives precedence to. And `enforceRegex` is computed **once per nginx `server`**: `rootfs/etc/nginx/template/nginx.tmpl` evaluates `enforceRegexModifier` over every location of the hostname and it returns true as soon as one needs a rewrite. qBittorrent's own object carries `rewrite-target` on that very hostname, so `enforceRegex` is true for the whole server and an `Exact` path renders as an ordinary regex location — the exact-match precedence never comes into being. The unsuitability follows from that short-circuit alone; what an unanchored regex location would then do to *ordering* (shadow the app, or loop the redirect, depending on emission order) is **inferred from the same sources and has not been observed on a running nginx**. **The operator's snippet on `freesailarr.jk-dev.de` stays, and stays necessary.** That host runs in **Ingress mode** — it was deliberately not migrated — so the snippet remains the only thing performing the redirect there. This PR changes nothing about it. It stops mattering only if and when that host moves to Gateway mode, at which point `ingress.annotations` fall away anyway. No follow-up issue, and nothing here makes it redundant. `jk-cluster-config` is not touched. ## The new render assurance `ci/run-httproute-match-test.sh` gains a third assertion, **per HTTPRoute object**: a rule matching a `PathPrefix` other than `/` and carrying a `URLRewrite` filter with `urlRewrite.path.type: ReplacePrefixMatch` requires a rule **in the same route** whose match is `type: Exact` on that same path and which carries a `RequestRedirect` filter. Per object because Gateway-API precedence is a property of one route; a different route is a different hostname. Precedence also makes it a question of existence, not order. Same mechanism as the two sibling tests — Helm parses the YAML through `ci/yaml2json`, the comparison is an inline `python3` heredoc on the standard library only, emitting `OK|`/`FAIL|`/`SKIP|` lines the bash loop counts. No `pyyaml`, no `yq`, no Docker. Documented in the script header, including in the "WHAT IS DELIBERATELY NOT CHECKED" list that this proves the **object** carries a redirect and **not** that any redirect is ever served. `ci/httproute/freesailarr.yaml` already existed and already switches the chart into Gateway mode with qBittorrent on the shared host, so the new check really fires rather than covering nothing. It is unchanged — bazarr is still the app moved onto a host of its own. **Measured: 37 assertions over 15 charts, up from 36**, plus one additional "not checked" line for freesailarr — the redirect rule has no `backendRefs`, which the script already reports as a legal filter-only rule. Both verdicts are correct at once and the gap is not counted as passing. **Both fail paths exercised, not assumed.** Redirect pointed at a path other than the one stripped: ``` FAIL freesailarr: HTTPRoute httproute-freesailarr-httproute rules[0] strips the prefix '/qbittorrent' with a ReplacePrefixMatch rewrite, but NO rule of this route redirects the bare prefix: there is no match of type Exact on '/qbittorrent' carrying a RequestRedirect filter. [...] Gitea #118 freesailarr: FAILED ERROR: HTTPRoute findings in: freesailarr EXIT CODE = 1 ``` The rule deleted outright: same `FAIL`, `assertions held: 8`, `EXIT CODE = 1`. Restored, the full run is `failed: 0`, `assertions held: 37`, exit 0. ## Version: minor, and the burden of proof `3.2.0+up1.0.0` → **`3.3.0+up1.0.0`**, `appVersion` stays `"1.0.0"`. With the Ingress branch unchanged and `httpRoute.enabled` defaulting to `false`, no existing release's render changes by a byte and no value becomes required — additive, backward-compatible, unchanged defaults. **Empty `helm template` diff against `origin/main`, both Ingress shapes:** ``` ###### Ingress mode, shared-domain (ci/freesailarr/test-values.yaml) ###### DIFF EMPTY (exit 0) ###### Ingress mode, per-app hosts ###### DIFF EMPTY (exit 0) ``` **Non-empty diff in Gateway mode (with the `ci/httproute/freesailarr.yaml` overlay)** — exactly 11 added lines, nothing else: ```diff @@ -1129,6 +1129,17 @@ port: 8080 - matches: - path: + type: Exact + value: "/qbittorrent" + filters: + - type: RequestRedirect + requestRedirect: + path: + type: ReplaceFullPath + replaceFullPath: "/qbittorrent/" + statusCode: 301 + - matches: + - path: type: PathPrefix value: "/prowlarr" backendRefs: ``` ## What stays unproven **No Gateway controller runs in CI and no `HTTPRoute` ever reaches `Accepted: True`, so "it redirects" is not demonstrable here.** What is demonstrable, and is: a schema-valid object against the real Gateway-API CRDs (install-test), the render assertions above, and that the rule matches the path the value sets. Whether an implementation honours `statusCode: 301` — and the query-string behaviour noted above — is outside what a render can see. ## Also in this PR - `freesailarr/templates/NOTES.txt`: the qBittorrent section now says "open it with the trailing slash" in both modes, with the Ingress-mode limitation and the Gateway-mode redirect each stated in their own branch. A header comment records that **nothing in CI reads this file** (`helm template` does not emit it; no workflow or script under `ci/` reads it — the three `NOTES` hits under `ci/` are comments in fixtures), so its claims are unverified by any stage and carry review-only assurance. - `values.schema.json` needed **no change** — no values are introduced. Confirmed rather than assumed: both renders above pass schema validation, and `helm lint --strict` is clean. - `README.md`: the third finding in the failure-mode table, the per-object scope and precedence reasoning, the two new "not checked" entries, the corrected assertion figures, and the `freesailarr` documented-exception paragraph. ## Out of scope, still open `freesailarr/Chart.yaml` has no `icon:`. That is a deliberate, documented omission with an eight-line rationale in the file (the stack has no single upstream), so `helm lint` prints `[INFO] icon is recommended`. Left alone. ## Local verification - `helm lint --strict freesailarr` — `1 chart(s) linted, 0 chart(s) failed` (only the documented `icon` INFO) - `helm package freesailarr` — `freesailarr-3.3.0+up1.0.0.tgz` - `ci/run-httproute-match-test.sh` — `failed: 0`, `assertions held: 37`, exit 0 - `ci/run-selector-match-test.sh` — `failed: 0`, exit 0 - `ci/run-quantity-render-test.sh` — `failed: 0`, exit 0 - Guards exercised: `qbittorrent.ingress.path: /` renders no redirect rule; qBittorrent on its own host renders no redirect rule; `--set qbittorrent.ingress.path=/torrents` renders `Exact /torrents` → `/torrents/` Commit is signed. Opened under the bot account via `tea`.
jlab-bot added 1 commit 2026-10-12 22:08:06 +02:00
fix(freesailarr): redirect the bare /qbittorrent prefix in Gateway mode
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 8s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 22s
Install-test / Install-test freesailarr (pull_request) Successful in 4m19s
Validate Helm Charts / Install-test (pull_request) Successful in 4m19s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
Publish Freesailarr Helm Chart / Publish Freesailarr Helm Chart (push) Successful in 12s
SonarQube Analysis / SonarQube Analysis (push) Successful in 48s
f202fdc3fc
qBittorrent has no base-path setting, so the chart serves it under a prefix
and strips that prefix before the request reaches it. For the slashed form
that is correct. For the BARE form - /qbittorrent, no trailing slash - the
request is rewritten to "/" and qBittorrent answers 200 with its login page,
while the browser's document URL keeps the prefixless form. Every relative
link in that page therefore resolves against the host root, and on the shared
host the root is seerr: the login form comes up unstyled and does nothing
while a different application answers its assets and XHRs. Nothing reports an
error at any level.

The shared-domain HTTPRoute now carries a second rule for qBittorrent: an
Exact match on qbittorrent.ingress.path with a RequestRedirect filter that
replaces the full path with the slashed form, statusCode 301. The Gateway API
ranks an Exact path match above every prefix match, so it wins over both
qBittorrent's own PathPrefix rule and seerr's "/" wherever it is emitted - the
rules' length-descending order stays a rendering convention. 301 rather than
308 because only 301 and 302 are Core in that API; an Extended value an
implementation declines makes it set Accepted=False on the WHOLE route, which
would take the four neighbouring apps' routing down with it, and that would
also contradict decision 1 of Gitea #114. statusCode is written out because it
defaults to 302. The rule is guarded on the app being qbittorrent and its path
not being "/", and it reads the path from the value, never a literal.

The INGRESS BRANCH IS UNCHANGED, deliberately. The remedy there is an
nginx configuration-snippet annotation, and the chart cannot ship it: snippets
are ignored unless the controller runs with allow-snippet-annotations=true,
off by default since ingress-nginx 1.9 and only switched on by accepting
annotation risk level "Critical". A shipped annotation would be a silent no-op
on every cluster keeping the default. The working snippet, that price, and why
a third Ingress object with pathType: Exact is not a substitute - buildLocation
in ingress-nginx controller-v1.14.5 returns a regex location in its
enforceRegex branch before reaching the PathTypeExact branch, and enforceRegex
is computed once per nginx server over every location of the hostname, one of
which carries rewrite-target - are all written out in values.yaml instead.

ci/run-httproute-match-test.sh gains a third assertion, per HTTPRoute object:
a rule stripping a prefix with ReplacePrefixMatch requires a rule in the same
route matching that path Exactly and carrying a RequestRedirect filter. This
is what stops the gap reappearing; bulwark is the second rewrite-target chart
and meets the same check when group 3 of Gitea #114 lands. 37 assertions over
15 charts, up from 36. Both fail paths were exercised: the rule deleted, and
the redirect pointed at a path other than the one stripped.

Minor and not major: with the Ingress branch untouched and httpRoute.enabled
defaulting to false, no existing release's render changes by a byte and no
value becomes required. Shown by an empty helm template diff against
origin/main in both Ingress shapes, shared-domain and per-app hosts.

Fixes #118
jklein approved these changes 2026-10-12 22:27:08 +02:00
jklein merged commit f202fdc3fc into main 2026-10-12 22:27:12 +02:00
jklein deleted branch fix/freesailarr-118-qbittorrent-slash-redirect 2026-10-12 22:27:12 +02:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jlab-cluster/helm-charts#119