From 2a319001bc16cabfc937fb7a2d520e27e1837955 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 14 Jan 2026 16:40:21 -0500 Subject: [PATCH 1/9] chore: replace graphql server --- .../snapshots/rc.nginx.modified.snapshot | 2 +- .../modifications/patches/rc-nginx.patch | 13 +++ .../modifications/rc-nginx.modification.ts | 13 ++- plugin/plugins/dynamix.unraid.net.plg | 66 +++++++++++++ .../dynamix.unraid.net/etc/rc.d/rc.unraid | 88 +++++++++++++++++ .../etc/rc.d/rc6.d/K30unraid-core | 7 ++ .../dynamix.unraid.net/install/doinst.sh | 2 +- .../install/scripts/verify_install.sh | 12 ++- web/__test__/components/SsoButton.test.ts | 98 +------------------ web/src/components/sso/useSsoAuth.ts | 48 +-------- 10 files changed, 202 insertions(+), 147 deletions(-) create mode 100755 plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid create mode 100644 plugin/source/dynamix.unraid.net/etc/rc.d/rc6.d/K30unraid-core 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..cdab15b5d8 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 @@ -420,7 +420,7 @@ build_locations(){ location /graphql { allow all; error_log /dev/null crit; - proxy_pass http://unix:/var/run/unraid-api.sock:/graphql; + proxy_pass http://unix:/var/run/unraid-core.sock:/graphql; 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..9eea949545 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,6 +44,19 @@ Index: /etc/rc.d/rc.nginx T=' ' if check && [[ $1 == lo ]]; then if [[ $IPV4 == yes ]]; then +@@ -400,11 +418,11 @@ + # my servers proxy + # + location /graphql { + allow all; + error_log /dev/null crit; +- proxy_pass http://unix:/var/run/unraid-api.sock:/graphql; ++ proxy_pass http://unix:/var/run/unraid-core.sock:/graphql; + 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 +584,11 @@ # extract common name from cert CERTNAME=$(openssl x509 -noout -subject -nameopt multiline -in $CERTPATH | sed -n 's/ *commonName *= //p') 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..1e21880152 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 @@ -29,9 +29,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 +68,11 @@ 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;', + 'proxy_pass http://unix:/var/run/unraid-core.sock:/graphql;' + ); + newContent = newContent.replace( 'for NET in ${!NET_FQDN6[@]}; do', 'for NET in "${!NET_FQDN6[@]}"; do' @@ -91,7 +96,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/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 6e8a60e695..da2b61b70c 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 @@ -599,6 +658,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..d7c35b4abd --- /dev/null +++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid @@ -0,0 +1,88 @@ +#!/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}" + +# 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() { + if [ -d "/usr/local/unraid.prev" ]; then + echo "Rolling back to previous version..." + stop + rm -rf /usr/local/unraid + mv /usr/local/unraid.prev /usr/local/unraid + start + echo "Rollback complete" + else + echo "No previous version available" + exit 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..cf3f7cd7ac 100644 --- a/web/__test__/components/SsoButton.test.ts +++ b/web/__test__/components/SsoButton.test.ts @@ -253,14 +253,7 @@ describe('SsoButtons', () => { const button = wrapper.find('button'); await button.trigger('click'); - // Should set state and provider in sessionStorage - expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_state', expect.any(String)); - expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_provider', 'unraid-net'); - - const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; - const redirectUri = `${mockLocation.origin}/graphql/api/auth/oidc/callback`; - const expectedUrl = `/graphql/api/auth/oidc/authorize/unraid-net?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; - + const expectedUrl = `/auth/sso/unraid-net`; expect(mockLocation.href).toBe(expectedUrl); }); @@ -349,95 +342,6 @@ describe('SsoButtons', () => { expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', expectedUrl); }); - it('redirects to OIDC callback endpoint when code and state are present', 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 mockCode = 'mock_auth_code'; - const mockState = 'mock_session_state_value'; - - mockLocation.search = `?code=${mockCode}&state=${mockState}`; - mockLocation.pathname = '/login'; - - mount(SsoButtons, { - global: { - plugins: [createTestI18n()], - stubs: { - SsoProviderButton: SsoProviderButtonStub, - Button: { template: '' }, - }, - }, - }); - - await flushPromises(); - - // Should redirect to the OIDC callback endpoint - const expectedUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(mockCode)}&state=${encodeURIComponent(mockState)}`; - expect(mockLocation.href).toBe(expectedUrl); - }); - - it('handles HTTPS with non-standard port correctly', async () => { - const mockProviders = [ - { - id: 'tsidp', - name: 'Tailscale IDP', - buttonText: 'Sign in with Tailscale', - buttonIcon: null, - buttonVariant: 'secondary', - buttonStyle: null, - }, - ]; - - // Set up location with HTTPS and non-standard port - mockLocation.protocol = 'https:'; - mockLocation.host = 'unraid.mytailnet.ts.net:1443'; - mockLocation.origin = 'https://unraid.mytailnet.ts.net:1443'; - - mockUseQuery.mockReturnValue({ - result: { value: { publicOidcProviders: mockProviders } }, - refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), - }); - - const wrapper = mount(SsoButtons, { - global: { - plugins: [createTestI18n()], - stubs: { - SsoProviderButton: SsoProviderButtonStub, - Button: { template: '' }, - }, - }, - }); - - await flushPromises(); - vi.runAllTimers(); - await flushPromises(); - - const button = wrapper.find('button'); - await button.trigger('click'); - - // Should include the correct redirect URI with HTTPS and port 1443 - const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; - const redirectUri = 'https://unraid.mytailnet.ts.net:1443/graphql/api/auth/oidc/callback'; - const expectedUrl = `/graphql/api/auth/oidc/authorize/tsidp?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; - - expect(mockLocation.href).toBe(expectedUrl); - - // Reset location mock for other tests - mockLocation.protocol = 'http:'; - mockLocation.host = 'mock-origin.com'; - mockLocation.origin = 'http://mock-origin.com'; - }); - it('handles multiple OIDC providers', async () => { const mockProviders = [ { diff --git a/web/src/components/sso/useSsoAuth.ts b/web/src/components/sso/useSsoAuth.ts index 2b3d1f2573..573bfa45ab 100644 --- a/web/src/components/sso/useSsoAuth.ts +++ b/web/src/components/sso/useSsoAuth.ts @@ -35,19 +35,6 @@ export function useSsoAuth() { form.requestSubmit(); }; - const getStateToken = (): string | null => { - const state = sessionStorage.getItem('sso_state'); - return state ?? null; - }; - - const generateStateToken = (): string => { - const array = new Uint8Array(32); - window.crypto.getRandomValues(array); - const state = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); - sessionStorage.setItem('sso_state', state); - return state; - }; - const disableFormOnSubmit = () => { const fields = getInputFields(); if (fields?.form) { @@ -63,19 +50,9 @@ export function useSsoAuth() { }; const navigateToProvider = (providerId: string) => { - // Generate state token for CSRF protection - const state = generateStateToken(); - - // Store provider ID separately since state must be alphanumeric only - sessionStorage.setItem('sso_state', state); - sessionStorage.setItem('sso_provider', providerId); - - // Build the redirect URI based on current window location - const redirectUri = `${window.location.protocol}//${window.location.host}/graphql/api/auth/oidc/callback`; - - // Redirect to OIDC authorization endpoint with state token and redirect URI - const authUrl = `/graphql/api/auth/oidc/authorize/${encodeURIComponent(providerId)}?state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}`; - window.location.href = authUrl; + currentState.value = 'loading'; + error.value = null; + window.location.href = `/auth/sso/${encodeURIComponent(providerId)}`; }; const handleOAuthCallback = async () => { @@ -85,11 +62,8 @@ 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') ?? ''; - const sessionState = getStateToken(); // Check for error in hash (preferred) or query params (fallback) const errorParam = hashError || search.get('error') || ''; @@ -114,21 +88,9 @@ export function useSsoAuth() { return; } - // Handle Unraid.net SSO callback (comes to /login with code and state) - if (code && state && window.location.pathname === '/login') { - currentState.value = 'loading'; - - // Redirect to our OIDC callback endpoint to exchange the code - const callbackUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`; - window.location.href = callbackUrl; + if (window.location.pathname !== '/login') { return; } - - // Error if we have mismatched state - if (code && state && state !== sessionState) { - currentState.value = 'error'; - error.value = t('sso.useSsoAuth.invalidCallbackParameters'); - } } catch (err) { console.error('Error fetching token', err); currentState.value = 'error'; From 537f78350cea9661443d88ffbed1c912f41f532e Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 05:31:56 -0500 Subject: [PATCH 2/9] chore: update core_ release --- plugin/plugins/dynamix.unraid.net.plg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index da2b61b70c..b767b27988 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -10,9 +10,9 @@ - - - + + + From f73919a156e34175aab05ccadb402c2cfdd98f48 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 06:42:02 -0500 Subject: [PATCH 3/9] fix: sso auth --- .../snapshots/rc.nginx.modified.snapshot | 11 ++++++++ .../modifications/patches/rc-nginx.patch | 28 +++++++++++++++++-- .../modifications/rc-nginx.modification.ts | 8 ++++++ plugin/plugins/dynamix.unraid.net.plg | 2 +- .../dynamix.unraid.net/etc/rc.d/rc.unraid | 1 + 5 files changed, 46 insertions(+), 4 deletions(-) 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 cdab15b5d8..4064118890 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; 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 9eea949545..d615cd2d9a 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,29 @@ Index: /etc/rc.d/rc.nginx T=' ' if check && [[ $1 == lo ]]; then if [[ $IPV4 == yes ]]; then -@@ -400,11 +418,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; +@@ -400,11 +429,11 @@ # my servers proxy # location /graphql { @@ -57,7 +79,7 @@ Index: /etc/rc.d/rc.nginx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_cache_bypass $http_upgrade; -@@ -566,11 +584,11 @@ +@@ -566,11 +595,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 @@ -70,7 +92,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 +689,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/rc-nginx.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts index 1e21880152..c3f83c603f 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 @@ -73,6 +73,14 @@ check_remote_access(){ 'proxy_pass http://unix:/var/run/unraid-core.sock:/graphql;' ); + 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` + ); + } + newContent = newContent.replace( 'for NET in ${!NET_FQDN6[@]}; do', 'for NET in "${!NET_FQDN6[@]}"; do' diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index b767b27988..dfc2f11285 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -10,7 +10,7 @@ - + diff --git a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid index d7c35b4abd..80f636e20c 100755 --- a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid +++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid @@ -29,6 +29,7 @@ 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" From 9ddae2e3adc5b099572ebef5c42432efc3b3e1f6 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 08:25:41 -0500 Subject: [PATCH 4/9] fix: sso compat --- .../modifications/patches/rc-nginx.patch | 23 ++++- .../modifications/rc-nginx.modification.ts | 10 +- web/__test__/components/SsoButton.test.ts | 98 ++++++++++++++++++- web/src/components/sso/useSsoAuth.ts | 46 ++++++++- web/src/helpers/create-apollo-client.ts | 5 +- 5 files changed, 174 insertions(+), 8 deletions(-) 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 d615cd2d9a..acbd35f4b6 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 @@ -66,20 +66,35 @@ Index: /etc/rc.d/rc.nginx error_page 401 @401; location @401 { return 302 $scheme://$http_host/login; -@@ -400,11 +429,11 @@ +@@ -397,14 +426,26 @@ + nchan_stub_status; + } + # # my servers proxy # ++ 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; -+ proxy_pass http://unix:/var/run/unraid-core.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 +595,11 @@ +@@ -566,11 +607,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 @@ -92,7 +107,7 @@ Index: /etc/rc.d/rc.nginx WANIP6=$(curl https://wanip6.unraid.net/ 2>/dev/null) fi if [[ $CERTNAME == *\.myunraid\.net ]]; then -@@ -660,14 +689,14 @@ +@@ -660,14 +701,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/rc-nginx.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts index c3f83c603f..28d396f890 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 @@ -70,7 +70,7 @@ check_remote_access(){ newContent = newContent.replace( 'proxy_pass http://unix:/var/run/unraid-api.sock:/graphql;', - 'proxy_pass http://unix:/var/run/unraid-core.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')) { @@ -81,6 +81,14 @@ check_remote_access(){ ); } + if (!newContent.includes('location /graphql/api')) { + newContent = newContent.replace( + '\t# my servers proxy\n\t#\n\tlocation /graphql {', + // prettier-ignore + `\t# my servers proxy\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' diff --git a/web/__test__/components/SsoButton.test.ts b/web/__test__/components/SsoButton.test.ts index cf3f7cd7ac..c88c622ae0 100644 --- a/web/__test__/components/SsoButton.test.ts +++ b/web/__test__/components/SsoButton.test.ts @@ -253,7 +253,14 @@ describe('SsoButtons', () => { const button = wrapper.find('button'); await button.trigger('click'); - const expectedUrl = `/auth/sso/unraid-net`; + // Should set state and provider in sessionStorage + expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_state', expect.any(String)); + expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_provider', 'unraid-net'); + + const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; + const redirectUri = `${mockLocation.origin}/graphql/api/auth/oidc/callback`; + const expectedUrl = `/graphql/api/auth/oidc/authorize/unraid-net?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + expect(mockLocation.href).toBe(expectedUrl); }); @@ -342,6 +349,95 @@ describe('SsoButtons', () => { expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', expectedUrl); }); + it('redirects to OIDC callback endpoint when code and state are present', 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 mockCode = 'mock_auth_code'; + const mockState = 'mock_session_state_value'; + + mockLocation.search = `?code=${mockCode}&state=${mockState}`; + mockLocation.pathname = '/login'; + + mount(SsoButtons, { + global: { + plugins: [createTestI18n()], + stubs: { + SsoProviderButton: SsoProviderButtonStub, + Button: { template: '' }, + }, + }, + }); + + await flushPromises(); + + // Should redirect to the OIDC callback endpoint + const expectedUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(mockCode)}&state=${encodeURIComponent(mockState)}`; + expect(mockLocation.href).toBe(expectedUrl); + }); + + it('handles HTTPS with non-standard port correctly', async () => { + const mockProviders = [ + { + id: 'tsidp', + name: 'Tailscale IDP', + buttonText: 'Sign in with Tailscale', + buttonIcon: null, + buttonVariant: 'secondary', + buttonStyle: null, + }, + ]; + + // Set up location with HTTPS and non-standard port + mockLocation.protocol = 'https:'; + mockLocation.host = 'unraid.mytailnet.ts.net:1443'; + mockLocation.origin = 'https://unraid.mytailnet.ts.net:1443'; + + mockUseQuery.mockReturnValue({ + result: { value: { publicOidcProviders: mockProviders } }, + refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), + }); + + const wrapper = mount(SsoButtons, { + global: { + plugins: [createTestI18n()], + stubs: { + SsoProviderButton: SsoProviderButtonStub, + Button: { template: '' }, + }, + }, + }); + + await flushPromises(); + vi.runAllTimers(); + await flushPromises(); + + const button = wrapper.find('button'); + await button.trigger('click'); + + // Should include the correct redirect URI with HTTPS and port 1443 + const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; + const redirectUri = 'https://unraid.mytailnet.ts.net:1443/graphql/api/auth/oidc/callback'; + const expectedUrl = `/graphql/api/auth/oidc/authorize/tsidp?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + + expect(mockLocation.href).toBe(expectedUrl); + + // Reset location mock for other tests + mockLocation.protocol = 'http:'; + mockLocation.host = 'mock-origin.com'; + mockLocation.origin = 'http://mock-origin.com'; + }); + it('handles multiple OIDC providers', async () => { const mockProviders = [ { diff --git a/web/src/components/sso/useSsoAuth.ts b/web/src/components/sso/useSsoAuth.ts index 573bfa45ab..1806b1fcd4 100644 --- a/web/src/components/sso/useSsoAuth.ts +++ b/web/src/components/sso/useSsoAuth.ts @@ -35,6 +35,19 @@ export function useSsoAuth() { form.requestSubmit(); }; + const getStateToken = (): string | null => { + const state = sessionStorage.getItem('sso_state'); + return state ?? null; + }; + + const generateStateToken = (): string => { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + const state = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); + sessionStorage.setItem('sso_state', state); + return state; + }; + const disableFormOnSubmit = () => { const fields = getInputFields(); if (fields?.form) { @@ -52,7 +65,19 @@ export function useSsoAuth() { const navigateToProvider = (providerId: string) => { currentState.value = 'loading'; error.value = null; - window.location.href = `/auth/sso/${encodeURIComponent(providerId)}`; + // Generate state token for CSRF protection + const state = generateStateToken(); + + // Store provider ID separately since state must be alphanumeric only + sessionStorage.setItem('sso_state', state); + sessionStorage.setItem('sso_provider', providerId); + + // Build the redirect URI based on current window location + const redirectUri = `${window.location.protocol}//${window.location.host}/graphql/api/auth/oidc/callback`; + + // Redirect to OIDC authorization endpoint with state token and redirect URI + const authUrl = `/graphql/api/auth/oidc/authorize/${encodeURIComponent(providerId)}?state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + window.location.href = authUrl; }; const handleOAuthCallback = async () => { @@ -64,6 +89,9 @@ export function useSsoAuth() { // 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') ?? ''; + const sessionState = getStateToken(); // Check for error in hash (preferred) or query params (fallback) const errorParam = hashError || search.get('error') || ''; @@ -88,6 +116,22 @@ export function useSsoAuth() { return; } + // Handle Unraid.net SSO callback (comes to /login with code and state) + if (code && state && window.location.pathname === '/login') { + currentState.value = 'loading'; + + // Redirect to our OIDC callback endpoint to exchange the code + const callbackUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`; + window.location.href = callbackUrl; + return; + } + + // Error if we have mismatched state + if (code && state && state !== sessionState) { + currentState.value = 'error'; + error.value = t('sso.useSsoAuth.invalidCallbackParameters'); + } + if (window.location.pathname !== '/login') { return; } 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({ From f83aa5cf1306fda1d4a47fd829ea1dab68f4b38e Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 08:27:50 -0500 Subject: [PATCH 5/9] chore: update unraid_core --- plugin/plugins/dynamix.unraid.net.plg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index dfc2f11285..6e795a94b3 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -10,7 +10,7 @@ - + From 9557998bb3561f670e73066dc215e91fd25554ea Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 08:35:58 -0500 Subject: [PATCH 6/9] test: update rc.nginx snapshot --- .../__test__/snapshots/rc.nginx.modified.snapshot | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 4064118890..3b24e4777c 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 @@ -428,10 +428,22 @@ build_locations(){ # # my servers proxy # + 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-core.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; From 84df97d174eb94840d260edca0a0f53c516b2755 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 20 Jan 2026 14:30:18 -0500 Subject: [PATCH 7/9] chore: address pr comments --- .../.login.php.modified.snapshot.php | 48 +++++++---- .../snapshots/rc.nginx.modified.snapshot | 9 ++ .../modifications/rc-nginx.modification.ts | 10 ++- .../modifications/sso.modification.ts | 55 +++++++++---- plugin/plugins/dynamix.unraid.net.plg | 12 +++ .../dynamix.unraid.net/etc/rc.d/rc.unraid | 28 ++++++- web/__test__/components/SsoButton.test.ts | 82 +++++++++++++++++++ 7 files changed, 205 insertions(+), 39 deletions(-) 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..ef0bc9658a 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,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) { 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 3b24e4777c..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 @@ -428,6 +428,15 @@ 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:; 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 28d396f890..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; @@ -81,11 +83,11 @@ check_remote_access(){ ); } - if (!newContent.includes('location /graphql/api')) { + 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 {\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 {` + `\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 {` ); } 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 6e795a94b3..e6fb8637d5 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -463,6 +463,9 @@ compare_versions() { return $result } +# Track whether we updated the API package during this run. +API_PKG_INSTALLED=0 + # Check if API is already installed and get its version CURRENT_API_VERSION="" if [ -f "/usr/local/share/dynamix.unraid.net/config/vendor_archive.json" ] && command -v jq >/dev/null 2>&1; then @@ -542,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" @@ -572,6 +576,10 @@ if [ -f "$CORE_PKG_FILE" ]; then 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 @@ -580,6 +588,10 @@ if [ -f "$CORE_PKG_FILE" ]; then 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 diff --git a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid index 80f636e20c..49c881a867 100755 --- a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid +++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid @@ -66,16 +66,36 @@ status() { } rollback() { - if [ -d "/usr/local/unraid.prev" ]; then + 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 - rm -rf /usr/local/unraid - mv /usr/local/unraid.prev /usr/local/unraid + 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" - exit 1 + return 1 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 = [ { From d77b2b7e21317f544e7de2351a424051545b7cac Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 20 Jan 2026 15:13:23 -0500 Subject: [PATCH 8/9] chore: bump core package to 2026.01.20.1 --- .../.login.php.modified.snapshot.php | 5 +- .../snapshots/rc.nginx.modified.snapshot | 9 ---- .../modifications/patches/rc-nginx.patch | 15 ++++-- .../modifications/patches/sso.patch | 53 +++++++++++++------ plugin/plugins/dynamix.unraid.net.plg | 6 +-- 5 files changed, 54 insertions(+), 34 deletions(-) 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 ef0bc9658a..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 @@ -35,14 +35,15 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool $context = stream_context_create([ "http" => [ "method" => "POST", - "header" => "Content-Type: application/json\r\n", + "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+).*/', '$1', $http_response_header[0]); + $code = (int) preg_replace('/^HTTP\/[0-9.]+\s+(\d+).*/', '', $http_response_header[0]); } } my_logger("SSO Login Attempt Code: $code"); 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 b1f68cd327..3b24e4777c 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 @@ -428,15 +428,6 @@ 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:; 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 acbd35f4b6..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 @@ -66,12 +66,21 @@ Index: /etc/rc.d/rc.nginx error_page 401 @401; location @401 { return 302 $scheme://$http_host/login; -@@ -397,14 +426,26 @@ +@@ -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:; @@ -94,7 +103,7 @@ Index: /etc/rc.d/rc.nginx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_cache_bypass $http_upgrade; -@@ -566,11 +607,11 @@ +@@ -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 @@ -107,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 +701,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/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index e6fb8637d5..5924f99743 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -10,9 +10,9 @@ - - - + + + From 8fdb128e2328d7eddae6b634315003f81fc66a8b Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 20 Jan 2026 15:34:42 -0500 Subject: [PATCH 9/9] test: update file mod snapshots --- .../__test__/snapshots/rc.nginx.modified.snapshot | 9 +++++++++ 1 file changed, 9 insertions(+) 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 3b24e4777c..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 @@ -428,6 +428,15 @@ 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:;