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 @@

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({