feat(api): serve under a URL sub-path via UrlBase and X-Forwarded-Prefix - #880
feat(api): serve under a URL sub-path via UrlBase and X-Forwarded-Prefix#880m4bard wants to merge 2 commits into
Conversation
|
Found an interaction I should have caught before opening this, and it is a real cost of the approach rather than a detail.
As a path suffix, which is what this PR assumes. As a full absolute URL. if (validateImageBaseUrl &&
!string.IsNullOrWhiteSpace(baseUrl) &&
!(baseUrl.StartsWith("http://", ...) || baseUrl.StartsWith("https://", ...)))
{
logger.LogWarning("Invalid base URL configured: {BaseUrl} - notifications will not include images", ...);
baseUrl = null;
}
So on this branch, someone who sets What I think this means for the PRReusing Three ways out, and I do not think this is mine to pick:
Happy to write whichever you prefer. If it is 1, this PR is a small edit. If it is 2, it is a bigger change and probably wants its own issue first. Worth noting the practical reach today is smaller than it looks, though I would not lean on it: I think a webhook configured through the settings UI is currently never dispatched for a real event at all, which is #872. That is a bug rather than a guarantee, and it will presumably be fixed, at which point this collision becomes live. |
StartupConfig.UrlBase has been in the config since before this change, defaulted to "/", and read only when building notification links. Nothing applied it to what the app serves, so the setting's name promised something it did not do and there was no way to run Listenarr behind a reverse proxy under a sub-path. Apply it with UsePathBase, and trust X-Forwarded-Prefix so a proxy that strips the prefix itself can drive the same thing without the setting. .NET 10 supports that header directly, which the older frameworks the other *arr apps target do not, so no UrlBaseMiddleware equivalent is needed. Deliberately no 307 redirect for unprefixed requests: that loops behind a prefix-stripping ingress. The forwarded header is gated by the existing KnownIPNetworks trust list, so an untrusted source cannot set PathBase. Tested. Backend only. The frontend still hardcodes its base at build time, which needs a separate change and a design decision, described in the issue.
StartupConfig.UrlBase had two incompatible readings in shipped code.
DiscordBotService treats it as a path suffix and builds
"{protocol}://{host}:{port}{urlBase}". NotificationPayloadContextResolver
treats it as an absolute URL and, when validateImageBaseUrl is set, drops
notification images if it does not start with http:// or https://.
Serving under a sub-path needs the first reading, so setting UrlBase to a
path silently cost notification images and logged a warning that read like
operator error.
Sonarr, Radarr and Readarr all split these. UrlBase is a path everywhere:
ConfigFileProvider trims it and it is consumed only by
app.UsePathBase(). Sonarr's ValidUrlBase rejects an absolute value outright
with "Must be a valid URL path (ie: '/sonarr')". The absolute external URL
lives in a separate ApplicationUrl setting, which Sonarr's WebhookBase and
CustomScript read.
So UrlBase keeps the path meaning this PR already relies on, and
notifications read a new StartupConfig.ApplicationUrl. It is not a breaking
change: an absolute UrlBase is still honoured when ApplicationUrl is unset,
with a warning pointing at the new setting, so existing installations keep
their notification images.
The bug this fixes is the third case. A path-shaped UrlBase used to be taken
as the base, which suppressed the request-context fallback and then failed
validation, so images were dropped. It now falls through to that fallback
and resolves normally.
1a21afd to
8f76216
Compare
|
I asked earlier which of three ways you wanted the What they doAll three keep two settings, not one.
They also reject the other reading outright. The absolute external URL is a separate setting, Two things I should be straight about rather than let the comparison do more work than it can. In Readarr, What I changed
Three production files. The property, documented as the absolute external URL and contrasted with It is not a breaking change. When The case that was actually brokenSetting That path now falls through to the fallback and resolves normally, which is what ChecksFull suite unfiltered, 3060 passed, 0 failed, 127 skipped. For the control I reverted only the base-selection logic and kept the property so it still compiles. Four of the six new tests fail, including Still not done hereThis is the backend half. It does not make the SPA load under a sub-path, as the PR description says. I have not started the frontend part, since option 1 would have changed which setting it reads and I wanted that settled first. If this shape is right I can pick it up. If you would rather |
|
Correction to my last comment. I proposed adding
What is actually wrong here is smaller and more specific than a missing setting: two notification paths disagree about how to find the same value.
The Discord bot and the notification payloads therefore resolve the same external URL by different rules, and only one of them knows about the environment variable an operator has probably already set. What I think the change should beThe resolver should follow the order the Discord bot already uses: Whether a I have not changed the branch yet, since the answer changes what the code should look like. A separate gap, while I am here
The only That is a small, self-contained addition and I am happy to send it as its own PR if you want it. It is worth saying that sub-path serving is not much use to an operator who cannot find the switch. |
feat(api): serve under a URL sub-path via UrlBase and X-Forwarded-Prefix
Backend half of the sub-path support described in #879. It does not make the SPA load under a sub-path on its own, the frontend still bakes its router base and asset URLs in at build time, but it makes the API, the controllers and the SignalR hubs route correctly under a prefix, and it is independent of whatever gets decided for the frontend.
What was missing
Nothing in the pipeline set a path base, so the app could only be served at the root of a host. Both of the ways a reverse proxy signals a sub-path went unhandled:
app.UseRouting().git grep UsePathBasereturned nothing.X-Forwarded-Prefixhad that header dropped, because onlyX-Forwarded-For,-Protoand-Hostwere in the trusted set (ListenarrPlatformRegistration.cs:31-34).StartupConfig.UrlBasealready existed (listenarr.domain/Configuration/StartupConfig.cs:29), already defaulted to"/"(StartupConfigService.cs:272), and was read in two places, both building notification links:DiscordBotService.cs:278andNotificationPayloadContextResolver.cs:32. The name promised something the app did not do.What this does
ForwardedHeaders.XForwardedPrefixto the trusted set. On .NET 10 the forwarded headers middleware populatesRequest.PathBasefrom that header, and it applies the sameKnownIPNetworkstrust check that the other three already got.ListenarrUrlBaseStartup.UseListenarrUrlBase(), which readsStartupConfig.UrlBase, normalizes it, and callsapp.UsePathBase. Wired in atListenarrPipeline.csright afterUseForwardedHeaders()and before static files and routing.Normalization returns null (no
UsePathBasecall at all) for empty,"/", a full URL, a backslash, or anything that reduces to no segments, so the root case is an explicit no-op rather than a path base of"/".Direct access is unaffected.
UsePathBasestrips the prefix when it is present and passes the request through untouched when it is not, so hitting the container on its own port still works alongside the proxied sub-path. There is a test asserting that.Prior art
Sonarr, Radarr, Prowlarr and Readarr all do the
UsePathBasehalf the same way, insrc/NzbDrone.Host/Startup.cs:reading from
ConfigFileProvider.UrlBase(src/NzbDrone.Core/Configuration/ConfigFileProvider.cs:241-254), which does the same trim-and-re-add-one-slash normalization. They do not supportX-Forwarded-Prefix, so a prefix-stripping proxy or ingress does not work with them. That part is the one deliberate difference here.Tests
tests/Features/Api/Startup/ListenarrUrlBaseStartupTests.cs, five test methods and fifteen cases:NormalizeUrlBaseproduces a leading-slash path with no trailing slash (theory, four cases).NormalizeUrlBasereturns null for root and unusable values (theory, eight cases).PathBasefromX-Forwarded-Prefixwhen the request comes from a trusted network, and leave it empty when it does not (two facts).ListenarrWebApplicationFactory: withUrlBaseset to/example,GET /example/api/v1/system/inforeturns 200 and the same request against a factory withoutUrlBasedoes not.GET /api/v1/system/infostill returns 200 either way.Plus one assertion added to the existing
ForwardedHeadersTrustModelTests.Controls, run with the production changes stashed:
ConfiguredUrlBase_RoutesRequestsThatArriveWithThePrefixStillAttachedfails (expected OK, got NotFound).ForwardedHeaders_SetPathBaseFromForwardedPrefix_ForAProxyThatStripsThePrefixfails (expected/example, got"").ForwardedHeadersOptions_TrustsCommonPrivateProxyNetworksfails on the new flag assertion.With the changes in place: 698 passed and 0 failed across
Listenarr.Tests.Features.Api, and the architecture suite is green.Not in this PR
The frontend.
fe/src/router/index.ts:160still callscreateWebHistory(import.meta.env.BASE_URL)and Vite inlines that at build time, so the SPA does not load under a sub-path yet. That needs a decision about how the base reaches the browser, which #879 describes.