diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/.login.php.modified.snapshot.php b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/.login.php.modified.snapshot.php
index b59772fc12..b5d6c0e59b 100644
--- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/.login.php.modified.snapshot.php
+++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/.login.php.modified.snapshot.php
@@ -17,30 +17,49 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool
my_logger("SSO Login Attempt Failed: Invalid token format");
return false;
}
- $safePassword = escapeshellarg($password);
-
- $output = array();
- exec("/etc/rc.d/rc.unraid-api sso validate-token $safePassword 2>&1", $output, $code);
+ $payload = json_encode(["token" => $password]);
+ $response = false;
+ $code = 0;
+
+ if (function_exists("curl_init")) {
+ $ch = curl_init("http://127.0.0.1/auth/sso/validate");
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 5);
+ $response = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ } else {
+ $context = stream_context_create([
+ "http" => [
+ "method" => "POST",
+ "header" => "Content-Type: application/json
+",
+ "content" => $payload,
+ "timeout" => 5,
+ ],
+ ]);
+ $response = @file_get_contents("http://127.0.0.1/auth/sso/validate", false, $context);
+ if (isset($http_response_header[0])) {
+ $code = (int) preg_replace('/^HTTP\/[0-9.]+\s+(\d+).*/', '', $http_response_header[0]);
+ }
+ }
my_logger("SSO Login Attempt Code: $code");
- my_logger("SSO Login Attempt Response: " . print_r($output, true));
+ my_logger("SSO Login Attempt Response: " . print_r($response, true));
- if ($code !== 0) {
+ if ($code !== 200) {
return false;
}
- if (empty($output)) {
+ if (empty($response)) {
return false;
}
try {
- // Split on first { and take everything after it
- $jsonParts = explode('{', $output[0], 2);
- if (count($jsonParts) < 2) {
- my_logger("SSO Login Attempt Failed: No JSON found in response");
- return false;
- }
- $response = json_decode('{' . $jsonParts[1], true);
- if (isset($response['valid']) && $response['valid'] === true) {
+ $decoded = json_decode($response, true);
+ if (isset($decoded['valid']) && $decoded['valid'] === true) {
return true;
}
} catch (Exception $e) {
diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot
index 7c555fda54..b1f68cd327 100644
--- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot
+++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot
@@ -383,6 +383,17 @@ build_locations(){
include fastcgi_params;
}
#
+ # SSO endpoints (public)
+ location /auth/sso {
+ allow all;
+ proxy_pass http://unix:/var/run/unraid-core.sock:;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ #
# Redirect to login page on failed authentication (401)
#
error_page 401 @401;
@@ -417,10 +428,31 @@ build_locations(){
#
# my servers proxy
#
+ location /graphql/api/auth/oidc {
+ allow all;
+ proxy_pass http://unix:/var/run/unraid-core.sock:;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ location /graphql/api {
+ allow all;
+ proxy_pass http://unix:/var/run/unraid-api.sock:;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
location /graphql {
allow all;
error_log /dev/null crit;
- proxy_pass http://unix:/var/run/unraid-api.sock:/graphql;
+ if ($http_upgrade = "websocket") {
+ rewrite ^/graphql$ /graphql/socket break;
+ }
+ proxy_pass http://unix:/var/run/unraid-core.sock:;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch
index fbac95e0bc..00bcf62618 100644
--- a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch
+++ b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch
@@ -44,7 +44,66 @@ Index: /etc/rc.d/rc.nginx
T=' '
if check && [[ $1 == lo ]]; then
if [[ $IPV4 == yes ]]; then
-@@ -566,11 +584,11 @@
+@@ -363,10 +381,21 @@
+ allow all;
+ try_files /login.php =404;
+ include fastcgi_params;
+ }
+ #
++ # SSO endpoints (public)
++ location /auth/sso {
++ allow all;
++ proxy_pass http://unix:/var/run/unraid-core.sock:;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++ #
+ # Redirect to login page on failed authentication (401)
+ #
+ error_page 401 @401;
+ location @401 {
+ return 302 $scheme://$http_host/login;
+@@ -397,14 +426,35 @@
+ nchan_stub_status;
+ }
+ #
+ # my servers proxy
+ #
++ location /graphql/api/auth/oidc {
++ allow all;
++ proxy_pass http://unix:/var/run/unraid-core.sock:;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++ location /graphql/api {
++ allow all;
++ proxy_pass http://unix:/var/run/unraid-api.sock:;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
+ location /graphql {
+ allow all;
+ error_log /dev/null crit;
+- proxy_pass http://unix:/var/run/unraid-api.sock:/graphql;
++ if ($http_upgrade = "websocket") {
++ rewrite ^/graphql$ /graphql/socket break;
++ }
++ proxy_pass http://unix:/var/run/unraid-core.sock:;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_cache_bypass $http_upgrade;
+@@ -566,11 +616,11 @@
# extract common name from cert
CERTNAME=$(openssl x509 -noout -subject -nameopt multiline -in $CERTPATH | sed -n 's/ *commonName *= //p')
# define CSP frame-ancestors for cert
@@ -57,7 +116,7 @@ Index: /etc/rc.d/rc.nginx
WANIP6=$(curl https://wanip6.unraid.net/ 2>/dev/null)
fi
if [[ $CERTNAME == *\.myunraid\.net ]]; then
-@@ -660,14 +678,14 @@
+@@ -660,14 +710,14 @@
echo "NGINX_WANFQDN=\"$WANFQDN\"" >>$INI
echo "NGINX_WANFQDN6=\"$WANFQDN6\"" >>$INI
# defined if ts_bundle.pem present:
diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/patches/sso.patch b/api/src/unraid-api/unraid-file-modifier/modifications/patches/sso.patch
index f9fce692fd..78a2d12c52 100644
--- a/api/src/unraid-api/unraid-file-modifier/modifications/patches/sso.patch
+++ b/api/src/unraid-api/unraid-file-modifier/modifications/patches/sso.patch
@@ -2,7 +2,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/.login.php
===================================================================
--- /usr/local/emhttp/plugins/dynamix/include/.login.php original
+++ /usr/local/emhttp/plugins/dynamix/include/.login.php modified
-@@ -1,6 +1,57 @@
+@@ -1,6 +1,76 @@
$password]);
++ $response = false;
++ $code = 0;
+
-+ $output = array();
-+ exec("/etc/rc.d/rc.unraid-api sso validate-token $safePassword 2>&1", $output, $code);
++ if (function_exists("curl_init")) {
++ $ch = curl_init("http://127.0.0.1/auth/sso/validate");
++ curl_setopt($ch, CURLOPT_POST, true);
++ curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
++ curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
++ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
++ curl_setopt($ch, CURLOPT_TIMEOUT, 5);
++ $response = curl_exec($ch);
++ $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
++ curl_close($ch);
++ } else {
++ $context = stream_context_create([
++ "http" => [
++ "method" => "POST",
++ "header" => "Content-Type: application/json
++",
++ "content" => $payload,
++ "timeout" => 5,
++ ],
++ ]);
++ $response = @file_get_contents("http://127.0.0.1/auth/sso/validate", false, $context);
++ if (isset($http_response_header[0])) {
++ $code = (int) preg_replace('/^HTTP\/[0-9.]+\s+(\d+).*/', '', $http_response_header[0]);
++ }
++ }
+ my_logger("SSO Login Attempt Code: $code");
-+ my_logger("SSO Login Attempt Response: " . print_r($output, true));
++ my_logger("SSO Login Attempt Response: " . print_r($response, true));
+
-+ if ($code !== 0) {
++ if ($code !== 200) {
+ return false;
+ }
+
-+ if (empty($output)) {
++ if (empty($response)) {
+ return false;
+ }
+
+ try {
-+ // Split on first { and take everything after it
-+ $jsonParts = explode('{', $output[0], 2);
-+ if (count($jsonParts) < 2) {
-+ my_logger("SSO Login Attempt Failed: No JSON found in response");
-+ return false;
-+ }
-+ $response = json_decode('{' . $jsonParts[1], true);
-+ if (isset($response['valid']) && $response['valid'] === true) {
++ $decoded = json_decode($response, true);
++ if (isset($decoded['valid']) && $decoded['valid'] === true) {
+ return true;
+ }
+ } catch (Exception $e) {
@@ -60,7 +79,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/.login.php
// Only start a session to check if they have a cookie that looks like our session
$server_name = strtok($_SERVER['HTTP_HOST'], ":");
if (!empty($_COOKIE['unraid_'.md5($server_name)])) {
-@@ -128,11 +179,11 @@
+@@ -128,11 +198,11 @@
}
throw new Exception(_('Too many invalid login attempts'));
}
@@ -73,7 +92,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/.login.php
// Successful login, start session
@unlink($failFile);
-@@ -434,10 +485,11 @@
+@@ -434,10 +504,11 @@
= $error ?>
diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts
index 6b1c717a3a..148ce78fa0 100644
--- a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts
+++ b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts
@@ -7,9 +7,11 @@ import {
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
/**
- * Patch rc.nginx on < Unraid 7.2.0 to read the updated connect & api config files
+ * Patch rc.nginx to read the updated connect & api config files.
*
- * Backport of https://github.com/unraid/webgui/pull/2269
+ * Backport of https://github.com/unraid/webgui/pull/2269. This modification
+ * runs on all versions but uses idempotent guards to avoid double-injection
+ * when the base OS already includes the changes.
*/
export default class RcNginxModification extends FileModification {
public filePath: string = '/etc/rc.d/rc.nginx' as const;
@@ -29,9 +31,9 @@ export default class RcNginxModification extends FileModification {
throw new Error(`File ${this.filePath} not found.`);
}
const fileContent = await readFile(this.filePath, 'utf8');
- if (!fileContent.includes('MYSERVERS=')) {
- throw new Error(`MYSERVERS not found in the file; incorrect target?`);
- }
+ // if (!fileContent.includes('MYSERVERS=')) {
+ // throw new Error(`MYSERVERS not found in the file; incorrect target?`);
+ // }
let newContent = fileContent.replace(
'MYSERVERS="/boot/config/plugins/dynamix.my.servers/myservers.cfg"',
@@ -68,6 +70,27 @@ check_remote_access(){
`if [[ -L /usr/local/sbin/unraid-api ]] && check_remote_access; then`
);
+ newContent = newContent.replace(
+ 'proxy_pass http://unix:/var/run/unraid-api.sock:/graphql;',
+ 'if ($http_upgrade = "websocket") {\n\t rewrite ^/graphql$ /graphql/socket break;\n\t }\n\t proxy_pass http://unix:/var/run/unraid-core.sock:;'
+ );
+
+ if (!newContent.includes('location /auth/sso')) {
+ newContent = newContent.replace(
+ '\t# Redirect to login page on failed authentication (401)\n',
+ // prettier-ignore
+ `\t# SSO endpoints (public)\n\tlocation /auth/sso {\n\t allow all;\n\t proxy_pass http://unix:/var/run/unraid-core.sock:;\n\t proxy_http_version 1.1;\n\t proxy_set_header Host $host;\n\t proxy_set_header X-Real-IP $remote_addr;\n\t proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t proxy_set_header X-Forwarded-Proto $scheme;\n\t}\n\t#\n\t# Redirect to login page on failed authentication (401)\n`
+ );
+ }
+
+ if (!newContent.includes('location /graphql/api/auth/oidc')) {
+ newContent = newContent.replace(
+ '\t# my servers proxy\n\t#\n\tlocation /graphql {',
+ // prettier-ignore
+ `\t# my servers proxy\n\t#\n\tlocation /graphql/api/auth/oidc {\n\t allow all;\n\t proxy_pass http://unix:/var/run/unraid-core.sock:;\n\t proxy_http_version 1.1;\n\t proxy_set_header Host $host;\n\t proxy_set_header X-Real-IP $remote_addr;\n\t proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t proxy_set_header X-Forwarded-Proto $scheme;\n\t}\n\tlocation /graphql/api {\n\t allow all;\n\t proxy_pass http://unix:/var/run/unraid-api.sock:;\n\t proxy_http_version 1.1;\n\t proxy_set_header Host $host;\n\t proxy_set_header X-Real-IP $remote_addr;\n\t proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t proxy_set_header X-Forwarded-Proto $scheme;\n\t}\n\tlocation /graphql {`
+ );
+ }
+
newContent = newContent.replace(
'for NET in ${!NET_FQDN6[@]}; do',
'for NET in "${!NET_FQDN6[@]}"; do'
@@ -91,7 +114,7 @@ check_remote_access(){
}
async shouldApply(): Promise {
- const { shouldApply, reason } = await super.shouldApply();
+ const { shouldApply, reason } = await super.shouldApply({ checkOsVersion: false });
return {
shouldApply,
reason,
diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts
index aef9b7dce6..9cda627165 100644
--- a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts
+++ b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts
@@ -9,6 +9,11 @@ export default class SSOFileModification extends FileModification {
id: string = 'sso';
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/include/.login.php';
+ protected async getPregeneratedPatch(): Promise {
+ // Prefer the dynamic patch to avoid stale pregenerated SSO patches.
+ return null;
+ }
+
protected async generatePatch(overridePath?: string): Promise {
// Define the new PHP function to insert
/* eslint-disable no-useless-escape */
@@ -29,30 +34,48 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool
my_logger("SSO Login Attempt Failed: Invalid token format");
return false;
}
- $safePassword = escapeshellarg($password);
-
- $output = array();
- exec("/etc/rc.d/rc.unraid-api sso validate-token $safePassword 2>&1", $output, $code);
+ $payload = json_encode(["token" => $password]);
+ $response = false;
+ $code = 0;
+
+ if (function_exists("curl_init")) {
+ $ch = curl_init("http://127.0.0.1/auth/sso/validate");
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 5);
+ $response = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ } else {
+ $context = stream_context_create([
+ "http" => [
+ "method" => "POST",
+ "header" => "Content-Type: application/json\r\n",
+ "content" => $payload,
+ "timeout" => 5,
+ ],
+ ]);
+ $response = @file_get_contents("http://127.0.0.1/auth/sso/validate", false, $context);
+ if (isset($http_response_header[0])) {
+ $code = (int) preg_replace('/^HTTP\\/[0-9.]+\\s+(\\d+).*/', '$1', $http_response_header[0]);
+ }
+ }
my_logger("SSO Login Attempt Code: $code");
- my_logger("SSO Login Attempt Response: " . print_r($output, true));
+ my_logger("SSO Login Attempt Response: " . print_r($response, true));
- if ($code !== 0) {
+ if ($code !== 200) {
return false;
}
- if (empty($output)) {
+ if (empty($response)) {
return false;
}
try {
- // Split on first { and take everything after it
- $jsonParts = explode('{', $output[0], 2);
- if (count($jsonParts) < 2) {
- my_logger("SSO Login Attempt Failed: No JSON found in response");
- return false;
- }
- $response = json_decode('{' . $jsonParts[1], true);
- if (isset($response['valid']) && $response['valid'] === true) {
+ $decoded = json_decode($response, true);
+ if (isset($decoded['valid']) && $decoded['valid'] === true) {
return true;
}
} catch (Exception $e) {
@@ -89,7 +112,7 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool
}
async shouldApply(): Promise {
- const superShouldApply = await super.shouldApply();
+ const superShouldApply = await super.shouldApply({ checkOsVersion: false });
if (!superShouldApply.shouldApply) {
return superShouldApply;
}
diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg
index 6e8a60e695..5924f99743 100755
--- a/plugin/plugins/dynamix.unraid.net.plg
+++ b/plugin/plugins/dynamix.unraid.net.plg
@@ -9,6 +9,10 @@
+
+
+
+
@@ -52,6 +56,12 @@ exit 0
&txz_sha256;
+
+
+ &core_txz_url;
+ &core_txz_sha256;
+
+
@@ -320,6 +330,21 @@ exit 0
fi
fi
+ # Stop and remove Unraid Core package
+ if [ -x "/etc/rc.d/rc.unraid" ]; then
+ echo "Stopping Unraid Core..."
+ /etc/rc.d/rc.unraid stop || echo "Warning: Failed to stop Unraid Core"
+ fi
+
+ core_pkg_installed=$(ls -1 /var/log/packages/unraid-* 2>/dev/null | head -1)
+ if [ -n "$core_pkg_installed" ]; then
+ core_pkg_basename=$(basename "$core_pkg_installed")
+ echo "Removing core package: $core_pkg_basename"
+ removepkg --terse "$core_pkg_basename"
+ else
+ echo "No Unraid Core package found"
+ fi
+
# File restoration function
echo "Restoring files..."
@@ -404,6 +429,9 @@ exit 0
PKG_FILE="&source;" # Full path to the package file including .txz extension
PKG_URL="&txz_url;" # URL where package was downloaded from
PKG_NAME="&txz_name;" # Name of the package file
+ CORE_PKG_FILE="&core_source;"
+ CORE_PKG_URL="&core_txz_url;"
+ CORE_PKG_NAME="&core_txz_name;"
CONNECT_API_VERSION="&api_version;" # Version of API included with Connect
/dev/null 2>&1; then
@@ -514,6 +545,7 @@ if [ "$SKIP_API_INSTALL" = false ]; then
echo "⚠️ Package installation failed"
exit 1
fi
+ API_PKG_INSTALLED=1
if [[ -n "$TAG" && "$TAG" != "" ]]; then
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
@@ -524,6 +556,45 @@ else
echo "Connect plugin remains installed but API was not modified"
fi
+# Install Unraid Core package
+if [ -f "$CORE_PKG_FILE" ]; then
+ echo "Installing Unraid Core package..."
+ # Clean up any old core package txz files if they don't match our current version
+ for txz_file in /boot/config/plugins/dynamix.my.servers/unraid-*.txz; do
+ if [ -f "$txz_file" ] && [ "$txz_file" != "${CORE_PKG_FILE}" ]; then
+ echo "Removing old core package file: $txz_file"
+ rm -f "$txz_file"
+ fi
+ done
+
+ # Stop the core service before mutating /usr/local/unraid
+ if [ -x "/etc/rc.d/rc.unraid" ]; then
+ echo "Stopping Unraid Core service before upgrade..."
+ /etc/rc.d/rc.unraid stop || echo "Warning: Failed to stop Unraid Core service"
+ fi
+
+ upgradepkg --install-new --reinstall "${CORE_PKG_FILE}"
+ if [ $? -ne 0 ]; then
+ echo "⚠️ Core package installation failed"
+ if [ "$API_PKG_INSTALLED" -eq 1 ]; then
+ echo "⚠️ Unraid API package was installed; leaving it in place for troubleshooting."
+ echo " Re-run install or uninstall via Plugins > Installed Plugins to rollback."
+ fi
+ exit 1
+ fi
+
+ if [ -f "/etc/rc.d/rc.unraid" ]; then
+ chmod +x /etc/rc.d/rc.unraid
+ fi
+else
+ echo "⚠️ Core package file not found: $CORE_PKG_FILE"
+ if [ "$API_PKG_INSTALLED" -eq 1 ]; then
+ echo "⚠️ Unraid API package was installed; leaving it in place for troubleshooting."
+ echo " Re-run install or uninstall via Plugins > Installed Plugins to rollback."
+ fi
+ exit 1
+fi
+
exit 0
]]>
@@ -599,6 +670,13 @@ echo "If no additional messages appear within 30 seconds, it is safe to refresh
/etc/rc.d/rc.unraid-api start
echo "Unraid API service started"
+if [ -x "/etc/rc.d/rc.unraid" ]; then
+ echo "Starting Unraid Core service"
+ /etc/rc.d/rc.unraid start
+ echo "Unraid Core service started"
+else
+ echo "Warning: rc.unraid not found; core service not started"
+fi
echo "✅ Installation is complete, it is safe to close this window"
echo
exit 0
diff --git a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid
new file mode 100755
index 0000000000..49c881a867
--- /dev/null
+++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid
@@ -0,0 +1,109 @@
+#!/bin/bash
+# /etc/rc.d/rc.unraid
+# Unraid Phoenix Application Service
+
+APP_DIR="/usr/local/unraid"
+RELEASE_BIN="$APP_DIR/_build/prod/rel/unraid/bin/unraid"
+CONFIG_DIR="/boot/config/unraid"
+SOCKET_PATH="/var/run/unraid-core.sock"
+LOG_PATH="${UNRAID_LOG_PATH:-/var/log/unraid-core.log}"
+
+# Load user env if exists
+[ -f "$CONFIG_DIR/env" ] && source "$CONFIG_DIR/env"
+
+# Ensure config and log directories exist
+mkdir -p "$CONFIG_DIR"
+mkdir -p "$(dirname "$LOG_PATH")"
+touch "$LOG_PATH"
+
+# Generate secret_key_base if not exists
+if [ ! -f "$CONFIG_DIR/secret_key_base" ]; then
+ head -c 64 /dev/urandom | base64 | tr -d '\n' > "$CONFIG_DIR/secret_key_base"
+ chmod 600 "$CONFIG_DIR/secret_key_base"
+fi
+
+export SECRET_KEY_BASE=$(cat "$CONFIG_DIR/secret_key_base")
+export RELEASE_COOKIE=$(cat "$CONFIG_DIR/secret_key_base" | head -c 20)
+export UNRAID_CONFIG_DIR="$CONFIG_DIR"
+export RUN_ERL_LOG="${RUN_ERL_LOG:-$LOG_PATH}"
+export RELEASE_LOG_DIR="${RELEASE_LOG_DIR:-$(dirname "$LOG_PATH")}"
+export RELEASE_NODE="${UNRAID_RELEASE_NODE:-unraid}"
+export RELEASE_DISTRIBUTION="${UNRAID_RELEASE_DISTRIBUTION:-sname}"
+export RELEASE_MODE="${UNRAID_RELEASE_MODE:-interactive}"
+
+# Import user's runtime.exs if exists
+[ -f "$CONFIG_DIR/runtime.exs" ] && export RELEASE_CONFIG_DIR="$CONFIG_DIR"
+
+# Socket/port configuration
+if [ -n "${UNRAID_PORT:-}" ]; then
+ export PHX_PORT="$UNRAID_PORT"
+else
+ export PHX_SOCKET="${UNRAID_SOCKET:-$SOCKET_PATH}"
+fi
+
+start() {
+ echo -n "Starting Unraid... "
+ [ -S "$SOCKET_PATH" ] && rm -f "$SOCKET_PATH"
+ "$RELEASE_BIN" daemon
+ echo "done"
+}
+
+stop() {
+ echo -n "Stopping Unraid... "
+ "$RELEASE_BIN" stop 2>/dev/null || true
+ [ -S "$SOCKET_PATH" ] && rm -f "$SOCKET_PATH"
+ echo "done"
+}
+
+restart() {
+ stop
+ sleep 2
+ start
+}
+
+status() {
+ "$RELEASE_BIN" pid >/dev/null 2>&1 && echo "Running" || echo "Stopped"
+}
+
+rollback() {
+ local current_dir="/usr/local/unraid"
+ local previous_dir="/usr/local/unraid.prev"
+ local temp_dir="/usr/local/unraid.tmp"
+
+ if [ -d "$previous_dir" ]; then
+ echo "Rolling back to previous version..."
+ stop
+ if [ -e "$temp_dir" ]; then
+ echo "Rollback failed: temp backup already exists at $temp_dir"
+ return 1
+ fi
+ if ! mv "$current_dir" "$temp_dir"; then
+ echo "Rollback failed: unable to move current install to temp backup"
+ return 1
+ fi
+ if ! mv "$previous_dir" "$current_dir"; then
+ echo "Rollback failed: unable to restore previous version"
+ if [ -d "$temp_dir" ]; then
+ mv "$temp_dir" "$current_dir" || echo "Rollback recovery failed: unable to restore current install"
+ fi
+ return 1
+ fi
+ if ! rm -rf "$temp_dir"; then
+ echo "Rollback warning: unable to remove temp backup at $temp_dir"
+ fi
+ start
+ echo "Rollback complete"
+ else
+ echo "No previous version available"
+ return 1
+ fi
+}
+
+case "${1:-}" in
+ start) start ;;
+ stop) stop ;;
+ restart) restart ;;
+ status) status ;;
+ rollback) rollback ;;
+ *) echo "Usage: $0 {start|stop|restart|status|rollback}" ;;
+esac
diff --git a/plugin/source/dynamix.unraid.net/etc/rc.d/rc6.d/K30unraid-core b/plugin/source/dynamix.unraid.net/etc/rc.d/rc6.d/K30unraid-core
new file mode 100644
index 0000000000..82a004bbcd
--- /dev/null
+++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc6.d/K30unraid-core
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Stop Unraid Core on shutdown/reboot
+
+if [ -x /etc/rc.d/rc.unraid ]; then
+ echo "Stopping Unraid Core..."
+ /etc/rc.d/rc.unraid stop
+fi
diff --git a/plugin/source/dynamix.unraid.net/install/doinst.sh b/plugin/source/dynamix.unraid.net/install/doinst.sh
index e18f5f64eb..79b6e2292f 100644
--- a/plugin/source/dynamix.unraid.net/install/doinst.sh
+++ b/plugin/source/dynamix.unraid.net/install/doinst.sh
@@ -6,7 +6,7 @@ backup_file_if_exists() {
fi
}
-for f in etc/rc.d/rc6.d/K*unraid-api etc/rc.d/rc6.d/K*flash-backup; do
+for f in etc/rc.d/rc6.d/K*unraid-api etc/rc.d/rc6.d/K*unraid-core etc/rc.d/rc6.d/K*flash-backup; do
[ -e "$f" ] && chmod 755 "$f"
done
diff --git a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh
index 0731bd976e..107d44aa87 100755
--- a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh
+++ b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh
@@ -42,10 +42,12 @@ echo "Performing comprehensive installation verification..."
# Define critical files to check (POSIX-compliant, no arrays)
CRITICAL_FILES="/usr/local/bin/unraid-api
/etc/rc.d/rc.unraid-api
+/etc/rc.d/rc.unraid
/usr/local/emhttp/plugins/dynamix.my.servers/scripts/gitflash_log"
# Define critical directories to check (POSIX-compliant, no arrays)
CRITICAL_DIRS="/usr/local/unraid-api
+/usr/local/unraid
/var/log/unraid-api
/usr/local/emhttp/plugins/dynamix.my.servers
/usr/local/emhttp/plugins/dynamix.unraid.net
@@ -159,6 +161,14 @@ else
SHUTDOWN_ERRORS=$((SHUTDOWN_ERRORS + 1))
fi
+# Check for unraid-core shutdown script
+if [ -x "/etc/rc.d/rc6.d/K30unraid-core" ]; then
+ printf '✓ Shutdown script for unraid-core exists and is executable\n'
+else
+ printf '✗ Shutdown script for unraid-core missing or not executable\n'
+ SHUTDOWN_ERRORS=$((SHUTDOWN_ERRORS + 1))
+fi
+
# Check for rc0.d symlink or directory
if [ -L "/etc/rc.d/rc0.d" ]; then
printf '✓ rc0.d symlink exists\n'
@@ -206,4 +216,4 @@ else
echo "Please review the errors above and contact support if needed."
# We don't exit with error as this is just a verification script
exit 0
-fi
\ No newline at end of file
+fi
diff --git a/web/__test__/components/SsoButton.test.ts b/web/__test__/components/SsoButton.test.ts
index c88c622ae0..92aacb2d89 100644
--- a/web/__test__/components/SsoButton.test.ts
+++ b/web/__test__/components/SsoButton.test.ts
@@ -386,6 +386,88 @@ describe('SsoButtons', () => {
expect(mockLocation.href).toBe(expectedUrl);
});
+ it('shows an error when code/state do not match stored state', async () => {
+ const mockProviders = [
+ {
+ id: 'unraid-net',
+ name: 'Unraid.net',
+ buttonText: 'Log In With Unraid.net',
+ },
+ ];
+
+ mockUseQuery.mockReturnValue({
+ result: { value: { publicOidcProviders: mockProviders } },
+ refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
+ });
+
+ (sessionStorage.getItem as Mock).mockReturnValue('expected-state');
+
+ mockLocation.search = '?code=mock_code&state=unexpected_state';
+ mockLocation.pathname = '/not-login';
+
+ const wrapper = mount(SsoButtons, {
+ global: {
+ plugins: [createTestI18n()],
+ stubs: {
+ SsoProviderButton: SsoProviderButtonStub,
+ Button: { template: '' },
+ },
+ },
+ });
+
+ await flushPromises();
+
+ const errorElement = wrapper.find('p.text-red-500');
+ expect(errorElement.exists()).toBe(true);
+ expect(errorElement.text()).toBe('Invalid callback parameters');
+ });
+
+ it('handles unexpected callback errors', async () => {
+ const mockProviders = [
+ {
+ id: 'unraid-net',
+ name: 'Unraid.net',
+ buttonText: 'Log In With Unraid.net',
+ },
+ ];
+
+ mockUseQuery.mockReturnValue({
+ result: { value: { publicOidcProviders: mockProviders } },
+ refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
+ });
+
+ const originalURLSearchParams = globalThis.URLSearchParams;
+ vi.stubGlobal(
+ 'URLSearchParams',
+ vi.fn(() => {
+ throw new Error('boom');
+ })
+ );
+
+ mockLocation.search = '';
+ mockLocation.hash = '';
+ mockLocation.pathname = '/login';
+
+ const wrapper = mount(SsoButtons, {
+ global: {
+ plugins: [createTestI18n()],
+ stubs: {
+ SsoProviderButton: SsoProviderButtonStub,
+ Button: { template: '' },
+ },
+ },
+ });
+
+ await flushPromises();
+
+ const errorElement = wrapper.find('p.text-red-500');
+ expect(errorElement.exists()).toBe(true);
+ expect(errorElement.text()).toBe('Error fetching token');
+ expect(mockForm.style.display).toBe('block');
+
+ vi.stubGlobal('URLSearchParams', originalURLSearchParams);
+ });
+
it('handles HTTPS with non-standard port correctly', async () => {
const mockProviders = [
{
diff --git a/web/src/components/sso/useSsoAuth.ts b/web/src/components/sso/useSsoAuth.ts
index 2b3d1f2573..1806b1fcd4 100644
--- a/web/src/components/sso/useSsoAuth.ts
+++ b/web/src/components/sso/useSsoAuth.ts
@@ -63,6 +63,8 @@ export function useSsoAuth() {
};
const navigateToProvider = (providerId: string) => {
+ currentState.value = 'loading';
+ error.value = null;
// Generate state token for CSRF protection
const state = generateStateToken();
@@ -85,7 +87,7 @@ export function useSsoAuth() {
const hashToken = hashParams.get('token');
const hashError = hashParams.get('error');
- // Then check query parameters (for OAuth code/state from provider redirects)
+ // Then check query parameters (for error/token fallback)
const search = new URLSearchParams(window.location.search);
const code = search.get('code') ?? '';
const state = search.get('state') ?? '';
@@ -129,6 +131,10 @@ export function useSsoAuth() {
currentState.value = 'error';
error.value = t('sso.useSsoAuth.invalidCallbackParameters');
}
+
+ if (window.location.pathname !== '/login') {
+ return;
+ }
} catch (err) {
console.error('Error fetching token', err);
currentState.value = 'error';
diff --git a/web/src/helpers/create-apollo-client.ts b/web/src/helpers/create-apollo-client.ts
index f3e0c0adaf..c45c5491a9 100644
--- a/web/src/helpers/create-apollo-client.ts
+++ b/web/src/helpers/create-apollo-client.ts
@@ -43,8 +43,11 @@ const wsEndpoint = new URL(httpEndpoint);
wsEndpoint.protocol = wsEndpoint.protocol === 'https:' ? 'wss:' : 'ws:';
const DEV_MODE = (globalThis as unknown as { __DEV__: boolean }).__DEV__ ?? false;
+const csrfToken = globalThis.csrf_token ?? '0000000000000000';
+wsEndpoint.searchParams.set('_csrf_token', csrfToken);
+
const headers = {
- 'x-csrf-token': globalThis.csrf_token ?? '0000000000000000',
+ 'x-csrf-token': csrfToken,
};
const httpLink = createHttpLink({