From 37486038cea9338e8d31700c9dcddbf8ae36a22a Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:21:20 -0500 Subject: [PATCH] feat(signalr): opt-in SignalR/WebSocket passthrough for proxied arrs (#91) Adds an nginx /signalr location that WebSocket-proxies //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 #91 --- README.md | 4 ++- root/app/www/public/ajax/starr.php | 33 +++++++++++------ root/app/www/public/ajax/templates.php | 7 ++-- root/app/www/public/api/index.php | 21 ++++++----- root/app/www/public/js/starr.js | 1 + root/app/www/public/js/templates.js | 6 ++++ .../app/www/public/migrations/009_signalr.php | 23 ++++++++++++ root/app/www/public/pages/home.php | 3 +- root/app/www/public/pages/starr.php | 3 +- root/app/www/public/signalr/auth.php | 35 +++++++++++++++++++ .../www/public/templates/radarr/bazarr.json | 1 + .../www/public/templates/sonarr/bazarr.json | 1 + root/etc/nginx/template.conf | 26 ++++++++++++++ 13 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 root/app/www/public/migrations/009_signalr.php create mode 100644 root/app/www/public/signalr/auth.php diff --git a/README.md b/README.md index 57ea70b..efd1b70 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ 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. +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/WebSocket access is separately disabled by default. Enable the SignalR option when configuring an app that needs an arr `/signalr` connection. The Radarr and Sonarr Bazarr templates enable this option automatically. An example curl would be: diff --git a/root/app/www/public/ajax/starr.php b/root/app/www/public/ajax/starr.php index 2e46dc2..55ee0aa 100644 --- a/root/app/www/public/ajax/starr.php +++ b/root/app/www/public/ajax/starr.php @@ -154,12 +154,16 @@ - - Endpoint template
Automatically select the endpoints based on an app template + + Endpoint template
Automatically select the endpoints based on an app template - - + + + + SignalR
Allow this app to use the arr SignalR/WebSocket endpoint + > + endpoints
@@ -240,8 +244,9 @@ $fields['apikey'] = $_POST['apikey']; $fields['starr_id'] = intval($_POST['starr_id']); $fields['endpoints'] = json_encode($endpoints, JSON_UNESCAPED_SLASHES); - $fields['template'] = $_POST['template']; - $fields['redactions'] = $_POST['redactions']; + $fields['template'] = $_POST['template']; + $fields['redactions'] = $_POST['redactions']; + $fields['signalr'] = intval($_POST['signalr']); if ($_POST['id'] != 99) { $error = $proxyDb->updateApp($_POST['id'], $fields); @@ -306,9 +311,11 @@ $templateFile = file_exists($app['template']) ? $app['template'] : str_replace('../', './', $app['template']); $appTemplate = getFile($templateFile); - if ($appTemplate) { - $app['endpoints'] = json_encode($appTemplate); - $error = $proxyDb->updateApp($_POST['appId'], $app); + if ($appTemplate) { + $app['signalr'] = intval(!empty($appTemplate['signalr'])); + unset($appTemplate['signalr']); + $app['endpoints'] = json_encode($appTemplate); + $error = $proxyDb->updateApp($_POST['appId'], $app); } break; @@ -331,8 +338,12 @@ } $endpoints = []; - foreach ($templateFile as $templateEndpoint => $methods) { - foreach ($methods as $method) { + foreach ($templateFile as $templateEndpoint => $methods) { + if (!is_array($methods)) { + continue; + } + + foreach ($methods as $method) { $endpoints[$templateEndpoint][$method][] = 'template'; } } diff --git a/root/app/www/public/ajax/templates.php b/root/app/www/public/ajax/templates.php index f9e60f6..55aaadf 100644 --- a/root/app/www/public/ajax/templates.php +++ b/root/app/www/public/ajax/templates.php @@ -42,8 +42,11 @@ } if ($_POST['m'] == 'saveTemplateStarrAccess') { - $existing = $proxyDb->getAppFromId($_POST['id'], $appsTable); - $endpoints = $existing['endpoints'] ? json_decode($existing['endpoints'], true) : []; + $existing = $proxyDb->getAppFromId($_POST['id'], $appsTable); + $endpoints = $existing['endpoints'] ? json_decode($existing['endpoints'], true) : []; + if ($existing['signalr']) { + $endpoints['signalr'] = true; + } $name = strtolower(preg_replace('/[^a-zA-Z0-9 _-]/', '', $_POST['name'])); file_put_contents(APP_USER_TEMPLATES_PATH . $app . '/' . $name . '.json', json_encode($endpoints, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } diff --git a/root/app/www/public/api/index.php b/root/app/www/public/api/index.php index 07de774..47c31b4 100644 --- a/root/app/www/public/api/index.php +++ b/root/app/www/public/api/index.php @@ -120,15 +120,18 @@ $starrApp = $starr->getAppFromStarrKey($request['apikey'], $starrsTable); if (!$error) { - $scopeKey = generateApikey(); - $scopeAccess = $request['template'] ? json_decode(file_get_contents('../templates/' . $request['starr'] . '/' . $request['template'] . '.json'), true) : []; - - $fields = [ - 'name' => $request['name'], - 'apikey' => $scopeKey, - 'starr_id' => intval($starrApp['id']), - 'endpoints' => json_encode($scopeAccess, JSON_UNESCAPED_SLASHES) - ]; + $scopeKey = generateApikey(); + $scopeAccess = $request['template'] ? json_decode(file_get_contents('../templates/' . $request['starr'] . '/' . $request['template'] . '.json'), true) : []; + $signalr = !empty($scopeAccess['signalr']); + unset($scopeAccess['signalr']); + + $fields = [ + 'name' => $request['name'], + 'apikey' => $scopeKey, + 'starr_id' => intval($starrApp['id']), + 'endpoints' => json_encode($scopeAccess, JSON_UNESCAPED_SLASHES), + 'signalr' => intval($signalr) + ]; $error = $proxyDb->addApp($fields); if (!$error) { diff --git a/root/app/www/public/js/starr.js b/root/app/www/public/js/starr.js index fd27854..998cabb 100644 --- a/root/app/www/public/js/starr.js +++ b/root/app/www/public/js/starr.js @@ -125,6 +125,7 @@ function saveAppStarrAccess(app, id) params += '&starr_id=' + $('#access-instance').val(); params += '&template=' + $('#access-template').val(); params += '&redactions=' + $('#access-redactions').val(); + params += '&signalr=' + ($('#access-signalr').prop('checked') ? 1 : 0); $.each($('[id^=endpoint-counter-]'), function() { const counter = $(this).attr('id').replace('endpoint-counter-', ''); diff --git a/root/app/www/public/js/templates.js b/root/app/www/public/js/templates.js index 39b5ec4..89e191e 100644 --- a/root/app/www/public/js/templates.js +++ b/root/app/www/public/js/templates.js @@ -38,12 +38,18 @@ function applyTemplateOptions() const loopId = $(this).prop('id'); $.each(resultData, function(endpoint, methods) { + if (!Array.isArray(methods)) { + return; + } + if (loopEndpoint == endpoint && methods.includes(loopMethod)) { $('#' + loopId).prop('checked', true); } }); }); + $('#access-signalr').prop('checked', resultData.signalr === true); + toast('Templates', 'The selected template access has been applied', 'info'); } }); diff --git a/root/app/www/public/migrations/009_signalr.php b/root/app/www/public/migrations/009_signalr.php new file mode 100644 index 0000000..f8cee05 --- /dev/null +++ b/root/app/www/public/migrations/009_signalr.php @@ -0,0 +1,23 @@ + '[Q] ' . preg_replace('!\s+!', ' ', $query)]); + + $proxyDb->query($query); + + if ($proxyDb->error() != 'not an error') { + logger(MIGRATION_LOG, ['text' => '[R] ' . $proxyDb->error()]); + } else { + logger(MIGRATION_LOG, ['text' => '[R] query applied!']); + } +} diff --git a/root/app/www/public/pages/home.php b/root/app/www/public/pages/home.php index b615af4..609042b 100644 --- a/root/app/www/public/pages/home.php +++ b/root/app/www/public/pages/home.php @@ -184,7 +184,8 @@ $appAccess = json_decode($app['endpoints'], true); if (file_exists($templateFile)) { - $appTemplate = getFile($templateFile); + $appTemplate = getFile($templateFile); + unset($appTemplate['signalr']); if (count($appAccess, COUNT_RECURSIVE) != count($appTemplate, COUNT_RECURSIVE)) { foreach ($starrsTable as $starrApp) { diff --git a/root/app/www/public/pages/starr.php b/root/app/www/public/pages/starr.php index 17ee694..906a957 100644 --- a/root/app/www/public/pages/starr.php +++ b/root/app/www/public/pages/starr.php @@ -148,7 +148,8 @@ $templateFile = file_exists($accessApp['template']) ? $accessApp['template'] : str_replace('../', './', $accessApp['template']); if (file_exists($templateFile)) { - $templateEndpoints = getFile($templateFile); + $templateEndpoints = getFile($templateFile); + unset($templateEndpoints['signalr']); $template = 'Template: ' . count($templateEndpoints, COUNT_RECURSIVE) . ' endpoint' . (count($templateEndpoints, COUNT_RECURSIVE) == 1 ? '' : 's') . ''; } ?> diff --git a/root/app/www/public/signalr/auth.php b/root/app/www/public/signalr/auth.php new file mode 100644 index 0000000..a1f9876 --- /dev/null +++ b/root/app/www/public/signalr/auth.php @@ -0,0 +1,35 @@ +getAppFromProxiedKey($apikey); + +if (!$apikey || !$proxiedApp['starrApp'] || !$proxiedApp['starrAppDetails'] || !$proxiedApp['proxiedAppDetails']) { + http_response_code(401); + exit(); +} + +if (!$proxiedApp['proxiedAppDetails']['signalr']) { + http_response_code(403); + exit(); +} + +$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(); +} + +header('X-Starr-Backend-Url: ' . $backendUrl); +header('X-Starr-Api-Key: ' . $proxiedApp['starrAppDetails']['apikey']); +$proxyArgs = $_GET; +unset($proxyArgs['access_token'], $proxyArgs['apikey']); +$proxyArgs = ['access_token' => $proxiedApp['starrAppDetails']['apikey']] + $proxyArgs; +header('X-Starr-Proxy-Args: ' . http_build_query($proxyArgs)); +http_response_code(204); diff --git a/root/app/www/public/templates/radarr/bazarr.json b/root/app/www/public/templates/radarr/bazarr.json index 91c887b..0db59d9 100644 --- a/root/app/www/public/templates/radarr/bazarr.json +++ b/root/app/www/public/templates/radarr/bazarr.json @@ -1,4 +1,5 @@ { + "signalr": true, "/api/v3/command": [ "post" ], diff --git a/root/app/www/public/templates/sonarr/bazarr.json b/root/app/www/public/templates/sonarr/bazarr.json index 35909e3..3909615 100644 --- a/root/app/www/public/templates/sonarr/bazarr.json +++ b/root/app/www/public/templates/sonarr/bazarr.json @@ -1,4 +1,5 @@ { + "signalr": true, "/api/v3/command": [ "post" ], diff --git a/root/etc/nginx/template.conf b/root/etc/nginx/template.conf index 8b9b788..91414c1 100644 --- a/root/etc/nginx/template.conf +++ b/root/etc/nginx/template.conf @@ -136,6 +136,32 @@ http { fastcgi_param QUERY_STRING endpoint=/api/$2&$args; fastcgi_index index.php; } + + # SignalR routing must stay at the NGINX layer because PHP cURL cannot proxy WebSockets. + location = /signalr-auth { + internal; + alias $root/; + fastcgi_pass 127.0.0.1:9000; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $root/signalr/auth.php; + fastcgi_param QUERY_STRING $args; + fastcgi_index index.php; + } + + location ~ ^(.*)/signalr/(.*)$ { + alias $root/; + set $signalr_path $2; + auth_request /signalr-auth; + auth_request_set $signalr_backend_url $upstream_http_x_starr_backend_url; + auth_request_set $signalr_real_key $upstream_http_x_starr_api_key; + auth_request_set $signalr_proxy_args $upstream_http_x_starr_proxy_args; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Api-Key $signalr_real_key; + proxy_pass $signalr_backend_url/signalr/$signalr_path?$signalr_proxy_args; + proxy_read_timeout 300s; + } location ~ ^(.*)/feed/(.*)$ { alias $root/; fastcgi_pass 127.0.0.1:9000;