Skip to content

feat(api): serve under a URL sub-path via UrlBase and X-Forwarded-Prefix - #880

Open
m4bard wants to merge 2 commits into
Listenarrs:canaryfrom
m4bard:fix/bug28-urlbase-subpath
Open

feat(api): serve under a URL sub-path via UrlBase and X-Forwarded-Prefix#880
m4bard wants to merge 2 commits into
Listenarrs:canaryfrom
m4bard:fix/bug28-urlbase-subpath

Conversation

@m4bard

@m4bard m4bard commented Aug 22, 2026

Copy link
Copy Markdown

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:

  • A proxy that forwards the path un-rewritten had nothing stripping the prefix before app.UseRouting(). git grep UsePathBase returned nothing.
  • A proxy that strips the prefix itself and announces it in X-Forwarded-Prefix had that header dropped, because only X-Forwarded-For, -Proto and -Host were in the trusted set (ListenarrPlatformRegistration.cs:31-34).

StartupConfig.UrlBase already 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:278 and NotificationPayloadContextResolver.cs:32. The name promised something the app did not do.

What this does

  • Adds ForwardedHeaders.XForwardedPrefix to the trusted set. On .NET 10 the forwarded headers middleware populates Request.PathBase from that header, and it applies the same KnownIPNetworks trust check that the other three already got.
  • Adds ListenarrUrlBaseStartup.UseListenarrUrlBase(), which reads StartupConfig.UrlBase, normalizes it, and calls app.UsePathBase. Wired in at ListenarrPipeline.cs right after UseForwardedHeaders() and before static files and routing.

Normalization returns null (no UsePathBase call 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. UsePathBase strips 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 UsePathBase half the same way, in src/NzbDrone.Host/Startup.cs:

app.UseForwardedHeaders();
app.UseMiddleware<LoggingMiddleware>();
app.UsePathBase(new PathString(configFileProvider.UrlBase));

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 support X-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:

  • NormalizeUrlBase produces a leading-slash path with no trailing slash (theory, four cases).
  • NormalizeUrlBase returns null for root and unusable values (theory, eight cases).
  • The configured forwarded headers options set PathBase from X-Forwarded-Prefix when the request comes from a trusted network, and leave it empty when it does not (two facts).
  • End to end through ListenarrWebApplicationFactory: with UrlBase set to /example, GET /example/api/v1/system/info returns 200 and the same request against a factory without UrlBase does not. GET /api/v1/system/info still returns 200 either way.

Plus one assertion added to the existing ForwardedHeadersTrustModelTests.

Controls, run with the production changes stashed:

  • ConfiguredUrlBase_RoutesRequestsThatArriveWithThePrefixStillAttached fails (expected OK, got NotFound).
  • ForwardedHeaders_SetPathBaseFromForwardedPrefix_ForAProxyThatStripsThePrefix fails (expected /example, got "").
  • ForwardedHeadersOptions_TrustsCommonPrivateProxyNetworks fails 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:160 still calls createWebHistory(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.

@m4bard
m4bard requested a review from a team August 22, 2026 21:09
@m4bard

m4bard commented Aug 24, 2026

Copy link
Copy Markdown
Author

Found an interaction I should have caught before opening this, and it is a real cost of the approach rather than a detail.

StartupConfig.UrlBase already has two incompatible meanings in shipped code, and this PR picks one of them.

As a path suffix, which is what this PR assumes. DiscordBotService.cs:278-284 builds $"{protocol}://{host}:{port}{urlBase}", so /example is exactly right there.

As a full absolute URL. NotificationPayloadContextResolver.ResolveAsync reads the same field and, when asked to validate it, requires an http:// or https:// prefix:

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;
}

validateImageBaseUrl: true is passed on the webhook send path, at NotificationService.Webhooks.cs:34. Three tests pin that reading too, setting UrlBase = "https://listenarr.example.com" (SecurityRedactionTests.cs:51, NotificationServiceTests.cs:299 and :520).

So on this branch, someone who sets UrlBase to /example to get sub-path serving would also start dropping images from webhook notifications, with a warning that reads as a misconfiguration on their part. I have read that path rather than run it end to end, so treat the consequence as derived from the code and not measured.

What I think this means for the PR

Reusing UrlBase looked like the neat part of this change, precisely because the setting already existed and its name already promised what the app did not do. That argument does not survive the second reader. The field is not unused-but-named-correctly, it is actively used with a different meaning.

Three ways out, and I do not think this is mine to pick:

  1. A separate setting for the serving path, leaving UrlBase alone. Smallest blast radius, at the cost of two similarly named settings.
  2. Reconcile the two readings, making UrlBase a path everywhere and having the notification path build an absolute URL from host and scheme as DiscordBotService already does. Tidier, and it changes behaviour for anyone currently setting an absolute URL for notifications, so it needs a migration story.
  3. Keep this PR as is and accept the collision, if the notification reading is considered the accident. I would not choose this without you saying so, because it is a silent loss rather than an error.

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.

m4bard added 2 commits August 24, 2026 21:26
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.
@m4bard
m4bard force-pushed the fix/bug28-urlbase-subpath branch from 1a21afd to 8f76216 Compare August 25, 2026 16:11
@m4bard

m4bard commented Aug 25, 2026

Copy link
Copy Markdown
Author

I asked earlier which of three ways you wanted the UrlBase collision resolved and left the PR sitting on it. I went and looked at what the other *arr projects do, and the answer was clear enough that I have implemented it rather than keep asking. Happy to unpick it if you disagree.

What they do

All three keep two settings, not one.

UrlBase is a path, everywhere. (_serverOptions.UrlBase ?? GetValue("UrlBase", "")).Trim('/'), then re-prefixed with /: Readarr ConfigFileProvider.cs:241, Sonarr :255, Radarr :266. It has exactly one consumer in each, app.UsePathBase(new PathString(configFileProvider.UrlBase)), at Readarr src/NzbDrone.Host/Startup.cs:247 and Sonarr :339.

They also reject the other reading outright. ValidUrlBase in src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs:42-45 is a regex ^(?!\/?https?://[-_a-z0-9.]+) with the message "Must be a valid URL path (ie: '/sonarr')", applied at HostConfigController.cs:42. An absolute URL in UrlBase fails validation before it reaches anything.

The absolute external URL is a separate setting, ApplicationUrl, on ConfigService rather than ConfigFileProvider: Readarr :405, Sonarr :427. Sonarr reads it in WebhookBase.cs at ten sites, in CustomScript.cs for the script environment, and in ScriptImportDecider.cs:131.

Two things I should be straight about rather than let the comparison do more work than it can. In Readarr, ApplicationUrl has no consumer beyond HostConfigResource, so in that repo it is a dead setting; Sonarr is the live example. And Sonarr's Discord notifier uses RemoteUrl from the metadata provider rather than self-hosting images, so it never meets the exact problem Listenarr has here. What the comparison supports is that the two-setting split is the established shape, not that Sonarr solved this particular case.

What I changed

UrlBase keeps the path meaning this PR already relies on. Notifications read a new StartupConfig.ApplicationUrl.

Three production files. The property, documented as the absolute external URL and contrasted with UrlBase. CreateDefaultConfig sets it to null so the key shows up in a generated config rather than being invisible until someone reads the source. And NotificationPayloadContextResolver reads it as the notification base, with the http/https test pulled into a small helper since it is now used twice.

It is not a breaking change. When ApplicationUrl is unset and UrlBase holds an absolute URL, that value is still used, with a warning naming the new setting. Installations configured before this keep their notification images, and the existing NotificationServiceTests case that sets UrlBase to an absolute URL passes untouched.

The case that was actually broken

Setting UrlBase to a path actively broke notification images. ResolveAsync took the path as the base, which is non-empty, so the request-context fallback was skipped, and then the validation branch nulled it and logged "Invalid base URL configured", which reads like the operator got it wrong. Turning on sub-path serving silently cost you notification images.

That path now falls through to the fallback and resolves normally, which is what PathUrlBase_NoLongerBlocksTheRequestContextFallback covers.

Checks

Full suite unfiltered, 3060 passed, 0 failed, 127 skipped. dotnet build listenarr.slnx and the test project both clean at 0 warnings.

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 ApplicationUrl_IsUsedAsTheNotificationBase and the fallback one above. The other two, PathUrlBase_DoesNotBecomeTheNotificationBase and ApplicationUrl_ThatIsNotAbsolute_IsRejected_WhenValidating, pass either way: they guard against the opposite regression and are not evidence for this change.

Still not done here

This 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 ApplicationUrl lived on ApplicationSettings with a UI field instead of on StartupConfig, say so. I put it next to UrlBase because that is where the sibling setting already is and because StartupConfig round-trips through StartupConfigurationController already, but that is a preference rather than a technical constraint.

@m4bard

m4bard commented Aug 26, 2026

Copy link
Copy Markdown
Author

Correction to my last comment. I proposed adding StartupConfig.ApplicationUrl and justified it with Sonarr's two-setting split, without mentioning that Listenarr already has a setting for this. That was my mistake and it makes the proposal look like it invents a second name for something you ship already.

LISTENARR_PUBLIC_URL is documented in the README in four places and read as priority 1 by DiscordBotService.GetListenarrUrl(). It is the absolute external URL, and it is the same concept. For what it is worth, Sonarr's own help text for ApplicationUrl reads "This application's external URL including http(s)://, port and URL base", which describes LISTENARR_PUBLIC_URL exactly.

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.

DiscordBotService.GetListenarrUrl() has a three-step order: LISTENARR_PUBLIC_URL, then scheme and host from the request context, then a construction from StartupConfig that treats UrlBase as a path suffix.

NotificationPayloadContextResolver.ResolveAsync does something different. It reads UrlBase as an absolute URL, falls back to the request context, and never looks at LISTENARR_PUBLIC_URL at all.

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 be

The resolver should follow the order the Discord bot already uses: LISTENARR_PUBLIC_URL first, then a configured value, then the request context. That fixes the sub-path collision I described last time, and it also makes the two paths agree, which the version I pushed does not.

Whether a StartupConfig.ApplicationUrl is worth adding alongside the environment variable is then a smaller question, and yours rather than mine. Sonarr carries both a variable and a settings field. If you would rather have one way to set it, dropping the property and reading only LISTENARR_PUBLIC_URL is a smaller change than what is on the branch now, and I would be happy with that outcome.

I have not changed the branch yet, since the answer changes what the code should look like.

A separate gap, while I am here

UrlBase has no field in the UI. Sonarr, Radarr and Readarr all put it in Settings, General, under Host, next to Bind Address and Port, with the help text "For reverse proxy support, default is empty" and a restart warning. In Listenarr it can only be set by editing config.json in the config volume or by posting to /api/v1/configuration/startupconfig, so unless you already know the file exists the feature is invisible.

The only urlBase in the Listenarr UI today is the download client field for qBittorrent and Transmission, which is a different setting entirely and makes searching for it misleading.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant