diff --git a/README.md b/README.md
index 57ea70b..930440d 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,15 @@ If you need to auto add starr apps and 3rd party apps you can do that via the ap
The `template` variable is not required but if you do not use an existing template then the app will have no starr api access initially.
+SignalR (`/signalr/messages` and `/signalr/messages/negotiate`) is granted like any other endpoint. It is allowed only when the app's endpoint list or template includes those entries, and it goes through the same scoped key, endpoint, and method checks as the REST API. The Radarr and Sonarr Bazarr templates include them.
+
+The SignalR stream carries every broadcast event from the arr and cannot be field-redacted, so an app with any Redactions set is refused SignalR (401). Auth results for SignalR are logged as `proxyCode:auth-granted` (nginx contacts the arr after the auth check, so the real upstream status is not known to the log) and count as `allowed` in usage. SignalR connections are capped at 10 concurrent per client address (429 over the cap).
+
+SignalR limitations:
+
+- nginx opens the SignalR upstream itself, resolving the arr hostname through Docker's embedded DNS (`127.0.0.11`). An arr URL that only resolves through `/etc/hosts`, `localhost`, `extra_hosts`, or host networking works for REST but fails for SignalR. Use an IP address or a hostname resolvable by Docker DNS in the starr app URL.
+- The 10 connection cap keys on the client address nginx sees. starrproxy has no trusted-proxy setting, so behind a reverse proxy every client shares one cap of 10.
+
An example curl would be:
``` bash
diff --git a/root/app/www/public/classes/Starr.php b/root/app/www/public/classes/Starr.php
index 2174556..a17dd8d 100644
--- a/root/app/www/public/classes/Starr.php
+++ b/root/app/www/public/classes/Starr.php
@@ -97,7 +97,7 @@ public function getEndpoints($app)
if ($cache) {
$cached = json_decode($cache, true);
- return is_array($cached) ? $cached : $endpoints;
+ return $this->addSignalrEndpoints(is_array($cached) ? $cached : $endpoints);
}
switch ($app) {
@@ -169,6 +169,16 @@ public function getEndpoints($app)
$this->cache->set($cacheKey, json_encode($endpoints), STARR_ENDPOINT_LIST_TIME);
}
+ return $this->addSignalrEndpoints($endpoints);
+ }
+
+ //-- SIGNALR IS NOT IN THE OPENAPI SPEC, LIST IT SO IT IS GRANTED LIKE ANY OTHER ENDPOINT
+ private function addSignalrEndpoints($endpoints)
+ {
+ $endpoints = $endpoints ?: [];
+ $endpoints['/signalr/messages'] = ['label' => 'SignalR (all broadcast events, unredacted)', 'methods' => ['get']];
+ $endpoints['/signalr/messages/negotiate'] = ['label' => 'SignalR (negotiate)', 'methods' => ['post']];
+
return $endpoints;
}
diff --git a/root/app/www/public/templates/radarr/bazarr.json b/root/app/www/public/templates/radarr/bazarr.json
index 91c887b..bb1b448 100644
--- a/root/app/www/public/templates/radarr/bazarr.json
+++ b/root/app/www/public/templates/radarr/bazarr.json
@@ -31,5 +31,11 @@
],
"/api/system/status": [
"get"
+ ],
+ "/signalr/messages": [
+ "get"
+ ],
+ "/signalr/messages/negotiate": [
+ "post"
]
}
diff --git a/root/app/www/public/templates/sonarr/bazarr.json b/root/app/www/public/templates/sonarr/bazarr.json
index 35909e3..1fd504d 100644
--- a/root/app/www/public/templates/sonarr/bazarr.json
+++ b/root/app/www/public/templates/sonarr/bazarr.json
@@ -43,5 +43,11 @@
],
"/api/system/status": [
"get"
+ ],
+ "/signalr/messages": [
+ "get"
+ ],
+ "/signalr/messages/negotiate": [
+ "post"
]
}
diff --git a/root/app/www/signalr-auth.php b/root/app/www/signalr-auth.php
new file mode 100644
index 0000000..7da8bc1
--- /dev/null
+++ b/root/app/www/signalr-auth.php
@@ -0,0 +1,119 @@
+/signalr/*. Lives outside the web root so it can only be
+ * reached through the internal /signalr-auth location, never directly by a client.
+ *
+ * Applies the same scoped-key lookup, endpoint/method allow-list, logging, and usage accounting
+ * as api/index.php. The only response is status + headers consumed by nginx, which never forwards
+ * them to the client. The upstream path comes from the matched allow-list entry, not the request.
+ */
+
+if (!defined('ABSOLUTE_PATH')) {
+ define('ABSOLUTE_PATH', __DIR__ . '/public/');
+}
+
+require ABSOLUTE_PATH . 'loader.php';
+
+if (($_SERVER['SIGNALR_SUBREQUEST'] ?? '') !== '1') {
+ http_response_code(404);
+ exit();
+}
+
+$requestCounter = $cache->get(REQUEST_COUNTER_KEY) + 1;
+$cache->set(REQUEST_COUNTER_KEY, $requestCounter, REQUEST_COUNTER_TIME);
+
+$logfile = LOGS_PATH . 'access.log';
+$method = strtolower($_SERVER['ORIG_METHOD'] ?? '');
+$uri = (string) ($_SERVER['ORIG_URI'] ?? '');
+$path = (string) parse_url($uri, PHP_URL_PATH);
+$rawArgs = (string) parse_url($uri, PHP_URL_QUERY);
+
+//-- SPLIT THE QUERY, KEEPING IT RAW. THE SCOPED KEY NEVER GOES TO THE ARR
+$apikey = '';
+$keepArgs = [];
+foreach (array_filter(explode('&', $rawArgs), 'strlen') as $arg) {
+ $name = strtolower(urldecode(explode('=', $arg, 2)[0]));
+ if (in_array($name, ['access_token', 'apikey'])) {
+ $apikey = $apikey ?: urldecode(explode('=', $arg, 2)[1] ?? '');
+ continue;
+ }
+ $keepArgs[] = $arg;
+}
+$apikey = $apikey ?: ($_SERVER['HTTP_X_API_KEY'] ?? '');
+
+//-- //signalr/ -> /signalr/
+$endpoint = '';
+if (preg_match('#/signalr/(.*)$#i', $path, $match)) {
+ $endpoint = rtrim('/signalr/' . strtolower($match[1]), '/');
+}
+
+if (!$apikey) {
+ logger($logfile, ['req' => $requestCounter, 'proxyCode' => 401]);
+ http_response_code(401);
+ exit();
+}
+
+$proxiedApp = $starr->getAppFromProxiedKey($apikey);
+
+if (!$proxiedApp || !$proxiedApp['starrApp']) {
+ logger($logfile, ['req' => $requestCounter, 'apikey' => $apikey, 'endpoint' => $endpoint, 'proxyCode' => 401]);
+ http_response_code(401);
+ exit();
+}
+
+$proxiedAppLogfile = str_replace('access.log', 'access_' . $proxiedApp['proxiedAppDetails']['name'] . '.log', $logfile);
+$app = $proxiedApp['starrApp'];
+
+$block = function ($code, $extra = []) use ($logfile, $proxiedAppLogfile, $requestCounter, $apikey, $endpoint, $proxiedApp, $usageDb, $proxyDb, $notifications) {
+ logger($logfile, ['req' => $requestCounter, 'apikey' => $apikey, 'endpoint' => $endpoint, 'proxyCode' => $code]);
+ logger($proxiedAppLogfile, ['req' => $requestCounter, 'apikey' => $apikey, 'endpoint' => $endpoint, 'proxyCode' => $code]);
+ $usageDb->adjustAppUsage($proxiedApp['proxiedAppDetails']['id'], $code);
+
+ if ($proxyDb->isNotificationTriggerEnabled('blocked')) {
+ $notifications->notify(0, 'blocked', [
+ 'event' => 'blocked',
+ 'proxyApp' => $proxiedApp['proxiedAppDetails']['name'],
+ 'starrApp' => $proxiedApp['starrAppDetails']['name'],
+ 'endpoint' => $endpoint
+ ] + $extra);
+ }
+
+ //-- auth_request only understands 401/403
+ http_response_code($code == 405 ? 403 : 401);
+ exit();
+};
+
+//-- THE SIGNALR STREAM CANNOT BE FIELD-REDACTED, SO AN APP WITH REDACTIONS NEVER GETS IT
+if (trim((string) $proxiedApp['proxiedAppDetails']['redactions'])) {
+ $block(401);
+}
+
+$isAllowedEndpoint = $starr->isAllowedEndpoint($app, $proxiedApp['access'], $endpoint);
+if (!$isAllowedEndpoint['allowed']) {
+ $block(401);
+}
+
+$endpoint = $isAllowedEndpoint['starrEndpoint'];
+if (!$starr->isAllowedEndpointMethod($proxiedApp['access'], $endpoint, $method)) {
+ $block(405, ['method' => $method]);
+}
+
+$backendUrl = rtrim($proxiedApp['starrAppDetails']['url'], '/');
+$backendParts = parse_url($backendUrl);
+if (!filter_var($backendUrl, FILTER_VALIDATE_URL) || !in_array(strtolower($backendParts['scheme']), ['http', 'https'])) {
+ http_response_code(502);
+ exit();
+}
+
+//-- AUTH ONLY: THE ARR HAS NOT BEEN CONTACTED YET, SO THIS IS NOT A PROXY RESULT AND IS NOT LOGGED AS 200
+logger($logfile, ['req' => $requestCounter, 'apikey' => $apikey, 'endpoint' => $endpoint, 'proxyCode' => 'auth-granted']);
+logger($proxiedAppLogfile, ['req' => $requestCounter, 'apikey' => $apikey, 'endpoint' => $endpoint, 'proxyCode' => 'auth-granted']);
+$usageDb->adjustAppUsage($proxiedApp['proxiedAppDetails']['id'], 200);
+
+//-- CONSUMED BY NGINX ONLY. THE REAL KEY IS INJECTED AS A REQUEST HEADER TO THE ARR
+header('X-Starr-Backend-Url: ' . $backendUrl);
+header('X-Starr-Endpoint: ' . $endpoint);
+header('X-Starr-Proxy-Args: ' . ($keepArgs ? '?' . implode('&', $keepArgs) : ''));
+header('X-Starr-Api-Key: ' . $proxiedApp['starrAppDetails']['apikey']);
+http_response_code(204);
diff --git a/root/etc/nginx/template.conf b/root/etc/nginx/template.conf
index f8f272b..664b86d 100644
--- a/root/etc/nginx/template.conf
+++ b/root/etc/nginx/template.conf
@@ -66,6 +66,19 @@ http {
'' close;
}
+ # Forward Upgrade to the arr only for real websocket upgrades (SignalR); anything else (h2c etc.) is dropped.
+ map $http_upgrade $signalr_upgrade {
+ default '';
+ ~*^websocket$ websocket;
+ }
+ map $signalr_upgrade $signalr_connection {
+ default upgrade;
+ '' close;
+ }
+
+ # Long-lived SignalR websockets: cap concurrent connections per client address.
+ limit_conn_zone $binary_remote_addr zone=signalr:10m;
+
# Enable http2 by default for all servers
# http2 on;
@@ -121,6 +134,7 @@ http {
fastcgi_pass 127.0.0.1:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $root/$1;
+ fastcgi_ignore_headers X-Accel-Redirect;
fastcgi_index index.php;
}
@@ -130,14 +144,33 @@ http {
fastcgi_pass 127.0.0.1:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $root/api/index.php;
+ fastcgi_ignore_headers X-Accel-Redirect;
fastcgi_param QUERY_STRING endpoint=/api/$2&$args;
fastcgi_index index.php;
}
+
+ # SignalR must stay at the NGINX layer because PHP cURL cannot proxy WebSockets.
+ # Auth, endpoint, and method checks run in a PHP script outside the web root, reachable only from here.
+ location = /signalr-auth {
+ internal;
+ fastcgi_pass 127.0.0.1:9000;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /app/www/signalr-auth.php;
+ fastcgi_ignore_headers X-Accel-Redirect;
+ fastcgi_param QUERY_STRING "";
+ fastcgi_param SIGNALR_SUBREQUEST 1;
+ fastcgi_param ORIG_URI $request_uri;
+ fastcgi_param ORIG_METHOD $request_method;
+ fastcgi_pass_request_body off;
+ fastcgi_param CONTENT_LENGTH "";
+ }
+
location ~ ^(.*)/feed/(.*)$ {
alias $root/;
fastcgi_pass 127.0.0.1:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $root/api/index.php;
+ fastcgi_ignore_headers X-Accel-Redirect;
fastcgi_param QUERY_STRING feed=/feed/$2&$args;
fastcgi_index index.php;
}
@@ -146,6 +179,7 @@ http {
fastcgi_pass 127.0.0.1:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $root/api/index.php;
+ fastcgi_ignore_headers X-Accel-Redirect;
fastcgi_param QUERY_STRING backup=/backup/$2&$args;
fastcgi_index index.php;
}
@@ -167,6 +201,25 @@ http {
return 404 '{"error":"Starr Proxy: Invalid starr route"}';
}
+ location ~ ^(.*)/signalr/(.*)$ {
+ alias $root/;
+ auth_request /signalr-auth;
+ auth_request_set $signalr_backend $upstream_http_x_starr_backend_url;
+ auth_request_set $signalr_endpoint $upstream_http_x_starr_endpoint;
+ auth_request_set $signalr_args $upstream_http_x_starr_proxy_args;
+ auth_request_set $signalr_key $upstream_http_x_starr_api_key;
+ proxy_http_version 1.1;
+ limit_conn signalr 10;
+ limit_conn_status 429;
+ proxy_ignore_headers X-Accel-Redirect;
+ proxy_set_header Upgrade $signalr_upgrade;
+ proxy_set_header Connection $signalr_connection;
+ proxy_set_header X-Api-Key $signalr_key;
+ proxy_set_header Authorization "";
+ proxy_pass $signalr_backend$signalr_endpoint$signalr_args;
+ proxy_read_timeout 300s;
+ }
+
# Health check
location /health {
return 200 'healthy';