Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion root/app/www/public/classes/Starr.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 6 additions & 0 deletions root/app/www/public/templates/radarr/bazarr.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@
],
"/api/system/status": [
"get"
],
"/signalr/messages": [
"get"
],
"/signalr/messages/negotiate": [
"post"
]
}
6 changes: 6 additions & 0 deletions root/app/www/public/templates/sonarr/bazarr.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,11 @@
],
"/api/system/status": [
"get"
],
"/signalr/messages": [
"get"
],
"/signalr/messages/negotiate": [
"post"
]
}
119 changes: 119 additions & 0 deletions root/app/www/signalr-auth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

/*
* nginx auth_request target for /<base>/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'] ?? '');

//-- /<base>/signalr/<rest> -> /signalr/<rest>
$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);
53 changes: 53 additions & 0 deletions root/etc/nginx/template.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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';
Expand Down
Loading