Skip to content

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

Open
bakerboy448 wants to merge 1 commit into
developfrom
signalr
Open

bakerboy448 wants to merge 1 commit into
developfrom
signalr

Conversation

@bakerboy448

@bakerboy448 bakerboy448 commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #91. Lets apps like Bazarr use the arr's SignalR live updates through starrproxy.

TLDR

  • SignalR is just two endpoints (/signalr/messages GET, /signalr/messages/negotiate POST). Turn them on per app or template like anything else. Bazarr templates have them on.
  • nginx asks starrproxy first. starrproxy checks key, endpoint, and method like REST. Pass: nginx opens the websocket to the arr with the real key. Fail: 401, arr sees nothing.
  • Real arr key never reaches the client.
  • Apps with redactions get no SignalR. Max 10 sockets per client IP.
  • Turning it off blocks new connections. An already-open socket stays up until it drops (details in the comments).

Limits

  • Arr URL must resolve via Docker DNS or be an IP (no /etc/hosts, localhost, host networking).
  • Behind a reverse proxy, the 10-socket cap is shared by everyone.
How it works and test results
  • Starr.php adds the two endpoints. signalr-auth.php (outside the web root) does the check. nginx auth_request to internal /signalr-auth, then proxies and upgrades.
  • PHP cURL cannot proxy websockets, so the upgrade stays in nginx.
  • Only Upgrade: websocket is forwarded. X-Accel-Redirect is ignored everywhere.
  • Logged as auth-granted, counted as allowed.

Testing

Head 1d7251b (squashed) re-test against real Sonarr behind a request recorder. A disallowed SignalR endpoint or method returns 401; the arr sees only allowed calls:

Key allows POST negotiate GET websocket PUT/DELETE/other methods REST system/status
neither SignalR entry 401 401 401 200
GET messages only 401 101 401 200
POST negotiate only 200 401 401 200
both 200 101 401 200

php -l and nginx -t pass.

Head 9b5cd89 (rebased onto develop 8bc0d68). The image was built from a clean tree at that commit with a fresh /config and run on a throwaway docker network with generated keys; no production containers or keys. Backends: real Sonarr (linuxserver latest) x3, plus a recording echo upstream registered as a Sonarr backend to show what reaches the arr. Result: 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 endpoints: 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; a new websocket is accepted after one closes
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.

🤖 Generated with Claude Code

@bakerboy448

bakerboy448 commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Right: auth runs once, at connect. Turning SignalR off (or deleting the app/key) blocks new connections, but an open socket stays up until it drops. The reconnect re-negotiates, so it gets 401 then. Restart the container to cut it now.

Options if we want it to bite sooner:

  1. Docs only. Note it in the README. No code.
  2. Reload on change. Watch for app/key/endpoint changes, reload nginx with worker_shutdown_timeout 10s. Kicks every open SignalR socket (not just the revoked one); clients reconnect and re-auth. ~40 lines, no new packages.
  3. njs check. Add nginx-mod-http-js and check a revoke flag per frame. Unproven, new package to maintain.

No native nginx setting caps a websocket's lifetime, and proxy_read_timeout never fires because SignalR pings every 15s.

@bakerboy448

Copy link
Copy Markdown
Collaborator Author

Good call. The auth subrequest itself is always a GET, so the method is now passed explicitly: fastcgi_param STARR_CLIENT_METHOD $request_method; (nginx reads $request_method from the client's original request, even inside the subrequest), and signalr-auth.php checks that against the app's allowed methods.

Tested live against Sonarr with a key allowing only messages GET and negotiate POST:

Request Result
POST, PUT, DELETE /signalr/messages 403, arr never sees it
GET, PUT, DELETE /signalr/messages/negotiate 403, arr never sees it
GET /signalr/messages (websocket) 101
POST /signalr/messages/negotiate 200

Also added the standard header to signalr-auth.php.

@austinwbest

austinwbest commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Tested live against Sonarr with a key allowing only messages GET and negotiate POST:

The test is not allowing post or get and making sure it properly 401s

Edit: Squash it after you test that and it passes so i can re-pull the PR and give it a once over again

Adds an nginx /<base>/signalr/ location that WebSocket-proxies to the arr,
gated by an auth_request to signalr-auth.php. The script applies the same
scoped-key lookup, endpoint and method allow-list, logging, and usage
accounting as api/index.php, then hands nginx the backend URL, endpoint, and
the real arr key as headers, so the scoped key never reaches the arr.

- apikey and access_token are read from the original query string and removed
  before proxying
- apps with redactions are refused (the stream cannot be field-redacted)
- only real websocket Upgrade requests are forwarded; X-Accel-Redirect is
  ignored on every fastcgi location
- 10 concurrent connections per client address (429 over the cap)
- disallowed endpoint or method returns 401
- Bazarr templates for Radarr and Sonarr include the SignalR endpoints

Closes #91
@bakerboy448

Copy link
Copy Markdown
Collaborator Author

Good catch. A SignalR request for a disallowed endpoint or method now returns 401 (the method case was 403). The branch is squashed to one commit. I tested against a real Sonarr behind a request recorder, so I could confirm the arr saw only the allowed requests:

Key allows POST negotiate GET websocket PUT/DELETE/other methods REST system/status
neither SignalR entry 401 401 401 200
GET messages only 401 101 401 200
POST negotiate only 200 401 401 200
both 200 101 401 200

php -l and nginx -t pass. REST regression is unchanged (disallowed endpoint 401, disallowed method 405 as before).

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)

2 participants