Serve Kagura under a reverse-proxy sub-path (ADR 0015) #13
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/path-base"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
https://umbrel.kagaku.space:8080/kaguraworks. Before this, it could not:App.razorhard-coded<base href="/" />and nothing readX-Forwarded-Prefix, so the browser would fetch/_framework/blazor.web.jsand/gateat the host root and get a blank page.Adopts doujin-manager's approach (its ADR 0013) rather than inventing one: the app is configured with nothing. Routes stay bare, nginx strips the prefix — the standard pattern — and announces it in
X-Forwarded-Prefix, from whichForwardedHeaderspopulatesRequest.PathBase. AKAGURA_PATH_PREFIXenv var would have to agree with nginx in two places and couldn't handle the stripping case, which is exactly why doujin-manager abandoned its own.What changed
ForwardedHeadersgainsXForwardedHost | XForwardedPrefix.App.razorrenders<base href>fromPathBase. This single line is what makes the sub-path work: Blazor resolves every relative asset, the SignalR circuit endpoint, and client-side navigation against it.Redirects that emit a
Locationheader had to carry the prefix themselves, because aLocationis resolved against the origin, notPathBase. That's sign-out, and the gate's no-return-url fallback — which previously sent the user to the site root, i.e. out of the app entirely. The cookie handler already prefixesLoginPath, so the challenge redirect needed nothing.deploy/nginx-kagura.confis the proxy block, anddeploy/docker-compose.ymlmoves to the env-map style with the secret pulled from the environment.Two nginx details that cost me a debugging round
Host $http_host, not$host.$hostdrops the port. With it,https://umbrel.kagaku.space:8080/kagura/redirected tohttps://umbrel.kagaku.space/kagura/gate— right host, wrong port, dead link. I only caught this because I ran a real nginx in front of the real image instead of trusting the config.location = /kagura { return 301 /kagura/; }. Without the trailing-slash redirect,https://host/kaguradoesn't match the proxy block, and every relative asset resolves one path segment too high.Both are in the shipped config with comments, and in ADR 0015's consequences.
Tests
+7 (
SubPathTests, 162 total). They drive the real pipeline with anX-Forwarded-Prefixheader:<base href>carries the prefix under/kaguraand stays a bare/at the root, the unauthenticated redirect lands inside the sub-path, sign-in without a return url goes to the app root rather than the site root, sign-out returns to the prefixed gate, an off-site return url still cannot escape, and the auth cookie is scopedPath=/kagura.That last one matters on your host specifically: doujin-manager sits at
/doujinshion the same origin, andPath=/kagurais what keeps Kagura's auth cookie off its requests.SubPathTestsdrives cookies by hand rather than using aCookieContainer. The framework scopes cookies toPath=/kagura, which a browser honours because it requests/kagura/gate; the test client requests the stripped/gate, so a container refuses to send them and every flow fails as a 400 for reasons that have nothing to do with the app. Sending them explicitly models the browser, and the scoping is asserted directly instead. The first version of these tests failed exactly this way — the 400 was the test's fault, not the app's.Verified against the real thing
Built the image from this branch and put a real nginx in front of it (podman), using the config in
deploy/:/kagura/health→ 200healthy/kagura/→ 302 to/kagura/gate?ReturnUrl=%2Fkagura%2F, on the right host and port<base href="/kagura/" />_content/Kagura.UI/css/kagura-ui.*.cssand_framework/blazor.web.*.jsboth resolve through the prefix and return 200/kagura/; both cookies come backpath=/kaguraPOST /kagura/_blazor/negotiate→ 200, so the circuit is reachableRoot deployment is unaffected —
<base href="/" />there, covered by a test.🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 0%
Kagura.Domain - 96.4%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 95.1%
n
Kagura.Kernel - 90%
Kagura.Server - 95.1%
Kagura.UI - 98.3%
Kagura.UseCases - 95.2%
🔮 fufu~ Jibril reviewed your code!
Oh? OH! A reverse-proxy sub-path PR that adopts the sibling's abandoned approach rather than inventing one — and documents why it was abandoned, and ships the two nginx footguns (
$http_hostnot$host, the trailing-slash redirect) as comments in the shipped config? Fufu~ this is the kind of "I ran it for real and caught the thing" energy that makes Jibril's heart sing. ♪Verdict: ✅ Looks good to me~
This is clean. Let me show my work~
The load-bearing question: did you catch every redirect?
A sub-path PR lives or dies on missed
Locationheaders, becauseLocationis resolved against the origin — notPathBase— so any redirect that writes a bare app path silently ejects the user from the app under/kagura. I traced every redirect/navigation site in the codebase:Program.cs:112—/logoutredirect →Results.Redirect(AccessGate.PathBaseRelative(http, AccessGate.GatePath))✅ explicitly prefixed. Good.Gate.razor:63— sign-inNavigateTo→AccessGate.SafeReturnUrl(HttpContext, ReturnUrl), whose fallback is nowBaseHref(http)(not"/") ✅. The cookie handler writes the prefix into theReturnUrlit generates, so a real return path arrives fully qualified — and a missing one lands on/kagura/, not/. Correct.LoginPath/AccessDeniedPath→ set to bareGatePath("/gate"), and the framework prefixes them withPathBaseitself. Confirmed byAn_unauthenticated_request_redirects_into_the_sub_path(asserts/kagura/gate). ✅That's all three redirect sites in the app, all handled. No missed
Locationheader. ♡The rest
PathBaseRelative/BaseHref—PathBase.Value?.TrimEnd('/')then re-append is the right shape: handles emptyPathBase(root →/), single-segment (/kagura→/kagura/), and avoids the//gatedouble-slash. The?.guards the nullPathString.Value. Sound.ForwardedHeaders— gainsXForwardedHost | XForwardedPrefixalongside the existingXForwardedFor | XForwardedProto, withKnownIPNetworks.Clear()/KnownProxies.Clear()(trust all, correct for the "proxy is the only route in" deployment). Matches doujin-manager's ADR 0013 approach as claimed.<base href>fromHttpContext— the[CascadingParameter] HttpContextinApp.razoris available during the initial static SSR render that emits<head>. Correct for Blazor Server. The comment explaining why it's not a literal/is exactly the kind of context that prevents a future dev from "simplifying" it back to a bug.UseHttpsRedirection— runs afterUseForwardedHeaders(), soRequest.Schemeis already corrected tohttpsbefore the redirect check. Under the proxy (plain HTTP internally +X-Forwarded-Proto: https), this won't double-redirect or fight the proxy. Ordering is right.docker-compose.yml— the env-map style withKAGURA_ACCESS_TOKEN: "${KAGURA_ACCESS_TOKEN}"is a genuine improvement over the old inline literal; the old commented placeholder (replace-with-your-secret-token) is gone, so nobody accidentally ships the default. The host-port change (8086:8080) is just making room for the proxy on8080— cosmetic.Tests
+7
SubPathTests, all driving the real pipeline withX-Forwarded-Prefix. I especially like the hand-driven cookie explanation — the reasoning for why aCookieContainerwould 400 the tests (path scoping vs. the stripped request path) is correct, and assertingpath=/kaguradirectly is stronger than relying on container behavior. The//evil.exampleopen-redirect test at the sub-path confirms the guard still holds under prefixing. Coverage confirms:AccessGate100%/83.3%,App100%,Gate100%/100%,Program100%/80%.✅ What I liked~
$hostdrops the port). That's not a claim; that's a war story. The fact both nginx gotchas are in the shipped config with comments means the next person doesn't relearn them the hard way. ♡KAGURA_PATH_PREFIXenv var — recognizing that a config var would have to agree with nginx in two places and couldn't handle the stripping case, so rejecting it in favor of the header-as-single-source-of-truth. Correct call.Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA
c3fb9b61(91.4% line / 83.2% branch coverage; changed files at 100% line) · Local checks: skipped (CI present and green; coverage for changed files confirmed from the bot comment)