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..e57f24e1e0 --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/__test__/connect.config.service.test.ts @@ -0,0 +1,462 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; +import { Test } from '@nestjs/testing'; +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, + 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'; + +describe('ConnectConfigService sign-out', () => { + const temporaryDirectories: string[] = []; + + afterEach(async () => { + vi.unstubAllGlobals(); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ); + }); + + 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('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); + 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', + email: 'signed-in@example.com', + 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.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(); + }); + + 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); + 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); + 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'); + 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); + 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); + 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'); + 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')); + 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 { + vi.spyOn(module.get(ConnectConfigService), 'revokeCloudKey').mockResolvedValue(); + await expect(module.get(ConnectSettingsResolver).connectSignOut()).rejects.toThrow(); + expect(await readFile(legacyPath, 'utf8')).toContain('legacy-key'); + } finally { + 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 connectConfig = new ConnectConfigService(configService); + vi.spyOn(connectConfig, 'revokeCloudKey').mockResolvedValue(); + const resolver = new ConnectSettingsResolver( + {} as ConnectSettingsService, + emitter, + connectConfig + ); + + 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(''); + 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(); + } + }); + + 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/__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/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 72d820462a..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 @@ -1,10 +1,29 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OnEvent } from '@nestjs/event-emitter'; +import { readFile, unlink } from 'node:fs/promises'; + +import { parse as parseIni } from 'ini'; +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'; +/** 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 }); + } +} + +/** 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'; @@ -15,6 +34,60 @@ export class ConnectConfigService { return this.configService.getOrThrow(this.configKey); } + async revokeCloudKey(): Promise { + 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:') { + throw new Error('Connect key revocation requires HTTPS'); + } + + const endpoint = new URL('/ks/server/self-unregister', mothership); + 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'); + } + } + } + getExtraOrigins(): string[] { const extraOrigins = this.configService.get('store.config.api.extraOrigins'); if (extraOrigins) { @@ -40,18 +113,51 @@ 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, { - ...this.getConfig(), + const { wanaccess, wanport, upnpEnabled, ...identity } = { + ...emptyMyServersConfig(), + email: '', + }; + const previousConfig = this.getConfig(); + const config = { + ...previousConfig, ...identity, - }); + }; + const persister = new ConnectConfigPersister(this.configService); + this.configService.set(this.configKey, config); + let writeCompleted = false; + try { + 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) { + 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( + '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 new ConnectLegacyCleanupError(error); + }); + } this.logger.verbose('Reset Connect user identity'); } @OnEvent(EVENTS.LOGOUT, { async: true }) - async onLogout() { - this.resetUser(); + async onLogout({ configResetCompleted = false }: { configResetCompleted?: boolean } = {}) { + if (configResetCompleted) return; + await this.resetUser(); } } 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..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 @@ -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,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'); - // 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(); + 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/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..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 @@ -9,6 +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, + ConnectIdentityVerificationError, + ConnectLegacyCleanupError, +} from '../config/connect.config.service.js'; import { EVENTS } from '../helper/nest-tokens.js'; import { ConnectSettingsService } from './connect-settings.service.js'; import { @@ -28,7 +33,8 @@ export class ConnectSettingsResolver { constructor( private readonly connectSettingsService: ConnectSettingsService, - private readonly eventEmitter: EventEmitter2 + private readonly eventEmitter: EventEmitter2, + private readonly connectConfigService: ConnectConfigService ) {} @UsePermissions({ @@ -118,7 +124,32 @@ export class ConnectSettingsResolver { resource: Resource.CONNECT, }) public async connectSignOut() { - this.eventEmitter.emit(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API' }); + await this.connectConfigService.revokeCloudKey(); + try { + await this.connectConfigService.resetUser(); + } catch (error) { + 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', + configResetCompleted: true, + }); + } catch (emitError) { + this.logger.error('Logout listeners failed after sign-out cleanup error', emitError); + } + throw error; + } + throw error; + } + await this.eventEmitter.emitAsync(EVENTS.LOGOUT, { + reason: 'Manual Sign Out Using API', + configResetCompleted: true, + }); return true; } diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 65130f605b..8a4dff4c23 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -120,40 +120,49 @@ 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_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 + [ -z "$current_key" ] || connect_keys+=("$current_key") 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 [ -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 [ "$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 - fi + # 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 "$revoke_endpoint" | + jq -e '.revoked == true' >/dev/null + ); then + echo "Error: Connect cloud key revocation failed; credentials were retained for retry" + return 1 + fi + done # Remove config files rm -f /boot/config/plugins/dynamix.my.servers/myservers.cfg @@ -165,7 +174,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; @@ -267,6 +276,106 @@ exit 0 MAINNAME="&name;" /dev/null) || { + echo "Error: Could not read Connect credentials for cloud revocation" + exit 1 + } + [ -z "$current_key" ] || connect_keys+=("$current_key") + fi + 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 + 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" + exit 1 + fi + if ! ( + 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 "$revoke_endpoint" | + jq -e '.revoked == true' >/dev/null + ); then + echo "Error: Connect cloud key revocation failed; credentials were retained for retry" + exit 1 + fi + done + + 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 + unraid-api stop || true + fi + + # 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 --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" + restore_api + exit 1 + fi + + rm -f /boot/config/plugins/dynamix.my.servers/configs/connect.json \ + /boot/config/plugins/dynamix.my.servers/myservers.cfg || { + restore_api + exit 1 + } + 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 UNRAID_VERSION="" @@ -296,6 +405,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" + restore_api fi # Send notification to user