From 6041c47b8cf1fadce5128fa07380c9b7ee2314c3 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 24 Oct 2026 22:54:22 -0400 Subject: [PATCH 01/16] fix(connect): clear persisted keys on sign-out and removal OS-1074 --- .../__test__/connect.config.service.test.ts | 67 +++++++++++++++++++ .../src/config/connect.config.service.ts | 30 +++++++-- .../connect-settings.resolver.ts | 2 +- plugin/plugins/dynamix.unraid.net.plg | 26 +++++++ 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts new file mode 100644 index 0000000000..3c1e857d8d --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -0,0 +1,67 @@ +import { ConfigService } from '@nestjs/config'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { emptyMyServersConfig } from '../config/connect.config.js'; +import { ConnectConfigService } from '../config/connect.config.service.js'; + +describe('ConnectConfigService sign-out', () => { + const temporaryDirectories: string[] = []; + + afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ); + }); + + it('persists the cleared identity and removes the legacy source of the key', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await writeFile(legacyPath, '[remote]\napikey="legacy-key"\n'); + const configService = new ConfigService({ + connect: { + config: { + ...emptyMyServersConfig(), + apikey: 'current-key', + username: 'signed-in-user', + wanaccess: true, + wanport: 33443, + }, + }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: legacyPath, + }); + + await new ConnectConfigService(configService).resetUser(); + + const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); + expect(persisted.apikey).toBe(''); + expect(persisted.username).toBe(''); + expect(persisted.wanaccess).toBe(true); + expect(persisted.wanport).toBe(33443); + await expect(readFile(legacyPath)).rejects.toThrow(); + }); + + it('keeps the legacy config when the cleared identity cannot be persisted', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await writeFile(legacyPath, '[remote]\napikey="legacy-key"\n'); + const blockedPath = join(directory, 'blocked'); + await writeFile(blockedPath, 'not a directory'); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: blockedPath, + PATHS_MY_SERVERS_CONFIG: legacyPath, + }); + + await expect(new ConnectConfigService(configService).resetUser()).rejects.toThrow(); + expect(await readFile(legacyPath, 'utf8')).toContain('legacy-key'); + }); +}); diff --git a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts index 72d820462a..c08b4e198c 100644 --- a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts +++ b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts @@ -1,8 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OnEvent } from '@nestjs/event-emitter'; +import { unlink } from 'node:fs/promises'; + +import { isMatch } from 'lodash-es'; import { EVENTS } from '../helper/nest-tokens.js'; +import { ConnectConfigPersister } from './config.persistence.js'; import { ConfigType, emptyMyServersConfig, MyServersConfig } from './connect.config.js'; @Injectable() @@ -40,18 +44,36 @@ export class ConnectConfigService { * This is used when the user logs out. * It retains the existing config, but resets identity-related fields. */ - resetUser() { + async resetUser() { // overwrite identity fields, but retain destructured fields const { wanaccess, wanport, upnpEnabled, ...identity } = emptyMyServersConfig(); - this.configService.set(this.configKey, { + const config = { ...this.getConfig(), ...identity, - }); + }; + this.configService.set(this.configKey, config); + + const persister = new ConnectConfigPersister(this.configService); + await persister.persist(config); + const savedConfig = await persister.getFileHandler().readConfigFile(); + if (!isMatch(savedConfig, identity)) { + throw new Error('Connect sign-out could not persist cleared identity'); + } + + const legacyPath = this.configService.get( + 'PATHS_MY_SERVERS_CONFIG', + '/boot/config/plugins/dynamix.my.servers/myservers.cfg' + ); + if (legacyPath) { + await unlink(legacyPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + } this.logger.verbose('Reset Connect user identity'); } @OnEvent(EVENTS.LOGOUT, { async: true }) async onLogout() { - this.resetUser(); + await this.resetUser(); } } diff --git a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts index 75d820fa3f..025b518bb4 100644 --- a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts @@ -118,7 +118,7 @@ export class ConnectSettingsResolver { resource: Resource.CONNECT, }) public async connectSignOut() { - this.eventEmitter.emit(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API' }); + await this.eventEmitter.emitAsync(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API' }); return true; } diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 65130f605b..46c3a60ece 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -267,6 +267,32 @@ exit 0 MAINNAME="&name;" /dev/null 2>&1; then + unraid-api stop || true + fi + + if pgrep -f '[n]ode /usr/local/unraid-api/dist/main.js' >/dev/null 2>&1; then + echo "Error: Unraid API is still running; Connect credentials were not removed" + exit 1 + fi + + rm -f /boot/config/plugins/dynamix.my.servers/configs/connect.json \ + /boot/config/plugins/dynamix.my.servers/myservers.cfg || exit 1 + if [ -x /etc/rc.d/rc.nginx ]; then + /etc/rc.d/rc.nginx reload || { + echo "Error: Could not reload nginx after clearing Connect credentials" + exit 1 + } + fi + fi # Check Unraid version UNRAID_VERSION="" From f6657945279df7971521723eaa20975220904552 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 24 Oct 2026 23:32:45 -0400 Subject: [PATCH 02/16] fix(connect): await persisted sign-out before returning --- .../__test__/connect.config.service.test.ts | 92 ++++++++++++++++++- .../src/config/connect.config.service.ts | 3 +- .../connect-settings.resolver.ts | 10 +- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index 3c1e857d8d..ba2cdd79ff 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -1,12 +1,16 @@ import { ConfigService } from '@nestjs/config'; +import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; +import { Test } from '@nestjs/testing'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { emptyMyServersConfig } from '../config/connect.config.js'; import { ConnectConfigService } from '../config/connect.config.service.js'; +import { ConnectSettingsResolver } from '../unraid-connect/connect-settings.resolver.js'; +import { ConnectSettingsService } from '../unraid-connect/connect-settings.service.js'; describe('ConnectConfigService sign-out', () => { const temporaryDirectories: string[] = []; @@ -64,4 +68,90 @@ describe('ConnectConfigService sign-out', () => { await expect(new ConnectConfigService(configService).resetUser()).rejects.toThrow(); expect(await readFile(legacyPath, 'utf8')).toContain('legacy-key'); }); + + it('finishes disk cleanup before the sign-out mutation succeeds', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await writeFile(legacyPath, '[remote]\napikey="legacy-key"\n'); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: legacyPath, + }); + const module = await Test.createTestingModule({ + imports: [EventEmitterModule.forRoot()], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: ConnectSettingsService, useValue: {} }, + { + provide: ConnectConfigService, + useFactory: () => new ConnectConfigService(configService), + }, + { + provide: ConnectSettingsResolver, + useFactory: ( + settings: ConnectSettingsService, + emitter: EventEmitter2, + connectConfig: ConnectConfigService + ) => new ConnectSettingsResolver(settings, emitter, connectConfig), + inject: [ConnectSettingsService, EventEmitter2, ConnectConfigService], + }, + ], + }).compile(); + await module.init(); + + try { + const resetUser = vi.spyOn(module.get(ConnectConfigService), 'resetUser'); + await expect(module.get(ConnectSettingsResolver).connectSignOut()).resolves.toBe(true); + expect(resetUser).toHaveBeenCalledTimes(1); + const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); + expect(persisted.apikey).toBe(''); + await expect(readFile(legacyPath)).rejects.toThrow(); + } finally { + await module.close(); + } + }); + + it('rejects the sign-out mutation when disk cleanup fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await writeFile(legacyPath, '[remote]\napikey="legacy-key"\n'); + const blockedPath = join(directory, 'blocked'); + await writeFile(blockedPath, 'not a directory'); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: blockedPath, + PATHS_MY_SERVERS_CONFIG: legacyPath, + }); + const module = await Test.createTestingModule({ + imports: [EventEmitterModule.forRoot()], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: ConnectSettingsService, useValue: {} }, + { + provide: ConnectConfigService, + useFactory: () => new ConnectConfigService(configService), + }, + { + provide: ConnectSettingsResolver, + useFactory: ( + settings: ConnectSettingsService, + emitter: EventEmitter2, + connectConfig: ConnectConfigService + ) => new ConnectSettingsResolver(settings, emitter, connectConfig), + inject: [ConnectSettingsService, EventEmitter2, ConnectConfigService], + }, + ], + }).compile(); + await module.init(); + + try { + await expect(module.get(ConnectSettingsResolver).connectSignOut()).rejects.toThrow(); + expect(await readFile(legacyPath, 'utf8')).toContain('legacy-key'); + } finally { + await module.close(); + } + }); }); diff --git a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts index c08b4e198c..5bc32e1536 100644 --- a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts +++ b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts @@ -73,7 +73,8 @@ export class ConnectConfigService { } @OnEvent(EVENTS.LOGOUT, { async: true }) - async onLogout() { + async onLogout({ configResetCompleted = false }: { configResetCompleted?: boolean } = {}) { + if (configResetCompleted) return; await this.resetUser(); } } diff --git a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts index 025b518bb4..0774ad0b35 100644 --- a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts @@ -9,6 +9,7 @@ import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { GraphQLJSON } from 'graphql-scalars'; +import { ConnectConfigService } from '../config/connect.config.service.js'; import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectSettingsService } from './connect-settings.service.js'; import { @@ -28,7 +29,8 @@ export class ConnectSettingsResolver { constructor( private readonly connectSettingsService: ConnectSettingsService, - private readonly eventEmitter: EventEmitter2 + private readonly eventEmitter: EventEmitter2, + private readonly connectConfigService: ConnectConfigService ) {} @UsePermissions({ @@ -118,7 +120,11 @@ export class ConnectSettingsResolver { resource: Resource.CONNECT, }) public async connectSignOut() { - await this.eventEmitter.emitAsync(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API' }); + await this.connectConfigService.resetUser(); + await this.eventEmitter.emitAsync(EVENTS.LOGOUT, { + reason: 'Manual Sign Out Using API', + configResetCompleted: true, + }); return true; } From 7955a2b745abde06863140c99667bc39c49e96f7 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 24 Oct 2026 23:49:07 -0400 Subject: [PATCH 03/16] fix(connect): keep sign-out state consistent and verify removal --- .../src/__test__/connect.config.service.test.ts | 4 ++++ .../src/config/connect.config.service.ts | 8 +++++--- plugin/plugins/dynamix.unraid.net.plg | 10 ++++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index ba2cdd79ff..2dbec3624d 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -34,6 +34,7 @@ describe('ConnectConfigService sign-out', () => { ...emptyMyServersConfig(), apikey: 'current-key', username: 'signed-in-user', + email: 'signed-in@example.com', wanaccess: true, wanport: 33443, }, @@ -47,6 +48,8 @@ describe('ConnectConfigService sign-out', () => { const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); expect(persisted.apikey).toBe(''); expect(persisted.username).toBe(''); + expect(persisted.email).toBe(''); + expect(configService.get('connect.config.email')).toBe(''); expect(persisted.wanaccess).toBe(true); expect(persisted.wanport).toBe(33443); await expect(readFile(legacyPath)).rejects.toThrow(); @@ -67,6 +70,7 @@ describe('ConnectConfigService sign-out', () => { await expect(new ConnectConfigService(configService).resetUser()).rejects.toThrow(); expect(await readFile(legacyPath, 'utf8')).toContain('legacy-key'); + expect(configService.get('connect.config.apikey')).toBe('current-key'); }); it('finishes disk cleanup before the sign-out mutation succeeds', async () => { diff --git a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts index 5bc32e1536..124dcc5e81 100644 --- a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts +++ b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts @@ -46,13 +46,14 @@ export class ConnectConfigService { */ async resetUser() { // overwrite identity fields, but retain destructured fields - const { wanaccess, wanport, upnpEnabled, ...identity } = emptyMyServersConfig(); + const { wanaccess, wanport, upnpEnabled, ...identity } = { + ...emptyMyServersConfig(), + email: '', + }; const config = { ...this.getConfig(), ...identity, }; - this.configService.set(this.configKey, config); - const persister = new ConnectConfigPersister(this.configService); await persister.persist(config); const savedConfig = await persister.getFileHandler().readConfigFile(); @@ -69,6 +70,7 @@ export class ConnectConfigService { if (error.code !== 'ENOENT') throw error; }); } + this.configService.set(this.configKey, config); this.logger.verbose('Reset Connect user identity'); } diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 46c3a60ece..a2f234f80f 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -279,8 +279,11 @@ exit 0 unraid-api stop || true fi - if pgrep -f '[n]ode /usr/local/unraid-api/dist/main.js' >/dev/null 2>&1; then - echo "Error: Unraid API is still running; Connect credentials were not removed" + # `unraid-api status` reports the PM2 app even when its worker command + # does not contain the application script path. An empty successful + # status is the stopped state on Unraid 7. + if ! api_status=$(unraid-api status 2>&1) || [ -n "$api_status" ]; then + echo "Error: Could not verify Unraid API stopped; Connect credentials were not removed" exit 1 fi @@ -322,6 +325,9 @@ exit 0 echo "Unraid 7.2+ detected. Using safe removal method." if ! /etc/rc.d/rc.unraid-api plugins remove unraid-api-plugin-connect -b; then echo "Warning: Failed to remove connect API plugin" + if ! /etc/rc.d/rc.unraid-api start; then + echo "Warning: Failed to restart Unraid API after Connect plugin removal failure" + fi fi # Send notification to user From c3f410ea8a9b8da58f7704f4f952767f05d4e378 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 25 Oct 2026 00:10:28 -0400 Subject: [PATCH 04/16] fix(plugin): scope uninstall PM2 check to API app --- plugin/plugins/dynamix.unraid.net.plg | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index a2f234f80f..e82c1902af 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -279,10 +279,13 @@ exit 0 unraid-api stop || true fi - # `unraid-api status` reports the PM2 app even when its worker command - # does not contain the application script path. An empty successful - # status is the stopped state on Unraid 7. - if ! api_status=$(unraid-api status 2>&1) || [ -n "$api_status" ]; then + # Inspect only the API app. `unraid-api status` lists every PM2 app, + # so an unrelated app must not block Connect credential removal. + if ! ( + set -o pipefail + PM2_HOME=/var/log/.pm2 /usr/local/unraid-api/node_modules/pm2/bin/pm2 --no-color jlist | + jq -e 'type == "array" and all(.[]; .name != "unraid-api")' >/dev/null + ) 2>/dev/null; then echo "Error: Could not verify Unraid API stopped; Connect credentials were not removed" exit 1 fi From 23c64f334ef78d146f688562537f5b13911aaabd Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 25 Oct 2026 00:30:35 -0400 Subject: [PATCH 05/16] fix(connect): prevent key restoration after legacy cleanup failure --- .../__test__/connect.config.service.test.ts | 29 ++++++++++++++++++- .../src/config/connect.config.service.ts | 4 ++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index 2dbec3624d..b6a254c0c4 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -1,7 +1,7 @@ import { ConfigService } from '@nestjs/config'; import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; import { Test } from '@nestjs/testing'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { emptyMyServersConfig } from '../config/connect.config.js'; import { ConnectConfigService } from '../config/connect.config.service.js'; +import { ConnectConfigPersister } from '../config/config.persistence.js'; import { ConnectSettingsResolver } from '../unraid-connect/connect-settings.resolver.js'; import { ConnectSettingsService } from '../unraid-connect/connect-settings.service.js'; @@ -73,6 +74,32 @@ describe('ConnectConfigService sign-out', () => { expect(configService.get('connect.config.apikey')).toBe('current-key'); }); + it('does not restore the key on shutdown when legacy cleanup fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await mkdir(legacyPath); + const configService = new ConfigService({ + connect: { + config: { + ...emptyMyServersConfig(), + apikey: 'current-key', + email: 'signed-in@example.com', + }, + }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: legacyPath, + }); + + await expect(new ConnectConfigService(configService).resetUser()).rejects.toThrow(); + expect(configService.get('connect.config.apikey')).toBe(''); + expect(configService.get('connect.config.email')).toBe(''); + await new ConnectConfigPersister(configService).onModuleDestroy(); + const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); + expect(persisted.apikey).toBe(''); + expect(persisted.email).toBe(''); + }); + it('finishes disk cleanup before the sign-out mutation succeeds', async () => { const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); temporaryDirectories.push(directory); diff --git a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts index 124dcc5e81..31aaa15b5d 100644 --- a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts +++ b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts @@ -60,6 +60,9 @@ export class ConnectConfigService { if (!isMatch(savedConfig, identity)) { throw new Error('Connect sign-out could not persist cleared identity'); } + // Keep memory aligned with the cleared file before legacy cleanup. If + // that cleanup fails, shutdown persistence must not restore the key. + this.configService.set(this.configKey, config); const legacyPath = this.configService.get( 'PATHS_MY_SERVERS_CONFIG', @@ -70,7 +73,6 @@ export class ConnectConfigService { if (error.code !== 'ENOENT') throw error; }); } - this.configService.set(this.configKey, config); this.logger.verbose('Reset Connect user identity'); } From df79bbb8259c0124b5fdaaa136f2cdfd7cf2771f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 25 Oct 2026 00:54:14 -0400 Subject: [PATCH 06/16] fix(connect): close relay after partial sign-out cleanup --- .../__test__/connect.config.service.test.ts | 31 ++++++++++++++++++- .../src/config/connect.config.service.ts | 9 +++++- .../src/mothership-proxy/mothership.events.ts | 7 ++--- .../connect-settings.resolver.ts | 20 ++++++++++-- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index b6a254c0c4..cd7bdbcec4 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -7,9 +7,10 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ConnectConfigPersister } from '../config/config.persistence.js'; import { emptyMyServersConfig } from '../config/connect.config.js'; import { ConnectConfigService } from '../config/connect.config.service.js'; -import { ConnectConfigPersister } from '../config/config.persistence.js'; +import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectSettingsResolver } from '../unraid-connect/connect-settings.resolver.js'; import { ConnectSettingsService } from '../unraid-connect/connect-settings.service.js'; @@ -185,4 +186,32 @@ describe('ConnectConfigService sign-out', () => { await module.close(); } }); + + it('tears down the relay when legacy cleanup fails after clearing the identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await mkdir(legacyPath); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: legacyPath, + }); + const emitter = new EventEmitter2(); + const emitAsync = vi.spyOn(emitter, 'emitAsync'); + const stopRelay = vi.fn().mockResolvedValue(undefined); + emitter.on(EVENTS.LOGOUT, stopRelay); + const resolver = new ConnectSettingsResolver( + {} as ConnectSettingsService, + emitter, + new ConnectConfigService(configService) + ); + + await expect(resolver.connectSignOut()).rejects.toThrow('legacy credential file'); + expect(emitAsync).toHaveBeenCalledWith(EVENTS.LOGOUT, expect.any(Object)); + expect(stopRelay).toHaveBeenCalledTimes(1); + expect(configService.get('connect.config.apikey')).toBe(''); + const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); + expect(persisted.apikey).toBe(''); + }); }); diff --git a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts index 31aaa15b5d..9a1a8d9200 100644 --- a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts +++ b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts @@ -9,6 +9,13 @@ import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectConfigPersister } from './config.persistence.js'; import { ConfigType, emptyMyServersConfig, MyServersConfig } from './connect.config.js'; +/** The identity was cleared, but removal of the legacy credential file failed. */ +export class ConnectLegacyCleanupError extends Error { + constructor(cause: unknown) { + super('Connect sign-out could not remove the legacy credential file', { cause }); + } +} + @Injectable() export class ConnectConfigService { public readonly configKey = 'connect.config'; @@ -70,7 +77,7 @@ export class ConnectConfigService { ); if (legacyPath) { await unlink(legacyPath).catch((error: NodeJS.ErrnoException) => { - if (error.code !== 'ENOENT') throw error; + if (error.code !== 'ENOENT') throw new ConnectLegacyCleanupError(error); }); } this.logger.verbose('Reset Connect user identity'); diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.events.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.events.ts index b7b4180a55..b4dac84179 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.events.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.events.ts @@ -30,10 +30,7 @@ export class MothershipHandler { @OnEvent(EVENTS.MOTHERSHIP_CONNECTION_STATUS_CHANGED, { async: true }) async onMothershipConnectionStatusChanged() { const state = this.connectionService.getConnectionState(); - if ( - state && - [MinigraphStatus.PING_FAILURE].includes(state.status) - ) { + if (state && [MinigraphStatus.PING_FAILURE].includes(state.status)) { this.logger.verbose( 'Mothership connection status changed to %s; setting up mothership subscription', state.status @@ -52,11 +49,11 @@ export class MothershipHandler { @OnEvent(EVENTS.LOGOUT, { async: true, prependListener: true }) async logout({ reason }: { reason?: string }) { this.logger.log('Logging out user: %s', reason ?? 'No reason provided'); + await this.mothershipController.stop(); // publish to the 'servers' and 'owner' endpoints await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.SERVERS, { servers: [] }); await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.OWNER, { owner: { username: 'root', url: '', avatar: '' }, }); - await this.mothershipController.stop(); } } diff --git a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts index 0774ad0b35..da30cd4eb6 100644 --- a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts @@ -9,7 +9,7 @@ import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { GraphQLJSON } from 'graphql-scalars'; -import { ConnectConfigService } from '../config/connect.config.service.js'; +import { ConnectConfigService, ConnectLegacyCleanupError } from '../config/connect.config.service.js'; import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectSettingsService } from './connect-settings.service.js'; import { @@ -120,7 +120,23 @@ export class ConnectSettingsResolver { resource: Resource.CONNECT, }) public async connectSignOut() { - await this.connectConfigService.resetUser(); + try { + await this.connectConfigService.resetUser(); + } catch (error) { + if (error instanceof ConnectLegacyCleanupError) { + // The cleared identity is already durable. Tear down the relay + // even though the stale legacy file still needs cleanup. + try { + await this.eventEmitter.emitAsync(EVENTS.LOGOUT, { + reason: 'Manual Sign Out Using API', + configResetCompleted: true, + }); + } finally { + throw error; + } + } + throw error; + } await this.eventEmitter.emitAsync(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API', configResetCompleted: true, From 461663a1a1a318b32e818b62d46523b4cf39842d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 25 Oct 2026 01:20:36 -0400 Subject: [PATCH 07/16] fix(connect): restore API alone on uninstall failure --- .../__test__/connect.config.service.test.ts | 2 +- plugin/plugins/dynamix.unraid.net.plg | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index cd7bdbcec4..4c8d63bddd 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -207,7 +207,7 @@ describe('ConnectConfigService sign-out', () => { new ConnectConfigService(configService) ); - await expect(resolver.connectSignOut()).rejects.toThrow('legacy credential file'); + await expect(resolver.connectSignOut()).rejects.toThrow(); expect(emitAsync).toHaveBeenCalledWith(EVENTS.LOGOUT, expect.any(Object)); expect(stopRelay).toHaveBeenCalledTimes(1); expect(configService.get('connect.config.apikey')).toBe(''); diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index e82c1902af..80bbaa65cf 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -268,6 +268,16 @@ exit 0 /dev/null ) 2>/dev/null; then echo "Error: Could not verify Unraid API stopped; Connect credentials were not removed" + restore_api exit 1 fi rm -f /boot/config/plugins/dynamix.my.servers/configs/connect.json \ - /boot/config/plugins/dynamix.my.servers/myservers.cfg || exit 1 + /boot/config/plugins/dynamix.my.servers/myservers.cfg || { + restore_api + exit 1 + } if [ -x /etc/rc.d/rc.nginx ]; then /etc/rc.d/rc.nginx reload || { echo "Error: Could not reload nginx after clearing Connect credentials" + restore_api exit 1 } fi @@ -328,9 +343,7 @@ exit 0 echo "Unraid 7.2+ detected. Using safe removal method." if ! /etc/rc.d/rc.unraid-api plugins remove unraid-api-plugin-connect -b; then echo "Warning: Failed to remove connect API plugin" - if ! /etc/rc.d/rc.unraid-api start; then - echo "Warning: Failed to restart Unraid API after Connect plugin removal failure" - fi + restore_api fi # Send notification to user From 6dd070e6b18982ca2752712ff962192a329c2db8 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 25 Oct 2026 01:36:22 -0400 Subject: [PATCH 08/16] fix(connect): reload nginx on uninstall retry --- plugin/plugins/dynamix.unraid.net.plg | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 80bbaa65cf..636cbee541 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -306,13 +306,16 @@ exit 0 restore_api exit 1 } - if [ -x /etc/rc.d/rc.nginx ]; then - /etc/rc.d/rc.nginx reload || { - echo "Error: Could not reload nginx after clearing Connect credentials" - restore_api - exit 1 - } - fi + fi + + # Retry the reload even if an earlier removal attempt already cleared + # credentials but failed before nginx accepted the new configuration. + if [ -x /etc/rc.d/rc.nginx ]; then + /etc/rc.d/rc.nginx reload || { + echo "Error: Could not reload nginx after clearing Connect credentials" + restore_api + exit 1 + } fi # Check Unraid version From 5ed83f932f32773c93c73ccf19a6aeeac776f973 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 25 Oct 2026 06:49:11 -0400 Subject: [PATCH 09/16] Revoke Connect cloud key before local sign-out and removal --- .../__test__/connect.config.service.test.ts | 57 +++++++++++++- .../src/config/connect.config.service.ts | 29 ++++++++ .../connect-settings.resolver.ts | 1 + plugin/plugins/dynamix.unraid.net.plg | 74 +++++++++++-------- 4 files changed, 129 insertions(+), 32 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index 4c8d63bddd..927c4e58ec 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -18,6 +18,7 @@ describe('ConnectConfigService sign-out', () => { const temporaryDirectories: string[] = []; afterEach(async () => { + vi.unstubAllGlobals(); await Promise.all( temporaryDirectories .splice(0) @@ -25,6 +26,30 @@ describe('ConnectConfigService sign-out', () => { ); }); + it('revokes the cloud key before local sign-out and keeps it on cloud failure', async () => { + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + MOTHERSHIP_GRAPHQL_LINK: 'https://mothership.unraid.net/ws', + }); + const service = new ConnectConfigService(configService); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ revoked: true }) }); + vi.stubGlobal('fetch', fetchMock); + + await service.revokeCloudKey(); + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://mothership.unraid.net/ks/server/self-unregister'), + expect.objectContaining({ + method: 'POST', + headers: { 'x-api-key': 'current-key' }, + redirect: 'error', + }) + ); + + fetchMock.mockResolvedValue({ ok: false }); + await expect(service.revokeCloudKey()).rejects.toThrow(); + expect(configService.get('connect.config.apikey')).toBe('current-key'); + }); + it('persists the cleared identity and removes the legacy source of the key', async () => { const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); temporaryDirectories.push(directory); @@ -57,6 +82,32 @@ describe('ConnectConfigService sign-out', () => { await expect(readFile(legacyPath)).rejects.toThrow(); }); + it('retains local credentials when cloud revocation fails during sign-out', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await writeFile(legacyPath, '[remote]\napikey="legacy-key"\n'); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: legacyPath, + }); + const emitter = new EventEmitter2(); + const connectConfig = new ConnectConfigService(configService); + vi.spyOn(connectConfig, 'revokeCloudKey').mockRejectedValue(new Error('cloud unavailable')); + const resetUser = vi.spyOn(connectConfig, 'resetUser'); + const resolver = new ConnectSettingsResolver( + {} as ConnectSettingsService, + emitter, + connectConfig + ); + + await expect(resolver.connectSignOut()).rejects.toThrow('cloud unavailable'); + expect(resetUser).not.toHaveBeenCalled(); + expect(configService.get('connect.config.apikey')).toBe('current-key'); + expect(await readFile(legacyPath, 'utf8')).toContain('legacy-key'); + }); + it('keeps the legacy config when the cleared identity cannot be persisted', async () => { const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); temporaryDirectories.push(directory); @@ -135,6 +186,7 @@ describe('ConnectConfigService sign-out', () => { try { const resetUser = vi.spyOn(module.get(ConnectConfigService), 'resetUser'); + vi.spyOn(module.get(ConnectConfigService), 'revokeCloudKey').mockResolvedValue(); await expect(module.get(ConnectSettingsResolver).connectSignOut()).resolves.toBe(true); expect(resetUser).toHaveBeenCalledTimes(1); const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); @@ -180,6 +232,7 @@ describe('ConnectConfigService sign-out', () => { await module.init(); try { + vi.spyOn(module.get(ConnectConfigService), 'revokeCloudKey').mockResolvedValue(); await expect(module.get(ConnectSettingsResolver).connectSignOut()).rejects.toThrow(); expect(await readFile(legacyPath, 'utf8')).toContain('legacy-key'); } finally { @@ -201,10 +254,12 @@ describe('ConnectConfigService sign-out', () => { const emitAsync = vi.spyOn(emitter, 'emitAsync'); const stopRelay = vi.fn().mockResolvedValue(undefined); emitter.on(EVENTS.LOGOUT, stopRelay); + const connectConfig = new ConnectConfigService(configService); + vi.spyOn(connectConfig, 'revokeCloudKey').mockResolvedValue(); const resolver = new ConnectSettingsResolver( {} as ConnectSettingsService, emitter, - new ConnectConfigService(configService) + connectConfig ); await expect(resolver.connectSignOut()).rejects.toThrow(); diff --git a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts index 9a1a8d9200..10dfe911a7 100644 --- a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts +++ b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts @@ -26,6 +26,35 @@ export class ConnectConfigService { return this.configService.getOrThrow(this.configKey); } + async revokeCloudKey(): Promise { + const apiKey = this.getConfig().apikey; + if (!apiKey) return; + + const mothership = new URL(this.configService.getOrThrow('MOTHERSHIP_GRAPHQL_LINK')); + if (mothership.protocol !== 'https:') { + throw new Error('Connect key revocation requires HTTPS'); + } + + const endpoint = new URL('/ks/server/self-unregister', mothership); + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'x-api-key': apiKey }, + redirect: 'error', + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error('Connect cloud key revocation failed'); + + const result: unknown = await response.json(); + if ( + !result || + typeof result !== 'object' || + !('revoked' in result) || + result.revoked !== true + ) { + throw new Error('Connect cloud key revocation was not confirmed'); + } + } + getExtraOrigins(): string[] { const extraOrigins = this.configService.get('store.config.api.extraOrigins'); if (extraOrigins) { diff --git a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts index da30cd4eb6..0c0b594475 100644 --- a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts @@ -120,6 +120,7 @@ export class ConnectSettingsResolver { resource: Resource.CONNECT, }) public async connectSignOut() { + await this.connectConfigService.revokeCloudKey(); try { await this.connectConfigService.resetUser(); } catch (error) { diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 636cbee541..2ee232578d 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -120,38 +120,25 @@ perform_connect_cleanup() { echo "⚠️ Warning: Neither /etc/rc.d/rc.unraid-api nor unraid-api command found" fi - # Sign out of Unraid Connect (we'll use curl directly from shell) - # We need to extract the username from connect.json or myservers.cfg and the registration key - has_username=false - - # Check connect.json first (newer format) - if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] && command -v jq >/dev/null 2>&1; then - username=$(jq -r '.username' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null) - if [ -n "$username" ] && [ "$username" != "null" ]; then - has_username=true - fi + connect_key="" + if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ]; then + connect_key=$(jq -r '.apikey // empty' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null) || return 1 fi - - # Fallback to myservers.cfg (legacy format) - if [ "$has_username" = false ] && [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then - if grep -q 'username' "/boot/config/plugins/dynamix.my.servers/myservers.cfg"; then - has_username=true - fi + if [ -z "$connect_key" ] && [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then + connect_key=$(sed -nE 's/^[[:space:]]*apikey[[:space:]]*=[[:space:]]*"?([^"[:space:]]+)"?[[:space:]]*$/\1/p' \ + "/boot/config/plugins/dynamix.my.servers/myservers.cfg" | head -n 1) fi - - if [ "$has_username" = true ]; then - printf "\nSigning out of Unraid Connect\n" - # Check if regFILE exists in var.ini - if [ -f "/var/local/emhttp/var.ini" ]; then - regfile=$(grep "regFILE" "/var/local/emhttp/var.ini" | cut -d= -f2) - if [ -n "$regfile" ] && [ -f "$regfile" ]; then - # Base64 encode the key file and send to server - encoded_key=$(base64 "$regfile" | tr -d '\n') - if [ -n "$encoded_key" ]; then - curl -s -X POST "https://keys.lime-technology.com/account/server/unregister" \ - -d "keyfile=$encoded_key" >/dev/null 2>&1 - fi - fi + if [ -n "$connect_key" ]; then + if [[ ! "$connect_key" =~ ^unraid_[A-Za-z0-9]{57}$ ]] || ! ( + set -o pipefail + printf 'header = "x-api-key: %s"\n' "$connect_key" | + curl --config - --silent --show-error --fail \ + --connect-timeout 5 --max-time 15 --proto '=https' \ + --request POST https://mothership.unraid.net/ks/server/self-unregister | + jq -e '.revoked == true' >/dev/null + ); then + echo "Error: Connect cloud key revocation failed; credentials were retained for retry" + return 1 fi fi @@ -165,7 +152,7 @@ perform_connect_cleanup() { fi } -perform_connect_cleanup +perform_connect_cleanup || exit 1 echo "Done. Please uninstall the Connect plugin to complete the cleanup." # Exit with error to clarify that further user action--either uninstalling the plugin or upgrading to a newer version of Unraid--is required. exit 1; @@ -283,6 +270,31 @@ exit 0 # persistence write cannot recreate the credentials on flash. if [ -f /boot/config/plugins/dynamix.my.servers/configs/connect.json ] || \ [ -f /boot/config/plugins/dynamix.my.servers/myservers.cfg ]; then + connect_key="" + if [ -f /boot/config/plugins/dynamix.my.servers/configs/connect.json ]; then + connect_key=$(jq -r '.apikey // empty' /boot/config/plugins/dynamix.my.servers/configs/connect.json 2>/dev/null) || { + echo "Error: Could not read Connect credentials for cloud revocation" + exit 1 + } + fi + if [ -z "$connect_key" ] && [ -f /boot/config/plugins/dynamix.my.servers/myservers.cfg ]; then + connect_key=$(sed -nE 's/^[[:space:]]*apikey[[:space:]]*=[[:space:]]*"?([^"[:space:]]+)"?[[:space:]]*$/\1/p' \ + /boot/config/plugins/dynamix.my.servers/myservers.cfg | head -n 1) + fi + if [ -n "$connect_key" ]; then + if [[ ! "$connect_key" =~ ^unraid_[A-Za-z0-9]{57}$ ]] || ! ( + set -o pipefail + printf 'header = "x-api-key: %s"\n' "$connect_key" | + curl --config - --silent --show-error --fail \ + --connect-timeout 5 --max-time 15 --proto '=https' \ + --request POST https://mothership.unraid.net/ks/server/self-unregister | + jq -e '.revoked == true' >/dev/null + ); then + echo "Error: Connect cloud key revocation failed; credentials were retained for retry" + exit 1 + fi + fi + if [ -x /etc/rc.d/rc.unraid-api ]; then /etc/rc.d/rc.unraid-api stop || true elif command -v unraid-api >/dev/null 2>&1; then From 548f28a43966f177eb45b91a878f7586046c8796 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 28 Oct 2026 00:02:09 -0400 Subject: [PATCH 10/16] fix(connect): publish sign-out state on relay stop failure --- .../src/__test__/mothership.events.test.ts | 13 +++++++++++++ .../src/mothership-proxy/mothership.events.ts | 14 ++++++++------ plugin/plugins/dynamix.unraid.net.plg | 2 +- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/mothership.events.test.ts b/packages/unraid-api-plugin-connect/src/__test__/mothership.events.test.ts index 53279161ab..907e543983 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/mothership.events.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/mothership.events.test.ts @@ -192,6 +192,19 @@ describe('MothershipHandler - Behavioral Tests', () => { expect(controllerStops).toHaveLength(1); expect(publishedMessages).toHaveLength(2); }); + + it('publishes cleared state when relay shutdown fails', async () => { + vi.mocked(mothershipController.stop).mockRejectedValueOnce(new Error('relay stop failed')); + + await expect(handler.logout({})).rejects.toThrow(); + expect(publishedMessages).toEqual([ + { channel: GRAPHQL_PUBSUB_CHANNEL.SERVERS, data: { servers: [] } }, + { + channel: GRAPHQL_PUBSUB_CHANNEL.OWNER, + data: { owner: { username: 'root', url: '', avatar: '' } }, + }, + ]); + }); }); describe('DDoS Prevention Behavior', () => { diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.events.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.events.ts index b4dac84179..d3cbc09cf5 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.events.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.events.ts @@ -49,11 +49,13 @@ export class MothershipHandler { @OnEvent(EVENTS.LOGOUT, { async: true, prependListener: true }) async logout({ reason }: { reason?: string }) { this.logger.log('Logging out user: %s', reason ?? 'No reason provided'); - await this.mothershipController.stop(); - // publish to the 'servers' and 'owner' endpoints - await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.SERVERS, { servers: [] }); - await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.OWNER, { - owner: { username: 'root', url: '', avatar: '' }, - }); + try { + await this.mothershipController.stop(); + } finally { + await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.SERVERS, { servers: [] }); + await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.OWNER, { + owner: { username: 'root', url: '', avatar: '' }, + }); + } } } diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 2ee232578d..adc702de88 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -305,7 +305,7 @@ exit 0 # so an unrelated app must not block Connect credential removal. if ! ( set -o pipefail - PM2_HOME=/var/log/.pm2 /usr/local/unraid-api/node_modules/pm2/bin/pm2 --no-color jlist | + PM2_HOME=/var/log/.pm2 /usr/local/unraid-api/node_modules/pm2/bin/pm2 --silent --no-color jlist | jq -e 'type == "array" and all(.[]; .name != "unraid-api")' >/dev/null ) 2>/dev/null; then echo "Error: Could not verify Unraid API stopped; Connect credentials were not removed" From 4d7888414e2d95a5fcee9e5c88b0c5752bd51f59 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 28 Oct 2026 00:02:58 -0400 Subject: [PATCH 11/16] fix(connect): explain malformed key recovery on removal --- plugin/plugins/dynamix.unraid.net.plg | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index adc702de88..89e99bffa8 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -282,7 +282,12 @@ exit 0 /boot/config/plugins/dynamix.my.servers/myservers.cfg | head -n 1) fi if [ -n "$connect_key" ]; then - if [[ ! "$connect_key" =~ ^unraid_[A-Za-z0-9]{57}$ ]] || ! ( + if [[ ! "$connect_key" =~ ^unraid_[A-Za-z0-9]{57}$ ]]; then + echo "Error: Connect key format is invalid; automatic cloud revocation is unavailable" + echo "Contact Unraid Support to verify revocation before removing the retained credentials" + exit 1 + fi + if ! ( set -o pipefail printf 'header = "x-api-key: %s"\n' "$connect_key" | curl --config - --silent --show-error --fail \ From 5d8869b5493f4f4ad28f69d19d76b1378cc3f1d4 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 28 Oct 2026 00:13:27 -0400 Subject: [PATCH 12/16] fix(connect): revoke distinct keys and serialize sign-out persistence --- .../__test__/connect.config.service.test.ts | 89 +++++++++++++++++++ .../src/config/config.persistence.ts | 17 ++++ .../src/config/connect.config.service.ts | 82 +++++++++++------ plugin/plugins/dynamix.unraid.net.plg | 70 ++++++++++++--- 4 files changed, 218 insertions(+), 40 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index 927c4e58ec..767240df8f 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -50,6 +50,63 @@ describe('ConnectConfigService sign-out', () => { expect(configService.get('connect.config.apikey')).toBe('current-key'); }); + it('revokes distinct current and legacy keys before clearing either credential', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await writeFile(legacyPath, '[remote]\napikey="legacy-key"\n'); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: legacyPath, + MOTHERSHIP_GRAPHQL_LINK: 'https://mothership.unraid.net/ws', + }); + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ revoked: true }) }) + .mockResolvedValueOnce({ ok: false }); + vi.stubGlobal('fetch', fetchMock); + const service = new ConnectConfigService(configService); + + await expect(service.revokeCloudKey()).rejects.toThrow(); + expect(fetchMock.mock.calls.map(([, options]) => options.headers['x-api-key'])).toEqual([ + 'current-key', + 'legacy-key', + ]); + expect(configService.get('connect.config.apikey')).toBe('current-key'); + expect(await readFile(legacyPath, 'utf8')).toContain('legacy-key'); + }); + + it('confirms both distinct keys before sign-out removes their local copies', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const legacyPath = join(directory, 'myservers.cfg'); + await writeFile(legacyPath, '[remote]\napikey="legacy-key"\n'); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: legacyPath, + MOTHERSHIP_GRAPHQL_LINK: 'https://staging.mothership.unraid.net/ws', + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ revoked: true }), + }); + vi.stubGlobal('fetch', fetchMock); + const service = new ConnectConfigService(configService); + + await service.revokeCloudKey(); + expect(fetchMock.mock.calls.map(([url, options]) => [ + url.toString(), + options.headers['x-api-key'], + ])).toEqual([ + ['https://staging.mothership.unraid.net/ks/server/self-unregister', 'current-key'], + ['https://staging.mothership.unraid.net/ks/server/self-unregister', 'legacy-key'], + ]); + await service.resetUser(); + expect(configService.get('connect.config.apikey')).toBe(''); + await expect(readFile(legacyPath)).rejects.toThrow(); + }); + it('persists the cleared identity and removes the legacy source of the key', async () => { const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); temporaryDirectories.push(directory); @@ -82,6 +139,38 @@ describe('ConnectConfigService sign-out', () => { await expect(readFile(legacyPath)).rejects.toThrow(); }); + it('waits for an earlier key write before persisting sign-out', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: join(directory, 'absent.cfg'), + }); + const persister = new ConnectConfigPersister(configService); + const handler = persister.getFileHandler(); + const writeConfigFile = handler.writeConfigFile.bind(handler); + let releaseWrite!: () => void; + let writeStarted!: () => void; + const holdWrite = new Promise((resolve) => { releaseWrite = resolve; }); + const started = new Promise((resolve) => { writeStarted = resolve; }); + vi.spyOn(handler, 'writeConfigFile').mockImplementationOnce(async (config) => { + writeStarted(); + await holdWrite; + return writeConfigFile(config); + }); + + const oldWrite = persister.persist(persister.getConfig()); + await started; + const signOut = new ConnectConfigService(configService).resetUser(); + releaseWrite(); + await Promise.all([oldWrite, signOut]); + + const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); + expect(persisted.apikey).toBe(''); + expect(configService.get('connect.config.apikey')).toBe(''); + }); + it('retains local credentials when cloud revocation fails during sign-out', async () => { const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); temporaryDirectories.push(directory); diff --git a/packages/unraid-api-plugin-connect/src/config/config.persistence.ts b/packages/unraid-api-plugin-connect/src/config/config.persistence.ts index b7808e7dbe..d21966eb79 100644 --- a/packages/unraid-api-plugin-connect/src/config/config.persistence.ts +++ b/packages/unraid-api-plugin-connect/src/config/config.persistence.ts @@ -10,12 +10,29 @@ import { parse as parseIni } from 'ini'; import type { MyServersConfig as LegacyConfig } from './my-servers.config.js'; import { emptyMyServersConfig, MyServersConfig } from './connect.config.js'; +const pendingConnectWrites = new Map>(); + @Injectable() export class ConnectConfigPersister extends ConfigFilePersister { constructor(configService: ConfigService) { super(configService); } + public override async persist( + config = this.configService.get(this.configKey()) + ): Promise { + const path = this.configPath(); + const previous = pendingConnectWrites.get(path) ?? Promise.resolve(); + const write = previous.then(() => super.persist(config)); + const settled = write.then(() => undefined, () => undefined); + pendingConnectWrites.set(path, settled); + try { + return await write; + } finally { + if (pendingConnectWrites.get(path) === settled) pendingConnectWrites.delete(path); + } + } + /** * @override * @returns The name of the config file. diff --git a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts index 10dfe911a7..4e2424f4ca 100644 --- a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts +++ b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts @@ -1,9 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OnEvent } from '@nestjs/event-emitter'; -import { unlink } from 'node:fs/promises'; +import { readFile, unlink } from 'node:fs/promises'; import { isMatch } from 'lodash-es'; +import { parse as parseIni } from 'ini'; import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectConfigPersister } from './config.persistence.js'; @@ -27,8 +28,31 @@ export class ConnectConfigService { } async revokeCloudKey(): Promise { - const apiKey = this.getConfig().apikey; - if (!apiKey) return; + const apiKeys = new Set(); + const currentKey = this.getConfig().apikey; + if (currentKey) apiKeys.add(currentKey); + + const legacyPath = this.configService.get( + 'PATHS_MY_SERVERS_CONFIG', + '/boot/config/plugins/dynamix.my.servers/myservers.cfg' + ); + if (legacyPath) { + const legacyContent = await readFile(legacyPath, 'utf8').catch( + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + } + ); + if (legacyContent !== null) { + const legacyConfig = parseIni(legacyContent); + const legacyKey: unknown = legacyConfig.remote?.apikey; + if (legacyKey != null && typeof legacyKey !== 'string') { + throw new Error('Connect legacy key could not be read'); + } + if (legacyKey) apiKeys.add(legacyKey); + } + } + if (apiKeys.size === 0) return; const mothership = new URL(this.configService.getOrThrow('MOTHERSHIP_GRAPHQL_LINK')); if (mothership.protocol !== 'https:') { @@ -36,22 +60,24 @@ export class ConnectConfigService { } const endpoint = new URL('/ks/server/self-unregister', mothership); - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'x-api-key': apiKey }, - redirect: 'error', - signal: AbortSignal.timeout(15_000), - }); - if (!response.ok) throw new Error('Connect cloud key revocation failed'); + for (const apiKey of apiKeys) { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'x-api-key': apiKey }, + redirect: 'error', + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error('Connect cloud key revocation failed'); - const result: unknown = await response.json(); - if ( - !result || - typeof result !== 'object' || - !('revoked' in result) || - result.revoked !== true - ) { - throw new Error('Connect cloud key revocation was not confirmed'); + const result: unknown = await response.json(); + if ( + !result || + typeof result !== 'object' || + !('revoked' in result) || + result.revoked !== true + ) { + throw new Error('Connect cloud key revocation was not confirmed'); + } } } @@ -86,19 +112,23 @@ export class ConnectConfigService { ...emptyMyServersConfig(), email: '', }; + const previousConfig = this.getConfig(); const config = { - ...this.getConfig(), + ...previousConfig, ...identity, }; const persister = new ConnectConfigPersister(this.configService); - await persister.persist(config); - const savedConfig = await persister.getFileHandler().readConfigFile(); - if (!isMatch(savedConfig, identity)) { - throw new Error('Connect sign-out could not persist cleared identity'); - } - // Keep memory aligned with the cleared file before legacy cleanup. If - // that cleanup fails, shutdown persistence must not restore the key. this.configService.set(this.configKey, config); + try { + await persister.persist(config); + const savedConfig = await persister.getFileHandler().readConfigFile(); + if (!isMatch(savedConfig, identity)) { + throw new Error('Connect sign-out could not persist cleared identity'); + } + } catch (error) { + this.configService.set(this.configKey, previousConfig); + throw error; + } const legacyPath = this.configService.get( 'PATHS_MY_SERVERS_CONFIG', diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 89e99bffa8..8a4dff4c23 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -120,27 +120,49 @@ perform_connect_cleanup() { echo "⚠️ Warning: Neither /etc/rc.d/rc.unraid-api nor unraid-api command found" fi - connect_key="" + connect_keys=() + current_key="" if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ]; then - connect_key=$(jq -r '.apikey // empty' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null) || return 1 + current_key=$(jq -r '.apikey // empty' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null) || return 1 + [ -z "$current_key" ] || connect_keys+=("$current_key") fi - if [ -z "$connect_key" ] && [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then - connect_key=$(sed -nE 's/^[[:space:]]*apikey[[:space:]]*=[[:space:]]*"?([^"[:space:]]+)"?[[:space:]]*$/\1/p' \ + if [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then + legacy_key=$(sed -nE 's/^[[:space:]]*apikey[[:space:]]*=[[:space:]]*"?([^"[:space:]]+)"?[[:space:]]*$/\1/p' \ "/boot/config/plugins/dynamix.my.servers/myservers.cfg" | head -n 1) + [ -z "$legacy_key" ] || [ "$legacy_key" = "${current_key:-}" ] || connect_keys+=("$legacy_key") fi - if [ -n "$connect_key" ]; then + # Match the endpoint used by the installed API. An unknown environment + # must retain the key rather than treating another cluster's 200 as proof. + mothership_link="" + api_environment="production" + if [ -f /usr/local/unraid-api/.env ]; then + mothership_link=$(sed -nE 's/^[[:space:]]*MOTHERSHIP_GRAPHQL_LINK[[:space:]]*=[[:space:]]*"?([^"[:space:]#]+)"?[[:space:]]*$/\1/p' /usr/local/unraid-api/.env | tail -n 1) + api_environment=$(sed -nE 's/^[[:space:]]*ENVIRONMENT[[:space:]]*=[[:space:]]*"?([^"[:space:]#]+)"?[[:space:]]*$/\1/p' /usr/local/unraid-api/.env | tail -n 1) + fi + case "$mothership_link" in + "") + case "$api_environment" in + ""|production) revoke_endpoint=https://mothership.unraid.net/ks/server/self-unregister ;; + staging) revoke_endpoint=https://staging.mothership.unraid.net/ks/server/self-unregister ;; + *) echo "Error: Unknown Connect cloud environment; credentials were retained"; return 1 ;; + esac ;; + https://mothership.unraid.net/ws) revoke_endpoint=https://mothership.unraid.net/ks/server/self-unregister ;; + https://staging.mothership.unraid.net/ws) revoke_endpoint=https://staging.mothership.unraid.net/ks/server/self-unregister ;; + *) echo "Error: Unknown Connect cloud endpoint; credentials were retained"; return 1 ;; + esac + for connect_key in "${connect_keys[@]}"; do if [[ ! "$connect_key" =~ ^unraid_[A-Za-z0-9]{57}$ ]] || ! ( set -o pipefail printf 'header = "x-api-key: %s"\n' "$connect_key" | curl --config - --silent --show-error --fail \ --connect-timeout 5 --max-time 15 --proto '=https' \ - --request POST https://mothership.unraid.net/ks/server/self-unregister | + --request POST "$revoke_endpoint" | jq -e '.revoked == true' >/dev/null ); then echo "Error: Connect cloud key revocation failed; credentials were retained for retry" return 1 fi - fi + done # Remove config files rm -f /boot/config/plugins/dynamix.my.servers/myservers.cfg @@ -270,18 +292,38 @@ exit 0 # persistence write cannot recreate the credentials on flash. if [ -f /boot/config/plugins/dynamix.my.servers/configs/connect.json ] || \ [ -f /boot/config/plugins/dynamix.my.servers/myservers.cfg ]; then - connect_key="" + connect_keys=() + current_key="" if [ -f /boot/config/plugins/dynamix.my.servers/configs/connect.json ]; then - connect_key=$(jq -r '.apikey // empty' /boot/config/plugins/dynamix.my.servers/configs/connect.json 2>/dev/null) || { + current_key=$(jq -r '.apikey // empty' /boot/config/plugins/dynamix.my.servers/configs/connect.json 2>/dev/null) || { echo "Error: Could not read Connect credentials for cloud revocation" exit 1 } + [ -z "$current_key" ] || connect_keys+=("$current_key") fi - if [ -z "$connect_key" ] && [ -f /boot/config/plugins/dynamix.my.servers/myservers.cfg ]; then - connect_key=$(sed -nE 's/^[[:space:]]*apikey[[:space:]]*=[[:space:]]*"?([^"[:space:]]+)"?[[:space:]]*$/\1/p' \ + if [ -f /boot/config/plugins/dynamix.my.servers/myservers.cfg ]; then + legacy_key=$(sed -nE 's/^[[:space:]]*apikey[[:space:]]*=[[:space:]]*"?([^"[:space:]]+)"?[[:space:]]*$/\1/p' \ /boot/config/plugins/dynamix.my.servers/myservers.cfg | head -n 1) + [ -z "$legacy_key" ] || [ "$legacy_key" = "${current_key:-}" ] || connect_keys+=("$legacy_key") + fi + mothership_link="" + api_environment="production" + if [ -f /usr/local/unraid-api/.env ]; then + mothership_link=$(sed -nE 's/^[[:space:]]*MOTHERSHIP_GRAPHQL_LINK[[:space:]]*=[[:space:]]*"?([^"[:space:]#]+)"?[[:space:]]*$/\1/p' /usr/local/unraid-api/.env | tail -n 1) + api_environment=$(sed -nE 's/^[[:space:]]*ENVIRONMENT[[:space:]]*=[[:space:]]*"?([^"[:space:]#]+)"?[[:space:]]*$/\1/p' /usr/local/unraid-api/.env | tail -n 1) fi - if [ -n "$connect_key" ]; then + case "$mothership_link" in + "") + case "$api_environment" in + ""|production) revoke_endpoint=https://mothership.unraid.net/ks/server/self-unregister ;; + staging) revoke_endpoint=https://staging.mothership.unraid.net/ks/server/self-unregister ;; + *) echo "Error: Unknown Connect cloud environment; credentials were retained"; exit 1 ;; + esac ;; + https://mothership.unraid.net/ws) revoke_endpoint=https://mothership.unraid.net/ks/server/self-unregister ;; + https://staging.mothership.unraid.net/ws) revoke_endpoint=https://staging.mothership.unraid.net/ks/server/self-unregister ;; + *) echo "Error: Unknown Connect cloud endpoint; credentials were retained"; exit 1 ;; + esac + for connect_key in "${connect_keys[@]}"; do if [[ ! "$connect_key" =~ ^unraid_[A-Za-z0-9]{57}$ ]]; then echo "Error: Connect key format is invalid; automatic cloud revocation is unavailable" echo "Contact Unraid Support to verify revocation before removing the retained credentials" @@ -292,13 +334,13 @@ exit 0 printf 'header = "x-api-key: %s"\n' "$connect_key" | curl --config - --silent --show-error --fail \ --connect-timeout 5 --max-time 15 --proto '=https' \ - --request POST https://mothership.unraid.net/ks/server/self-unregister | + --request POST "$revoke_endpoint" | jq -e '.revoked == true' >/dev/null ); then echo "Error: Connect cloud key revocation failed; credentials were retained for retry" exit 1 fi - fi + done if [ -x /etc/rc.d/rc.unraid-api ]; then /etc/rc.d/rc.unraid-api stop || true From 1b33bcd4d017e766e5e566b98de5b931cb2fb48f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 28 Oct 2026 00:50:40 -0400 Subject: [PATCH 13/16] fix(connect): keep revoked identity cleared when readback fails --- .../__test__/connect.config.service.test.ts | 87 +++++++++++++++++-- .../src/config/connect.config.service.ts | 21 ++++- .../connect-settings.resolver.ts | 15 +++- 3 files changed, 107 insertions(+), 16 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index 767240df8f..9c9ca16ddc 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -5,11 +5,15 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ConfigFileHandler } from '@unraid/shared/util/config-file-handler.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConnectConfigPersister } from '../config/config.persistence.js'; import { emptyMyServersConfig } from '../config/connect.config.js'; -import { ConnectConfigService } from '../config/connect.config.service.js'; +import { + ConnectConfigService, + ConnectIdentityVerificationError, +} from '../config/connect.config.service.js'; import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectSettingsResolver } from '../unraid-connect/connect-settings.resolver.js'; import { ConnectSettingsService } from '../unraid-connect/connect-settings.service.js'; @@ -61,7 +65,8 @@ describe('ConnectConfigService sign-out', () => { PATHS_MY_SERVERS_CONFIG: legacyPath, MOTHERSHIP_GRAPHQL_LINK: 'https://mothership.unraid.net/ws', }); - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ revoked: true }) }) .mockResolvedValueOnce({ ok: false }); vi.stubGlobal('fetch', fetchMock); @@ -95,10 +100,9 @@ describe('ConnectConfigService sign-out', () => { const service = new ConnectConfigService(configService); await service.revokeCloudKey(); - expect(fetchMock.mock.calls.map(([url, options]) => [ - url.toString(), - options.headers['x-api-key'], - ])).toEqual([ + expect( + fetchMock.mock.calls.map(([url, options]) => [url.toString(), options.headers['x-api-key']]) + ).toEqual([ ['https://staging.mothership.unraid.net/ks/server/self-unregister', 'current-key'], ['https://staging.mothership.unraid.net/ks/server/self-unregister', 'legacy-key'], ]); @@ -152,8 +156,12 @@ describe('ConnectConfigService sign-out', () => { const writeConfigFile = handler.writeConfigFile.bind(handler); let releaseWrite!: () => void; let writeStarted!: () => void; - const holdWrite = new Promise((resolve) => { releaseWrite = resolve; }); - const started = new Promise((resolve) => { writeStarted = resolve; }); + const holdWrite = new Promise((resolve) => { + releaseWrite = resolve; + }); + const started = new Promise((resolve) => { + writeStarted = resolve; + }); vi.spyOn(handler, 'writeConfigFile').mockImplementationOnce(async (config) => { writeStarted(); await holdWrite; @@ -215,6 +223,34 @@ describe('ConnectConfigService sign-out', () => { expect(configService.get('connect.config.apikey')).toBe('current-key'); }); + it('does not restore a revoked key if reading back the cleared file fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'revoked-key' } }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: '', + }); + const originalRead = ConfigFileHandler.prototype.readConfigFile; + const read = vi.spyOn(ConfigFileHandler.prototype, 'readConfigFile'); + read.mockImplementationOnce(function ( + this: ConfigFileHandler> + ) { + return originalRead.call(this); + }).mockRejectedValueOnce(new Error('read failed')); + try { + await expect(new ConnectConfigService(configService).resetUser()).rejects.toThrow( + ConnectIdentityVerificationError + ); + expect(configService.get('connect.config.apikey')).toBe(''); + await new ConnectConfigPersister(configService).onModuleDestroy(); + const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); + expect(persisted.apikey).toBe(''); + } finally { + read.mockRestore(); + } + }); + it('does not restore the key on shutdown when legacy cleanup fails', async () => { const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); temporaryDirectories.push(directory); @@ -358,4 +394,39 @@ describe('ConnectConfigService sign-out', () => { const persisted = JSON.parse(await readFile(join(directory, 'connect.json'), 'utf8')); expect(persisted.apikey).toBe(''); }); + + it('tears down the relay when the cleared identity cannot be read back', async () => { + const directory = await mkdtemp(join(tmpdir(), 'connect-sign-out-')); + temporaryDirectories.push(directory); + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + PATHS_CONFIG_MODULES: directory, + PATHS_MY_SERVERS_CONFIG: '', + }); + const originalRead = ConfigFileHandler.prototype.readConfigFile; + const read = vi.spyOn(ConfigFileHandler.prototype, 'readConfigFile'); + read.mockImplementationOnce(function ( + this: ConfigFileHandler> + ) { + return originalRead.call(this); + }).mockRejectedValueOnce(new Error('read failed')); + const emitter = new EventEmitter2(); + const stopRelay = vi.fn().mockResolvedValue(undefined); + emitter.on(EVENTS.LOGOUT, stopRelay); + const connectConfig = new ConnectConfigService(configService); + vi.spyOn(connectConfig, 'revokeCloudKey').mockResolvedValue(); + const resolver = new ConnectSettingsResolver( + {} as ConnectSettingsService, + emitter, + connectConfig + ); + + try { + await expect(resolver.connectSignOut()).rejects.toThrow(ConnectIdentityVerificationError); + expect(stopRelay).toHaveBeenCalledTimes(1); + expect(configService.get('connect.config.apikey')).toBe(''); + } finally { + read.mockRestore(); + } + }); }); diff --git a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts index 4e2424f4ca..56f4bb6120 100644 --- a/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts +++ b/packages/unraid-api-plugin-connect/src/config/connect.config.service.ts @@ -3,8 +3,8 @@ import { ConfigService } from '@nestjs/config'; import { OnEvent } from '@nestjs/event-emitter'; import { readFile, unlink } from 'node:fs/promises'; -import { isMatch } from 'lodash-es'; import { parse as parseIni } from 'ini'; +import { isMatch } from 'lodash-es'; import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectConfigPersister } from './config.persistence.js'; @@ -17,6 +17,13 @@ export class ConnectLegacyCleanupError extends Error { } } +/** The cleared identity was written, but its durable state could not be verified. */ +export class ConnectIdentityVerificationError extends Error { + constructor(cause: unknown) { + super('Connect sign-out could not verify the cleared identity', { cause }); + } +} + @Injectable() export class ConnectConfigService { public readonly configKey = 'connect.config'; @@ -119,15 +126,21 @@ export class ConnectConfigService { }; const persister = new ConnectConfigPersister(this.configService); this.configService.set(this.configKey, config); + let writeCompleted = false; try { - await persister.persist(config); + writeCompleted = await persister.persist(config); const savedConfig = await persister.getFileHandler().readConfigFile(); if (!isMatch(savedConfig, identity)) { throw new Error('Connect sign-out could not persist cleared identity'); } } catch (error) { - this.configService.set(this.configKey, previousConfig); - throw error; + if (!writeCompleted) { + this.configService.set(this.configKey, previousConfig); + throw error; + } + // The disk may already contain the cleared identity. Keep memory + // cleared so shutdown persistence cannot restore the revoked key. + throw new ConnectIdentityVerificationError(error); } const legacyPath = this.configService.get( diff --git a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts index 0c0b594475..ec2a1dc394 100644 --- a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts @@ -9,7 +9,11 @@ import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { GraphQLJSON } from 'graphql-scalars'; -import { ConnectConfigService, ConnectLegacyCleanupError } from '../config/connect.config.service.js'; +import { + ConnectConfigService, + ConnectIdentityVerificationError, + ConnectLegacyCleanupError, +} from '../config/connect.config.service.js'; import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectSettingsService } from './connect-settings.service.js'; import { @@ -124,9 +128,12 @@ export class ConnectSettingsResolver { try { await this.connectConfigService.resetUser(); } catch (error) { - if (error instanceof ConnectLegacyCleanupError) { - // The cleared identity is already durable. Tear down the relay - // even though the stale legacy file still needs cleanup. + if ( + error instanceof ConnectLegacyCleanupError || + error instanceof ConnectIdentityVerificationError + ) { + // The write completed, so keep the identity cleared and tear + // down the relay even if cleanup or verification failed. try { await this.eventEmitter.emitAsync(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API', From 2c25c5c7dca0f08976dd5dea2aaac87deb3c33e7 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 28 Oct 2026 10:23:09 -0400 Subject: [PATCH 14/16] fix(connect): log logout listener failures without masking cleanup error --- .../__test__/connect.config.service.test.ts | 30 +++++++++++++++++++ .../connect-settings.resolver.ts | 5 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts index 9c9ca16ddc..e57f24e1e0 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; import { Test } from '@nestjs/testing'; @@ -429,4 +430,33 @@ describe('ConnectConfigService sign-out', () => { read.mockRestore(); } }); + + it('keeps the cleanup error and logs a logout listener failure', async () => { + const configService = new ConfigService({ + connect: { config: { ...emptyMyServersConfig(), apikey: 'current-key' } }, + }); + const connectConfig = new ConnectConfigService(configService); + vi.spyOn(connectConfig, 'revokeCloudKey').mockResolvedValue(); + const cleanupError = new ConnectIdentityVerificationError(new Error('read failed')); + vi.spyOn(connectConfig, 'resetUser').mockRejectedValue(cleanupError); + const emitter = new EventEmitter2(); + const listenerError = new Error('relay stop failed'); + vi.spyOn(emitter, 'emitAsync').mockRejectedValue(listenerError); + const logError = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const resolver = new ConnectSettingsResolver( + {} as ConnectSettingsService, + emitter, + connectConfig + ); + + try { + await expect(resolver.connectSignOut()).rejects.toBe(cleanupError); + expect(logError).toHaveBeenCalledWith( + 'Logout listeners failed after sign-out cleanup error', + listenerError + ); + } finally { + logError.mockRestore(); + } + }); }); diff --git a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts index ec2a1dc394..93180048ba 100644 --- a/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/unraid-connect/connect-settings.resolver.ts @@ -139,9 +139,10 @@ export class ConnectSettingsResolver { reason: 'Manual Sign Out Using API', configResetCompleted: true, }); - } finally { - throw error; + } catch (emitError) { + this.logger.error('Logout listeners failed after sign-out cleanup error', emitError); } + throw error; } throw error; } From 2535360a5d7098530ee30c125edfda7539859a30 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 28 Oct 2026 10:33:40 -0400 Subject: [PATCH 15/16] fix(connect): parse unsupported-version revocation with PHP --- plugin/plugins/dynamix.unraid.net.plg | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 8a4dff4c23..1ffcc09b7b 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -123,7 +123,12 @@ perform_connect_cleanup() { connect_keys=() current_key="" if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ]; then - current_key=$(jq -r '.apikey // empty' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null) || return 1 + current_key=$(/usr/bin/php -r ' + $config = json_decode(@file_get_contents($argv[1]), true); + if (!is_array($config) || json_last_error() !== JSON_ERROR_NONE || + !is_string($config["apikey"] ?? "")) exit(1); + echo $config["apikey"] ?? ""; + ' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null) || return 1 [ -z "$current_key" ] || connect_keys+=("$current_key") fi if [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then @@ -157,7 +162,10 @@ perform_connect_cleanup() { curl --config - --silent --show-error --fail \ --connect-timeout 5 --max-time 15 --proto '=https' \ --request POST "$revoke_endpoint" | - jq -e '.revoked == true' >/dev/null + /usr/bin/php -r ' + $response = json_decode(stream_get_contents(STDIN), true); + exit(is_array($response) && ($response["revoked"] ?? null) === true ? 0 : 1); + ' ); then echo "Error: Connect cloud key revocation failed; credentials were retained for retry" return 1 From a3c9c8109d0422025b3f61b3f3c3ed6129b7f44a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 28 Oct 2026 11:14:41 -0400 Subject: [PATCH 16/16] revert(connect): retain jq for stock Unraid versions --- plugin/plugins/dynamix.unraid.net.plg | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 1ffcc09b7b..8a4dff4c23 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -123,12 +123,7 @@ perform_connect_cleanup() { connect_keys=() current_key="" if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ]; then - current_key=$(/usr/bin/php -r ' - $config = json_decode(@file_get_contents($argv[1]), true); - if (!is_array($config) || json_last_error() !== JSON_ERROR_NONE || - !is_string($config["apikey"] ?? "")) exit(1); - echo $config["apikey"] ?? ""; - ' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null) || return 1 + current_key=$(jq -r '.apikey // empty' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null) || return 1 [ -z "$current_key" ] || connect_keys+=("$current_key") fi if [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then @@ -162,10 +157,7 @@ perform_connect_cleanup() { curl --config - --silent --show-error --fail \ --connect-timeout 5 --max-time 15 --proto '=https' \ --request POST "$revoke_endpoint" | - /usr/bin/php -r ' - $response = json_decode(stream_get_contents(STDIN), true); - exit(is_array($response) && ($response["revoked"] ?? null) === true ? 0 : 1); - ' + jq -e '.revoked == true' >/dev/null ); then echo "Error: Connect cloud key revocation failed; credentials were retained for retry" return 1