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 @@
-
= ucfirst($app) ?> 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;
|