Skip to content

feat(signalr): opt-in SignalR/WebSocket passthrough for proxied arrs (#91) - #111

Closed
bakerboy448 wants to merge 5 commits into
Notifiarr:developfrom
bakerboy448:claude/signalr-passthrough
Closed

bakerboy448 wants to merge 5 commits into
Notifiarr:developfrom
bakerboy448:claude/signalr-passthrough

Conversation

@bakerboy448

@bakerboy448 bakerboy448 commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

Implements #91: pass /<base>/signalr/* through to the backend arr so consumers of the arr SignalR channel (Bazarr first) keep working when repointed at starrproxy with a scoped key.

How it works

  • SignalR is granted like any other endpoint. Starr.php (addSignalrEndpoints) adds /signalr/messages (get) and /signalr/messages/negotiate (post) to the endpoint list, because SignalR is not in the arr OpenAPI specs. An app can use SignalR only if its endpoint list or template includes those entries. The Radarr and Sonarr Bazarr templates include them. There is no per-app flag and no migration.
  • nginx location ~ ^(.*)/signalr/(.*)$ uses auth_request to an internal-only location = /signalr-auth, which runs root/app/www/signalr-auth.php (outside the web root, so a client cannot call it). The script resolves the scoped key, applies the same isAllowedEndpoint and method checks as REST, and returns the backend URL, the allow-listed upstream path, and the real key as response headers. The upstream path comes from the matched entry, never from the client path.
  • nginx sets X-Api-Key to the real key and drops the client Authorization header. The scoped key is removed from the proxied query string, and the real key never reaches the client.
  • WebSocket upgrade stays at the nginx layer because PHP cURL cannot proxy WebSockets. Only Upgrade: websocket is forwarded. X-Accel-Redirect from PHP and from the arr is ignored on every fastcgi_pass and proxy_pass location involved.
  • An app with any Redactions set is refused SignalR (401): the stream carries every broadcast event and cannot be field-redacted.
  • Auth results are logged as proxyCode:auth-granted (nginx contacts the arr after the check, so the upstream status is unknown) and count as allowed in usage.
  • At most 10 concurrent SignalR connections per client address (429 over the cap).
  • /api, /feed, /backup, /health, PHP, and static routes are otherwise unchanged.

Limitations (also in the README)

  • nginx resolves the arr hostname through Docker's embedded DNS (127.0.0.11). An arr URL that resolves only through /etc/hosts, localhost, extra_hosts, or host networking works for REST but not SignalR. Use an IP or a Docker-resolvable name.
  • The connection cap keys on the address nginx sees. starrproxy has no trusted-proxy setting, so behind a reverse proxy all clients share one cap of 10.

Testing

Head 9b5cd89 (rebased onto develop 8bc0d68), image built from a clean tree at that commit with a fresh /config, run on a throwaway docker network with generated keys. No production containers or keys. Real Sonarr (linuxserver latest) x3, plus a recording echo upstream registered as a Sonarr backend to prove what reaches the arr. Full plan and output: run4 (PR plan 60 / 60 PASS, upstream smoke and route-order checks 29 / 29 PASS). php -l on signalr-auth.php and Starr.php and nginx -t on the rendered config pass in the image.

Area Result
REST, negotiate (query key and X-Api-Key), websocket upgrade with SignalR handshake (101) against real Sonarr pass
Key for an app without the SignalR entries: negotiate and websocket 401 (control: same key works on REST, 200)
Wrong method, non-allow-listed or traversal paths, encoded %2f variants, HEAD/OPTIONS, uppercase path blocked, arr sees nothing off the allow-list
Unknown key, missing key, real arr key presented to the proxy, Authorization: Bearer alone 401
Direct hits on /signalr-auth, /signalr-auth.php, /signalr/auth.php 404, no X-Starr-* header or real key in any client-visible response (all captured responses checked)
Scoped key in ApiKey, encoded param name, ACCESS_TOKEN, duplicate params, with Authorization arr sees one request, no scoped key, real X-Api-Key, no Authorization
Upgrade: h2c, websocket, h2c no Upgrade forwarded; WebSocket forwards websocket
Redacted app: negotiate, websocket; same app with redactions cleared 401, 401; 200
Arr down client 5xx, logged auth-granted, not 200
12 concurrent websockets from one address first 10 are 101, 11th and 12th 429, new websocket accepted after close
Arr X-Accel-Redirect reached through ?endpoint= on /feed/, /backup/, /api/ not acted on (200, one arr request). Negative control with the directive removed from /feed/: nginx follows the redirect (401)
nginx -T: every fastcgi_pass location (5) has fastcgi_ignore_headers X-Accel-Redirect pass
Route order: SignalR location after the #110 catch-all vs moved above it allow-listed SignalR identical in both. Only /<base>/signalr/.../{api,feed,backup} differs, denied either way (JSON 404 now, 401 if moved), arr never reached. Kept after the catch-all
#110 JSON 404 on /api, /feed, /backup, /sonarr/api, /x/feed, /x/backup, /a/b/api; / still the UI pass
#118 addstarr on a new (4th) backend: app gets that backend's starr_id pass (starr_id 4)
#114/#115 kometa template scoped key: tag GET and POST, importlistexclusion and /paged against real Sonarr; config/host denied 200/201; 401
8bc0d68/db7a06c mitigations: php-fpm session.upload_progress.enabled Off, memory_limit -1, post_max_size 8M, fatal_error_backtraces Off; nginx client_max_body_size 8M (9 MiB body: 413) pass; image PHP is php85-8.5.12-r0 on Alpine 3.24.2

Not covered on this head: the Bazarr client itself (an earlier revision was tested with Bazarr 1.6.2 against Sonarr and Radarr), Radarr, UrlBase, TLS upstreams, legacy SignalR, and the admin UI.

Closes #91

🤖 Generated with Claude Code

…otifiarr#91)

Adds an nginx /signalr location that WebSocket-proxies /<base>/signalr/* to the
backend arr, using an internal auth_request to signalr/auth.php which resolves
the scoped key via getAppFromProxiedKey, gates on a new per-app 'signalr' opt-in
flag (migration 009), validates the backend URL, and swaps the scoped
access_token for the backend's real key. PHP cURL cannot proxy WebSockets, so
this stays at the nginx layer. Bazarr templates opt in; admin UI adds the toggle.

Closes Notifiarr#91
…ring

The auth_request subrequest has no query string, so keys passed as ?apikey= or ?access_token= (browser SignalR clients cannot set headers) were never seen by auth.php. Pass the original request URI to PHP and parse its query string.
…keep the real key off the client

Replace the per-app signalr flag with allow-list entries (/signalr/messages get, /signalr/messages/negotiate post) checked by the same isAllowedEndpoint and method logic as REST, with the same logging and usage accounting. The auth script moves out of the web root so a client cannot call it, and the upstream path comes from the matched entry. The real key is injected by nginx as X-Api-Key and is no longer placed in the query string.
…rict Upgrade and X-Accel-Redirect

Apps with redactions set get 401 on SignalR (the stream is unredacted). Only websocket Upgrade is forwarded. SignalR auth is logged as auth-granted instead of 200. SignalR location moved after /feed/ and /backup/. X-Accel-Redirect ignored on fastcgi and SignalR proxy. Concurrent SignalR connections capped at 10 per client address (429).
@bakerboy448
bakerboy448 force-pushed the claude/signalr-passthrough branch from 4292868 to 9b5cd89 Compare October 21, 2026 23:34
@bakerboy448

Copy link
Copy Markdown
Collaborator Author

Rebased onto develop (8bc0d68: php2shell mitigations, #110, #114/#115, #118) and force-pushed as 9b5cd89. Conflicts were in Starr.php (kept the is_array cache decode, wrapped in addSignalrEndpoints) and template.conf (kept the #110 JSON 404 locations; the SignalR block sits after them).

Tested live on that exact head, image built from a clean tree, against 3 real Sonarr instances:

  • 60/60 PR checks: SignalR negotiate and connect through a scoped key, real key never returned to the client, redacted apps refused, connection cap, Upgrade and X-Accel-Redirect handling
  • 29/29 upstream regression checks: fix(nginx): return JSON errors for unmatched api/feed/backup paths #110 JSON 404s, addstarr starr_id, tag and importlistexclusion endpoints, upload_progress off, 8M body limit
  • PHP 8.5.12 (php85, Alpine 3.24)

Not tested: Bazarr client, Radarr, UrlBase, TLS upstreams.

Separate from this PR: api/index.php forwards any /feed/<x> or /backup/<x> request with a valid scoped key to the arr unchanged, with no endpoint allow-list. I can open an issue for it if wanted.

@bakerboy448

Copy link
Copy Markdown
Collaborator Author

Moved to #124 from the in-repo signalr branch so it can be checked out without a fork remote. Same commit (9b5cd89).

@bakerboy448
bakerboy448 deleted the claude/signalr-passthrough branch October 22, 2026 22:42
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.

Feature request: SignalR / websocket passthrough for proxied /signalr (arr live-push via scoped keys)

1 participant