From fddc5345e9bd68cb40e5ed801f013babd4b9e9b2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 18 Jan 2025 10:42:34 -0500 Subject: [PATCH 01/42] feat: use zod to parse config --- .../files/config-file-normalizer.test.ts | 123 +++++++++--------- api/src/consts.ts | 1 + .../utils/files/config-file-normalizer.ts | 121 +++++++---------- api/src/types/my-servers-config.d.ts | 58 --------- api/src/types/my-servers-config.ts | 67 ++++++++++ api/src/unraid-api/cli/start.command.ts | 1 + .../unraid-api/cli/validate-token.command.ts | 1 + 7 files changed, 178 insertions(+), 194 deletions(-) delete mode 100644 api/src/types/my-servers-config.d.ts create mode 100644 api/src/types/my-servers-config.ts diff --git a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts index 5a1d2c4506..d4b911f0d3 100644 --- a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts +++ b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts @@ -10,72 +10,75 @@ test('it creates a FLASH config with NO OPTIONAL values', () => { const basicConfig = initialState; const config = getWriteableConfig(basicConfig, 'flash'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "", - "version": "", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "", + "version": "", + }, + "local": {}, + "notifier": { + "apikey": "", + }, + "remote": { + "accesstoken": "", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "upc": { + "apikey": "", + }, + } + `); }); test('it creates a MEMORY config with NO OPTIONAL values', () => { const basicConfig = initialState; const config = getWriteableConfig(basicConfig, 'memory'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "", - "version": "", - }, - "connectionStatus": { - "minigraph": "PRE_INIT", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "", + "version": "", + }, + "connectionStatus": { + "minigraph": "PRE_INIT", + "upnpStatus": "", + }, + "local": {}, + "notifier": { + "apikey": "", + }, + "remote": { + "accesstoken": "", + "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "upc": { + "apikey": "", + }, + } + `); }); test('it creates a FLASH config with OPTIONAL values', () => { diff --git a/api/src/consts.ts b/api/src/consts.ts index 96a41c69ad..eb79bed018 100644 --- a/api/src/consts.ts +++ b/api/src/consts.ts @@ -68,6 +68,7 @@ export const JWKS_LOCAL_PAYLOAD: JSONWebKeySet = { }, ], }; + export const OAUTH_BASE_URL = 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_btSkhlsEk'; export const OAUTH_CLIENT_ID = '53ci4o48gac8vq5jepubkjmo36'; diff --git a/api/src/core/utils/files/config-file-normalizer.ts b/api/src/core/utils/files/config-file-normalizer.ts index 3ab27adce0..d7f5009136 100644 --- a/api/src/core/utils/files/config-file-normalizer.ts +++ b/api/src/core/utils/files/config-file-normalizer.ts @@ -1,95 +1,64 @@ +import { isEqual } from 'lodash-es'; + import { getAllowedOrigins } from '@app/common/allowed-origins'; -import { DynamicRemoteAccessType } from '@app/graphql/generated/api/types'; +import { initialState } from '@app/store/modules/config'; import { - type SliceState as ConfigSliceState, - initialState, -} from '@app/store/modules/config'; -import { type RecursivePartial } from '@app/types'; -import type { MyServersConfig, MyServersConfigMemory, + MyServersConfigMemorySchema, + MyServersConfigSchema, } from '@app/types/my-servers-config'; -import { isEqual } from 'lodash-es'; +// Define ConfigType and ConfigObject export type ConfigType = 'flash' | 'memory'; -type ConfigObject = T extends 'flash' - ? MyServersConfig - : T extends 'memory' - ? MyServersConfigMemory - : never; + /** - * - * @param config Config to read from to create a new formatted server config to write - * @param mode 'flash' or 'memory', changes what fields are included in the writeable payload - * @returns + * Get a writeable configuration based on the mode ('flash' or 'memory'). */ - export const getWriteableConfig = ( - config: ConfigSliceState, + config: T extends 'memory' ? MyServersConfigMemory : MyServersConfig, mode: T -): ConfigObject => { - // Get current state - const { api, local, notifier, remote, upc, connectionStatus } = config; +): T extends 'memory' ? MyServersConfigMemory : MyServersConfig => { + const schema = mode === 'memory' ? MyServersConfigMemorySchema : MyServersConfigSchema; - // Create new state - - const newState: ConfigObject = { - api: { - version: api?.version ?? initialState.api.version, - extraOrigins: api?.extraOrigins ?? initialState.api.extraOrigins, - }, - local: {}, - notifier: { - apikey: notifier.apikey ?? initialState.notifier.apikey, - }, + const defaultConfig = schema.parse(initialState); + // Use a type assertion for the mergedConfig to include `connectionStatus` only if `mode === 'memory` + const mergedConfig = { + ...defaultConfig, + ...config, remote: { - wanaccess: remote.wanaccess ?? initialState.remote.wanaccess, - wanport: remote.wanport ?? initialState.remote.wanport, - ...(remote.upnpEnabled ? { upnpEnabled: remote.upnpEnabled } : {}), - apikey: remote.apikey ?? initialState.remote.apikey, - localApiKey: remote.localApiKey ?? initialState.remote.localApiKey, - email: remote.email ?? initialState.remote.email, - username: remote.username ?? initialState.remote.username, - avatar: remote.avatar ?? initialState.remote.avatar, - regWizTime: remote.regWizTime ?? initialState.remote.regWizTime, - idtoken: remote.idtoken ?? initialState.remote.idtoken, - accesstoken: remote.accesstoken ?? initialState.remote.accesstoken, - refreshtoken: - remote.refreshtoken ?? initialState.remote.refreshtoken, - ...(mode === 'memory' - ? { - allowedOrigins: - getAllowedOrigins().join(', ') - } - : {}), - dynamicRemoteAccessType: remote.dynamicRemoteAccessType ?? DynamicRemoteAccessType.DISABLED, + ...defaultConfig.remote, + ...config.remote, }, - upc: { - apikey: upc.apikey ?? initialState.upc.apikey, - }, - ...(mode === 'memory' - ? { - connectionStatus: { - minigraph: - connectionStatus.minigraph ?? - initialState.connectionStatus.minigraph, - ...(connectionStatus.upnpStatus - ? { upnpStatus: connectionStatus.upnpStatus } - : {}), - }, - } - : {}), - } as ConfigObject; - return newState; + } as T extends 'memory' ? MyServersConfigMemory : MyServersConfig; + + if (mode === 'memory') { + (mergedConfig as MyServersConfigMemory).remote.allowedOrigins = getAllowedOrigins().join(', '); + (mergedConfig as MyServersConfigMemory).connectionStatus = { + ...(defaultConfig as MyServersConfigMemory).connectionStatus, + ...(config as MyServersConfigMemory).connectionStatus, + }; + } + + return schema.parse(mergedConfig) as any; // Narrowing ensures correct typing }; /** - * Helper function to convert an object into a normalized config file. - * This is used for loading config files and ensure changes have been made before the state is merged. + * Check if two configurations are equivalent by normalizing them through the Zod schema. */ export const areConfigsEquivalent = ( - newConfigFile: RecursivePartial, - currentConfig: ConfigSliceState -): boolean => - // Enable to view config diffs: logger.debug(getDiff(getWriteableConfig(currentConfig, 'flash'), newConfigFile)); - isEqual(newConfigFile, getWriteableConfig(currentConfig, 'flash')); + newConfigFile: Partial, // Use Partial here for flexibility + currentConfig: MyServersConfig +): boolean => { + // Parse and validate the new config file using the schema (with default values applied) + const normalizedNewConfig = MyServersConfigSchema.parse({ + ...currentConfig, // Use currentConfig as a baseline to fill missing fields + ...newConfigFile, + }); + + // Get the writeable configuration for the current config + const normalizedCurrentConfig = getWriteableConfig(currentConfig, 'flash'); + + // Compare the normalized configurations + return isEqual(normalizedNewConfig, normalizedCurrentConfig); +}; diff --git a/api/src/types/my-servers-config.d.ts b/api/src/types/my-servers-config.d.ts deleted file mode 100644 index 488c8392fb..0000000000 --- a/api/src/types/my-servers-config.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { type DynamicRemoteAccessType, type MinigraphStatus } from '@app/graphql/generated/api/types'; - -interface MyServersConfig extends Record { - api: { - version: string; - extraOrigins: string; - }; - local: {}; - notifier: { - apikey: string; - }; - remote: { - wanaccess: string; - wanport: string; - upnpEnabled?: string; - apikey: string; - localApiKey?: string; - email: string; - username: string; - avatar: string; - regWizTime: string; - accesstoken: string; - idtoken: string; - refreshtoken: string; - allowedOrigins?: string; - dynamicRemoteAccessType?: DynamicRemoteAccessType; - }; - upc: { - apikey: string; - }; -} - -export interface MyServersConfigWithMandatoryHiddenFields extends MyServersConfig { - api: { - extraOrigins: string; - }; - remote: MyServersConfig['remote'] & { - upnpEnabled: string; - dynamicRemoteAccessType: DynamicRemoteAccessType; - }; -} - -export interface MyServersConfigMemory extends MyServersConfig { - connectionStatus: { - minigraph: MinigraphStatus; - upnpStatus?: null | string; - }; - remote: MyServersConfig['remote'] & { - allowedOrigins: string; - }; -} - -export interface MyServersConfigMemoryWithMandatoryHiddenFields extends MyServersConfigMemory { - connectionStatus: { - minigraph: MinigraphStatus; - upnpStatus?: null | string; - }; -} diff --git a/api/src/types/my-servers-config.ts b/api/src/types/my-servers-config.ts new file mode 100644 index 0000000000..1ac28c7a98 --- /dev/null +++ b/api/src/types/my-servers-config.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { DynamicRemoteAccessType, MinigraphStatus } from '@app/graphql/generated/api/types'; + +// Define Zod schemas +const ApiConfigSchema = z.object({ + version: z.string(), + extraOrigins: z.string(), +}); + +const NotifierConfigSchema = z.object({ + apikey: z.string(), +}); + +const RemoteConfigSchema = z.object({ + wanaccess: z.string(), + wanport: z.string(), + upnpEnabled: z.string(), + apikey: z.string(), + localApiKey: z.string(), + email: z.string(), + username: z.string(), + avatar: z.string(), + regWizTime: z.string(), + accesstoken: z.string(), + idtoken: z.string(), + refreshtoken: z.string(), + dynamicRemoteAccessType: z.nativeEnum(DynamicRemoteAccessType), +}); + +const UpcConfigSchema = z.object({ + apikey: z.string(), +}); + +// Base config schema +export const MyServersConfigSchema = z.object({ + api: ApiConfigSchema, + local: z.object({}), // Empty object + notifier: NotifierConfigSchema, + remote: RemoteConfigSchema, + upc: UpcConfigSchema, +}); + +// Memory config schema +export const ConnectionStatusSchema = z.object({ + minigraph: z.nativeEnum(MinigraphStatus), + upnpStatus: z.string().nullable().optional(), +}); + +export const MyServersConfigMemorySchema = MyServersConfigSchema.extend({ + connectionStatus: ConnectionStatusSchema, + remote: RemoteConfigSchema.extend({ + allowedOrigins: z.string(), + }), +}); + +// Memory config with mandatory hidden fields schema +export const MyServersConfigMemoryWithMandatoryHiddenFieldsSchema = MyServersConfigMemorySchema.extend({ + connectionStatus: ConnectionStatusSchema, +}); + +// Infer and export types from Zod schemas +export type MyServersConfig = z.infer; +export type MyServersConfigMemory = z.infer; +export type MyServersConfigMemoryWithMandatoryHiddenFields = z.infer< + typeof MyServersConfigMemoryWithMandatoryHiddenFieldsSchema +>; diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index e7fdfbe401..bae742a51d 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -28,6 +28,7 @@ export class StartCommand extends CommandRunner { ); if (stdout) { this.logger.log(stdout); + process.exit(0); } if (stderr) { this.logger.error(stderr); diff --git a/api/src/unraid-api/cli/validate-token.command.ts b/api/src/unraid-api/cli/validate-token.command.ts index 6febc5b9db..76ecac4b59 100644 --- a/api/src/unraid-api/cli/validate-token.command.ts +++ b/api/src/unraid-api/cli/validate-token.command.ts @@ -29,6 +29,7 @@ export class ValidateTokenCommand extends CommandRunner { async run(passedParams: string[]): Promise { if (passedParams.length !== 1) { this.logger.error('Please pass token argument only'); + process.exit(1); } const token = passedParams[0]; From 99c4ad9f60bf3cc2e89a2bf840015d0f8e1b7fa5 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 18 Jan 2025 11:06:12 -0500 Subject: [PATCH 02/42] feat: enable token sign in with comma separated subs in myservers.config --- .../__test__/common/allowed-origins.test.ts | 54 ++++---- .../files/config-file-normalizer.test.ts | 130 +++++++++--------- .../modules/__snapshots__/config.test.ts.snap | 40 ++++++ api/src/__test__/store/modules/config.test.ts | 43 +----- api/src/store/modules/config.ts | 1 + api/src/types/my-servers-config.ts | 5 +- api/src/unraid-api/cli/restart.command.ts | 49 +++---- .../unraid-api/cli/validate-token.command.ts | 48 ++++--- 8 files changed, 190 insertions(+), 180 deletions(-) create mode 100644 api/src/__test__/store/modules/__snapshots__/config.test.ts.snap diff --git a/api/src/__test__/common/allowed-origins.test.ts b/api/src/__test__/common/allowed-origins.test.ts index 07de93fa93..04e45b88b2 100644 --- a/api/src/__test__/common/allowed-origins.test.ts +++ b/api/src/__test__/common/allowed-origins.test.ts @@ -18,31 +18,31 @@ test('Returns allowed origins', async () => { // Get allowed origins expect(getAllowedOrigins()).toMatchInlineSnapshot(` - [ - "/var/run/unraid-notifications.sock", - "/var/run/unraid-php.sock", - "/var/run/unraid-cli.sock", - "http://localhost:8080", - "https://localhost:4443", - "https://tower.local:4443", - "https://192.168.1.150:4443", - "https://tower:4443", - "https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443", - "https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443", - "https://10-252-0-1.hash.myunraid.net:4443", - "https://10-252-1-1.hash.myunraid.net:4443", - "https://10-253-3-1.hash.myunraid.net:4443", - "https://10-253-4-1.hash.myunraid.net:4443", - "https://10-253-5-1.hash.myunraid.net:4443", - "https://10-100-0-1.hash.myunraid.net:4443", - "https://10-100-0-2.hash.myunraid.net:4443", - "https://10-123-1-2.hash.myunraid.net:4443", - "https://221-123-121-112.hash.myunraid.net:4443", - "https://google.com", - "https://test.com", - "https://connect.myunraid.net", - "https://connect-staging.myunraid.net", - "https://dev-my.myunraid.net:4000", - ] - `); + [ + "/var/run/unraid-notifications.sock", + "/var/run/unraid-php.sock", + "/var/run/unraid-cli.sock", + "http://localhost:8080", + "https://localhost:4443", + "https://tower.local:4443", + "https://192.168.1.150:4443", + "https://tower:4443", + "https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443", + "https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443", + "https://10-252-0-1.hash.myunraid.net:4443", + "https://10-252-1-1.hash.myunraid.net:4443", + "https://10-253-3-1.hash.myunraid.net:4443", + "https://10-253-4-1.hash.myunraid.net:4443", + "https://10-253-5-1.hash.myunraid.net:4443", + "https://10-100-0-1.hash.myunraid.net:4443", + "https://10-100-0-2.hash.myunraid.net:4443", + "https://10-123-1-2.hash.myunraid.net:4443", + "https://221-123-121-112.hash.myunraid.net:4443", + "https://google.com", + "https://test.com", + "https://connect.myunraid.net", + "https://connect-staging.myunraid.net", + "https://dev-my.myunraid.net:4000", + ] + `); }); diff --git a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts index d4b911f0d3..dfffef72c3 100644 --- a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts +++ b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts @@ -29,6 +29,7 @@ test('it creates a FLASH config with NO OPTIONAL values', () => { "localApiKey": "", "refreshtoken": "", "regWizTime": "", + "ssoSubIds": "", "upnpEnabled": "", "username": "", "wanaccess": "", @@ -69,6 +70,7 @@ test('it creates a MEMORY config with NO OPTIONAL values', () => { "localApiKey": "", "refreshtoken": "", "regWizTime": "", + "ssoSubIds": "", "upnpEnabled": "", "username": "", "wanaccess": "", @@ -93,35 +95,36 @@ test('it creates a FLASH config with OPTIONAL values', () => { basicConfig.connectionStatus.upnpStatus = 'Turned On'; const config = getWriteableConfig(basicConfig, 'flash'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "myextra.origins", - "version": "", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "upnpEnabled": "yes", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "myextra.origins", + "version": "", + }, + "local": {}, + "notifier": { + "apikey": "", + }, + "remote": { + "accesstoken": "", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "ssoSubIds": "", + "upnpEnabled": "yes", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "upc": { + "apikey": "", + }, + } + `); }); test('it creates a MEMORY config with OPTIONAL values', () => { @@ -135,38 +138,39 @@ test('it creates a MEMORY config with OPTIONAL values', () => { basicConfig.connectionStatus.upnpStatus = 'Turned On'; const config = getWriteableConfig(basicConfig, 'memory'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "myextra.origins", - "version": "", - }, - "connectionStatus": { - "minigraph": "PRE_INIT", - "upnpStatus": "Turned On", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "upnpEnabled": "yes", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "myextra.origins", + "version": "", + }, + "connectionStatus": { + "minigraph": "PRE_INIT", + "upnpStatus": "Turned On", + }, + "local": {}, + "notifier": { + "apikey": "", + }, + "remote": { + "accesstoken": "", + "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "ssoSubIds": "", + "upnpEnabled": "yes", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "upc": { + "apikey": "", + }, + } + `); }); diff --git a/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap b/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap new file mode 100644 index 0000000000..6875a62928 --- /dev/null +++ b/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap @@ -0,0 +1,40 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Before init returns default values for all fields 1`] = ` +{ + "api": { + "extraOrigins": "", + "version": "", + }, + "connectionStatus": { + "minigraph": "PRE_INIT", + "upnpStatus": "", + }, + "local": {}, + "nodeEnv": "test", + "notifier": { + "apikey": "", + }, + "remote": { + "accesstoken": "", + "allowedOrigins": "", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "ssoSubIds": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "status": "UNLOADED", + "upc": { + "apikey": "", + }, +} +`; diff --git a/api/src/__test__/store/modules/config.test.ts b/api/src/__test__/store/modules/config.test.ts index b1ac6c4d0d..dd98241799 100644 --- a/api/src/__test__/store/modules/config.test.ts +++ b/api/src/__test__/store/modules/config.test.ts @@ -1,46 +1,11 @@ import { expect, test } from 'vitest'; import { store } from '@app/store'; +import { MyServersConfigMemory } from '@app/types/my-servers-config'; test('Before init returns default values for all fields', async () => { const state = store.getState().config; - expect(state).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "", - "version": "", - }, - "connectionStatus": { - "minigraph": "PRE_INIT", - "upnpStatus": "", - }, - "local": {}, - "nodeEnv": "test", - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "allowedOrigins": "", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "upnpEnabled": "", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "status": "UNLOADED", - "upc": { - "apikey": "", - }, - } - `); + expect(state).toMatchSnapshot(); }, 10_000); test('After init returns values from cfg file for all fields', async () => { @@ -77,6 +42,7 @@ test('After init returns values from cfg file for all fields', async () => { localApiKey: '_______________________LOCAL_API_KEY_HERE_________________________', refreshtoken: '', regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0', + ssoSubIds: '', upnpEnabled: 'no', username: 'zspearmint', wanaccess: 'yes', @@ -130,6 +96,7 @@ test('updateUserConfig merges in changes to current state', async () => { localApiKey: '_______________________LOCAL_API_KEY_HERE_________________________', refreshtoken: '', regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0', + ssoSubIds: '', upnpEnabled: 'no', username: 'zspearmint', wanaccess: 'yes', @@ -139,6 +106,6 @@ test('updateUserConfig merges in changes to current state', async () => { upc: { apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', }, - }) + } as MyServersConfigMemory) ); }); diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index fe61d9b332..57dea7d0c9 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -51,6 +51,7 @@ export const initialState: SliceState = { refreshtoken: '', allowedOrigins: '', dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED, + ssoSubIds: '' }, local: {}, api: { diff --git a/api/src/types/my-servers-config.ts b/api/src/types/my-servers-config.ts index 1ac28c7a98..60dbf1d566 100644 --- a/api/src/types/my-servers-config.ts +++ b/api/src/types/my-servers-config.ts @@ -26,16 +26,19 @@ const RemoteConfigSchema = z.object({ idtoken: z.string(), refreshtoken: z.string(), dynamicRemoteAccessType: z.nativeEnum(DynamicRemoteAccessType), + ssoSubIds: z.string(), }); const UpcConfigSchema = z.object({ apikey: z.string(), }); +const LocalConfigSchema = z.object({}); + // Base config schema export const MyServersConfigSchema = z.object({ api: ApiConfigSchema, - local: z.object({}), // Empty object + local: LocalConfigSchema, notifier: NotifierConfigSchema, remote: RemoteConfigSchema, upc: UpcConfigSchema, diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index c1631157d0..ca7e7b31c1 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -1,38 +1,27 @@ -import { execSync } from 'child_process'; -import { join } from 'path'; - - - +import { execa } from 'execa'; import { Command, CommandRunner } from 'nest-commander'; - - import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts'; - - - - +import { LogService } from '@app/unraid-api/cli/log.service'; /** * Stop a running API process and then start it again. */ -@Command({ name: 'restart', description: 'Restart / Start the Unraid API'}) +@Command({ name: 'restart', description: 'Restart / Start the Unraid API' }) export class RestartCommand extends CommandRunner { - async run(_): Promise { - console.log( - 'Dirname is ', - import.meta.dirname, - ' command is ', - `${PM2_PATH} restart ${ECOSYSTEM_PATH} --update-env` - ); - execSync( - `${PM2_PATH} restart ${ECOSYSTEM_PATH} --update-env`, - { - env: process.env, - stdio: 'pipe', - cwd: process.cwd(), - } - ); - } - -} \ No newline at end of file + constructor(private readonly logger: LogService) { + super(); + } + + async run(_): Promise { + const { stderr, stdout } = await execa(PM2_PATH, ['restart', ECOSYSTEM_PATH]); + if (stderr) { + this.logger.error(stderr); + process.exit(1); + } + if (stdout) { + this.logger.info(stdout); + } + process.exit(0); + } +} diff --git a/api/src/unraid-api/cli/validate-token.command.ts b/api/src/unraid-api/cli/validate-token.command.ts index 76ecac4b59..68ad9d877f 100644 --- a/api/src/unraid-api/cli/validate-token.command.ts +++ b/api/src/unraid-api/cli/validate-token.command.ts @@ -7,12 +7,6 @@ import { store } from '@app/store'; import { loadConfigFile } from '@app/store/modules/config'; import { LogService } from '@app/unraid-api/cli/log.service'; -const createJsonErrorString = (errorMessage: string) => - JSON.stringify({ - error: errorMessage, - valid: false, - }); - @Command({ name: 'validate-token', description: 'Returns JSON: { error: string | null, valid: boolean }', @@ -26,6 +20,17 @@ export class ValidateTokenCommand extends CommandRunner { this.JWKSOffline = createLocalJWKSet(JWKS_LOCAL_PAYLOAD); this.JWKSOnline = createRemoteJWKSet(new URL(JWKS_REMOTE_LINK)); } + + private createErrorAndExit = (errorMessage: string) => { + this.logger.error( + JSON.stringify({ + error: errorMessage, + valid: false, + }) + ); + process.exit(1); + }; + async run(passedParams: string[]): Promise { if (passedParams.length !== 1) { this.logger.error('Please pass token argument only'); @@ -50,31 +55,32 @@ export class ValidateTokenCommand extends CommandRunner { if (caughtError) { if (caughtError instanceof Error) { - this.logger.error( - createJsonErrorString(`Caught error validating jwt token: ${caughtError.message}`) - ); + this.createErrorAndExit(`Caught error validating jwt token: ${caughtError.message}`); } else { - this.logger.error(createJsonErrorString('Caught error validating jwt token')); + this.createErrorAndExit('Caught unknown error validating jwt token'); } } if (tokenPayload === null) { - this.logger.error(createJsonErrorString('No data in JWT to use for user validation')); + this.createErrorAndExit('No data in JWT to use for user validation'); } - const username = tokenPayload!.username ?? tokenPayload!['cognito:username']; + const username = tokenPayload?.sub; + + if (!username) { + return this.createErrorAndExit('No ID found in token'); + } const configFile = await store.dispatch(loadConfigFile()).unwrap(); - if (!configFile.remote?.accesstoken) { - this.logger.error(createJsonErrorString('No local user token set to compare to')); + if (!configFile.remote?.ssoSubIds) { + this.createErrorAndExit( + 'No local user token set to compare to - please set any valid SSO IDs you would like to sign in with' + ); } - - const existingUserPayload = decodeJwt(configFile.remote?.accesstoken); - if (username === existingUserPayload.username) { - this.logger.info(JSON.stringify({ error: null, valid: true })); + const possibleUserIds = configFile.remote.ssoSubIds.split(','); + if (possibleUserIds.includes(username)) { + this.logger.info(JSON.stringify({ error: null, valid: true, username })); } else { - this.logger.error( - createJsonErrorString('Username on token does not match logged in user name') - ); + this.createErrorAndExit('Username on token does not match'); } } } From 6d094a9395ac1e96247334d879fc6e881298a558 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 15:10:45 -0500 Subject: [PATCH 03/42] feat: cleanup config entries --- api/dev/Unraid.net/myservers.cfg | 5 +- api/dev/states/myservers.cfg | 8 +- .../utils/files/config-file-normalizer.ts | 20 -- api/src/store/modules/config.ts | 204 +++++++----------- api/src/store/watch/config-watch.ts | 55 +++-- api/src/types/my-servers-config.ts | 32 +-- .../unraid-api/cli/validate-token.command.ts | 4 +- 7 files changed, 123 insertions(+), 205 deletions(-) diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index e76722fdd1..5b9ec05815 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -3,7 +3,6 @@ version="3.11.0" extraOrigins="https://google.com,https://test.com" [local] [notifier] -apikey="unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5" [remote] wanaccess="yes" wanport="8443" @@ -14,9 +13,9 @@ email="test@example.com" username="zspearmint" avatar="https://via.placeholder.com/200" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" -idtoken="" accesstoken="" +idtoken="" refreshtoken="" dynamicRemoteAccessType="DISABLED" +ssoSubIds="" [upc] -apikey="unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810" diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index 621129eaa1..6f20d6ff55 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -3,7 +3,6 @@ version="3.11.0" extraOrigins="https://google.com,https://test.com" [local] [notifier] -apikey="unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5" [remote] wanaccess="yes" wanport="8443" @@ -14,12 +13,13 @@ email="test@example.com" username="zspearmint" avatar="https://via.placeholder.com/200" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" -idtoken="" accesstoken="" +idtoken="" refreshtoken="" -allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" dynamicRemoteAccessType="DISABLED" +ssoSubIds="" +allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" [upc] -apikey="unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810" [connectionStatus] minigraph="PRE_INIT" +upnpStatus="" diff --git a/api/src/core/utils/files/config-file-normalizer.ts b/api/src/core/utils/files/config-file-normalizer.ts index d7f5009136..755ed4e47d 100644 --- a/api/src/core/utils/files/config-file-normalizer.ts +++ b/api/src/core/utils/files/config-file-normalizer.ts @@ -42,23 +42,3 @@ export const getWriteableConfig = ( return schema.parse(mergedConfig) as any; // Narrowing ensures correct typing }; - -/** - * Check if two configurations are equivalent by normalizing them through the Zod schema. - */ -export const areConfigsEquivalent = ( - newConfigFile: Partial, // Use Partial here for flexibility - currentConfig: MyServersConfig -): boolean => { - // Parse and validate the new config file using the schema (with default values applied) - const normalizedNewConfig = MyServersConfigSchema.parse({ - ...currentConfig, // Use currentConfig as a baseline to fill missing fields - ...newConfigFile, - }); - - // Get the writeable configuration for the current config - const normalizedCurrentConfig = getWriteableConfig(currentConfig, 'flash'); - - // Compare the normalized configurations - return isEqual(normalizedNewConfig, normalizedCurrentConfig); -}; diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 57dea7d0c9..91123a22c5 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -1,32 +1,29 @@ -import { parseConfig } from '@app/core/utils/misc/parse-config'; -import { - type MyServersConfig, - type MyServersConfigMemory, -} from '@app/types/my-servers-config'; -import { - createAsyncThunk, - createSlice, - type PayloadAction, -} from '@reduxjs/toolkit'; -import { access } from 'fs/promises'; -import merge from 'lodash/merge'; -import { FileLoadStatus } from '@app/store/types'; import { F_OK } from 'constants'; -import { type RecursivePartial } from '@app/types'; -import { DynamicRemoteAccessType, MinigraphStatus, type Owner } from '@app/graphql/generated/api/types'; -import { type RootState } from '@app/store'; import { randomBytes } from 'crypto'; +import { writeFileSync } from 'fs'; +import { access } from 'fs/promises'; + +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash-es'; +import merge from 'lodash/merge'; + +import type { Owner } from '@app/graphql/generated/api/types'; import { logger } from '@app/core/log'; -import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status'; +import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub'; import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer'; -import { writeFileSync } from 'fs'; import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; -import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub'; -import { isEqual } from 'lodash-es'; -import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access'; +import { parseConfig } from '@app/core/utils/misc/parse-config'; import { NODE_ENV } from '@app/environment'; +import { DynamicRemoteAccessType, MinigraphStatus } from '@app/graphql/generated/api/types'; import { GraphQLClient } from '@app/mothership/graphql-client'; import { stopPingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs'; +import { type RootState } from '@app/store'; +import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status'; +import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access'; +import { FileLoadStatus } from '@app/store/types'; +import { type RecursivePartial } from '@app/types'; +import { type MyServersConfig, type MyServersConfigMemory } from '@app/types/my-servers-config'; export type SliceState = { status: FileLoadStatus; @@ -51,7 +48,7 @@ export const initialState: SliceState = { refreshtoken: '', allowedOrigins: '', dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED, - ssoSubIds: '' + ssoSubIds: '', }, local: {}, api: { @@ -71,8 +68,8 @@ export const initialState: SliceState = { } as const; export const loginUser = createAsyncThunk< - Pick, - Pick, + Pick, + Pick, { state: RootState } >('config/login-user', async (userInfo) => { logger.info('Logging in user: %s', userInfo.username); @@ -84,29 +81,28 @@ export const loginUser = createAsyncThunk< return userInfo; }); -export const logoutUser = createAsyncThunk< - void, - { reason?: string }, - { state: RootState } ->('config/logout-user', async ({ reason }) => { - logger.info('Logging out user: %s', reason ?? 'No reason provided'); - const { pubsub } = await import('@app/core/pubsub'); +export const logoutUser = createAsyncThunk( + 'config/logout-user', + async ({ reason }) => { + logger.info('Logging out user: %s', reason ?? 'No reason provided'); + const { pubsub } = await import('@app/core/pubsub'); - // Publish to servers endpoint - await pubsub.publish(PUBSUB_CHANNEL.SERVERS, { - servers: [], - }); + // Publish to servers endpoint + await pubsub.publish(PUBSUB_CHANNEL.SERVERS, { + servers: [], + }); - const owner: Owner = { - username: 'root', - url: '', - avatar: '', - }; - // Publish to owner endpoint - await pubsub.publish(PUBSUB_CHANNEL.OWNER, { owner }); - stopPingTimeoutJobs(); - await GraphQLClient.clearInstance(); -}); + const owner: Owner = { + username: 'root', + url: '', + avatar: '', + }; + // Publish to owner endpoint + await pubsub.publish(PUBSUB_CHANNEL.OWNER, { owner }); + stopPingTimeoutJobs(); + await GraphQLClient.clearInstance(); + } +); /** * Load the myservers.cfg into the store. Returns null if the state after loading doesn't change @@ -128,32 +124,6 @@ type LoadFailureConfigEqual = { }; type ConfigRejectedValues = LoadFailureConfigEqual | LoadFailureWithConfig; -const generateApiKeysIfNotExistent = ( - file: RecursivePartial -): MyServersConfig => { - const newConfigFile = merge(file, { - upc: { - apikey: - file.upc?.apikey?.trim()?.length === 64 - ? file.upc?.apikey - : `unupc_${randomBytes(58).toString('hex')}`.substring( - 0, - 64 - ), - }, - notifier: { - apikey: - file.notifier?.apikey?.trim().length === 64 - ? file.notifier?.apikey - : `unnotify_${randomBytes(58).toString('hex')}`.substring( - 0, - 64 - ), - }, - }) as MyServersConfig; - return newConfigFile; -}; - export const loadConfigFile = createAsyncThunk< MyServersConfig, string | undefined, @@ -161,71 +131,51 @@ export const loadConfigFile = createAsyncThunk< state: RootState; rejectValue: ConfigRejectedValues; } ->( - 'config/load-config-file', - async (filePath, { getState, rejectWithValue }) => { - try { - const { paths, config } = getState(); - - const path = filePath ?? paths['myservers-config']; +>('config/load-config-file', async (filePath, { getState, rejectWithValue }) => { + try { + const { paths, config } = getState(); - const fileExists = await access(path, F_OK) - .then(() => true) - .catch(() => false); - if (!fileExists) { - throw new Error('Config File Missing'); - } + const path = filePath ?? paths['myservers-config']; - const file = fileExists - ? parseConfig>({ - filePath: path, - type: 'ini', - }) - : {}; + const fileExists = await access(path, F_OK) + .then(() => true) + .catch(() => false); + if (!fileExists) { + throw new Error('Config File Missing'); + } - const newConfigFile = generateApiKeysIfNotExistent(file); + const newConfigFile = getWriteableConfig( + parseConfig({ filePath: path, type: 'ini' }), + 'flash' + ); - const isNewlyLoadedConfigEqual = isEqual( - getWriteableConfig(newConfigFile as SliceState, 'flash'), - getWriteableConfig(config, 'flash') - ); - if (isNewlyLoadedConfigEqual) { - logger.warn( - 'Not loading config because it is the same as before' - ); - return rejectWithValue({ - type: CONFIG_LOAD_ERROR.CONFIG_EQUAL, - }); - } - return newConfigFile; - } catch (error: unknown) { - logger.warn('Config file is corrupted with error: %o - recreating config', error); - const config = getWriteableConfig(initialState, 'flash'); - const newConfig = generateApiKeysIfNotExistent(config); - newConfig.remote.wanaccess = 'no'; - const serializedConfig = safelySerializeObjectToIni(newConfig); - writeFileSync( - getState().paths['myservers-config'], - serializedConfig - ); + const isNewlyLoadedConfigEqual = isEqual(newConfigFile, getWriteableConfig(config, 'flash')); + if (isNewlyLoadedConfigEqual) { + logger.warn('Not loading config because it is the same as before'); return rejectWithValue({ - type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED, - error: - error instanceof Error ? error : new Error('Unknown Error'), - config: newConfig, + type: CONFIG_LOAD_ERROR.CONFIG_EQUAL, }); } + return newConfigFile; + } catch (error: unknown) { + logger.warn('Config file is corrupted with error: %o - recreating config', error); + const newConfig = getWriteableConfig(initialState, 'flash'); + newConfig.remote.wanaccess = 'no'; + const serializedConfig = safelySerializeObjectToIni(newConfig); + writeFileSync(getState().paths['myservers-config'], serializedConfig); + return rejectWithValue({ + type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED, + error: error instanceof Error ? error : new Error('Unknown Error'), + config: newConfig, + }); } -); +}); export const config = createSlice({ name: 'config', initialState, reducers: { - updateUserConfig( - state, - action: PayloadAction> - ) { + updateUserConfig(state, action: PayloadAction>) { return merge(state, action.payload); }, updateAccessTokens( @@ -287,10 +237,7 @@ export const config = createSlice({ state.status = FileLoadStatus.LOADED; break; case CONFIG_LOAD_ERROR.CONFIG_CORRUPTED: - logger.debug( - 'Config File Load Failed - %o', - action.payload.error - ); + logger.debug('Config File Load Failed - %o', action.payload.error); merge(state, action.payload.config); state.status = FileLoadStatus.LOADED; break; @@ -333,8 +280,7 @@ export const config = createSlice({ builder.addCase(setupRemoteAccessThunk.fulfilled, (state, action) => { state.remote.wanaccess = action.payload.wanaccess; - state.remote.dynamicRemoteAccessType = - action.payload.dynamicRemoteAccessType; + state.remote.dynamicRemoteAccessType = action.payload.dynamicRemoteAccessType; state.remote.wanport = action.payload.wanport; state.remote.upnpEnabled = action.payload.upnpEnabled; }); diff --git a/api/src/store/watch/config-watch.ts b/api/src/store/watch/config-watch.ts index 25ef6c685d..14808f23b8 100644 --- a/api/src/store/watch/config-watch.ts +++ b/api/src/store/watch/config-watch.ts @@ -1,29 +1,38 @@ -import { getters, store } from '@app/store'; +import { existsSync, writeFileSync } from 'fs'; + import { watch } from 'chokidar'; -import { loadConfigFile, logoutUser } from '@app/store/modules/config'; + import { logger } from '@app/core/log'; -import { existsSync, writeFileSync } from 'fs'; +import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer'; +import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; import { CHOKIDAR_USEPOLLING, ENVIRONMENT } from '@app/environment'; +import { getters, store } from '@app/store'; +import { initialState, loadConfigFile, logoutUser } from '@app/store/modules/config'; export const setupConfigPathWatch = () => { - const myServersConfigPath = getters.paths()?.['myservers-config']; - if (myServersConfigPath) { - logger.info('Watch Setup on Config Path: %s', myServersConfigPath); - if (!existsSync(myServersConfigPath)) { - writeFileSync(myServersConfigPath, '', 'utf-8'); - } - const watcher = watch(myServersConfigPath, { - persistent: true, - ignoreInitial: false, - usePolling: CHOKIDAR_USEPOLLING === true, - }).on('change', async () => { - await store.dispatch(loadConfigFile()); - }).on('unlink', async () => { - watcher.close(); - setupConfigPathWatch(); - store.dispatch(logoutUser({ reason: 'Config File was Deleted'})) - }); - } else { - logger.error('[FATAL] Failed to setup watch on My Servers Config (Could Not Read Config Path)'); - } + const myServersConfigPath = getters.paths()?.['myservers-config']; + if (myServersConfigPath) { + logger.info('Watch Setup on Config Path: %s', myServersConfigPath); + if (!existsSync(myServersConfigPath)) { + const config = safelySerializeObjectToIni(getWriteableConfig(initialState, 'flash')); + writeFileSync(myServersConfigPath, config, 'utf-8'); + } + const watcher = watch(myServersConfigPath, { + persistent: true, + ignoreInitial: false, + usePolling: CHOKIDAR_USEPOLLING === true, + }) + .on('change', async () => { + await store.dispatch(loadConfigFile()); + }) + .on('unlink', async () => { + const config = safelySerializeObjectToIni(getWriteableConfig(initialState, 'flash')); + await writeFileSync(myServersConfigPath, config, 'utf-8'); + watcher.close(); + setupConfigPathWatch(); + store.dispatch(logoutUser({ reason: 'Config File was Deleted' })); + }); + } else { + logger.error('[FATAL] Failed to setup watch on My Servers Config (Could Not Read Config Path)'); + } }; diff --git a/api/src/types/my-servers-config.ts b/api/src/types/my-servers-config.ts index 60dbf1d566..a7c224e2d7 100644 --- a/api/src/types/my-servers-config.ts +++ b/api/src/types/my-servers-config.ts @@ -8,10 +8,6 @@ const ApiConfigSchema = z.object({ extraOrigins: z.string(), }); -const NotifierConfigSchema = z.object({ - apikey: z.string(), -}); - const RemoteConfigSchema = z.object({ wanaccess: z.string(), wanport: z.string(), @@ -29,20 +25,16 @@ const RemoteConfigSchema = z.object({ ssoSubIds: z.string(), }); -const UpcConfigSchema = z.object({ - apikey: z.string(), -}); - const LocalConfigSchema = z.object({}); // Base config schema -export const MyServersConfigSchema = z.object({ - api: ApiConfigSchema, - local: LocalConfigSchema, - notifier: NotifierConfigSchema, - remote: RemoteConfigSchema, - upc: UpcConfigSchema, -}); +export const MyServersConfigSchema = z + .object({ + api: ApiConfigSchema, + local: LocalConfigSchema, + remote: RemoteConfigSchema, + }) + .strip(); // Memory config schema export const ConnectionStatusSchema = z.object({ @@ -55,16 +47,8 @@ export const MyServersConfigMemorySchema = MyServersConfigSchema.extend({ remote: RemoteConfigSchema.extend({ allowedOrigins: z.string(), }), -}); - -// Memory config with mandatory hidden fields schema -export const MyServersConfigMemoryWithMandatoryHiddenFieldsSchema = MyServersConfigMemorySchema.extend({ - connectionStatus: ConnectionStatusSchema, -}); +}).strip(); // Infer and export types from Zod schemas export type MyServersConfig = z.infer; export type MyServersConfigMemory = z.infer; -export type MyServersConfigMemoryWithMandatoryHiddenFields = z.infer< - typeof MyServersConfigMemoryWithMandatoryHiddenFieldsSchema ->; diff --git a/api/src/unraid-api/cli/validate-token.command.ts b/api/src/unraid-api/cli/validate-token.command.ts index 68ad9d877f..0b0bc663e0 100644 --- a/api/src/unraid-api/cli/validate-token.command.ts +++ b/api/src/unraid-api/cli/validate-token.command.ts @@ -42,11 +42,11 @@ export class ValidateTokenCommand extends CommandRunner { let caughtError: null | unknown = null; let tokenPayload: null | JWTPayload = null; try { - this.logger.debug('Attempting to validate token with local key'); + // this.logger.debug('Attempting to validate token with local key'); tokenPayload = (await jwtVerify(token, this.JWKSOffline)).payload; } catch (error: unknown) { try { - this.logger.debug('Local validation failed for key, trying remote validation'); + // this.logger.debug('Local validation failed for key, trying remote validation'); tokenPayload = (await jwtVerify(token, this.JWKSOnline)).payload; } catch (error: unknown) { caughtError = error; From a8076a4a59182bc2405b2b9205b189921a722f57 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 15:11:16 -0500 Subject: [PATCH 04/42] feat: remove unused config sections --- api/src/store/modules/config.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 91123a22c5..674330c63f 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -55,12 +55,6 @@ export const initialState: SliceState = { extraOrigins: '', version: '', }, - upc: { - apikey: '', - }, - notifier: { - apikey: '', - }, connectionStatus: { minigraph: MinigraphStatus.PRE_INIT, upnpStatus: '', From 0907f79551ca466550edc899825d1500f270b7b0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 15:13:27 -0500 Subject: [PATCH 05/42] fix: start command simplification --- api/src/unraid-api/cli/start.command.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index bae742a51d..7a81c36535 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -21,19 +21,19 @@ export class StartCommand extends CommandRunner { async run(_, options: StartCommandOptions): Promise { this.logger.info('Starting the Unraid API'); const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : ''; - const { stderr, stdout } = await execa( - `${envLog} ${PM2_PATH}`.trim(), - ['start', ECOSYSTEM_PATH, '--update-env'], - { stdio: 'inherit' } - ); + const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [ + 'start', + ECOSYSTEM_PATH, + '--update-env', + ]); if (stdout) { this.logger.log(stdout); - process.exit(0); } if (stderr) { this.logger.error(stderr); process.exit(1); } + process.exit(0); } @Option({ From c84768f3f105655ff36a6603eb75ae2adb3caa90 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 19:07:21 -0500 Subject: [PATCH 06/42] feat: only write config when a specific config update action occurs --- api/dev/Unraid.net/myservers.cfg | 10 ++-- api/dev/states/myservers.cfg | 12 ++-- api/src/store/listeners/config-listener.ts | 64 ++++------------------ api/src/store/modules/config.ts | 23 +++++++- api/src/store/watch/config-watch.ts | 3 +- api/src/unraid-api/auth/header.strategy.ts | 4 +- 6 files changed, 45 insertions(+), 71 deletions(-) diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index 5b9ec05815..d66bffad26 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -2,20 +2,18 @@ version="3.11.0" extraOrigins="https://google.com,https://test.com" [local] -[notifier] [remote] wanaccess="yes" wanport="8443" upnpEnabled="no" -apikey="_______________________BIG_API_KEY_HERE_________________________" +apikey="unraid_SfxcHvPqI5MIUE51KHJCb5m21QbIZeowqN3XT4QFHLJm0NQ2ZHAqUuMKW" localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" -email="test@example.com" -username="zspearmint" -avatar="https://via.placeholder.com/200" +email="ekbosley@gmail.com" +username="Hi" +avatar="" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" accesstoken="" idtoken="" refreshtoken="" dynamicRemoteAccessType="DISABLED" ssoSubIds="" -[upc] diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index 6f20d6ff55..4fad18602e 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -2,16 +2,15 @@ version="3.11.0" extraOrigins="https://google.com,https://test.com" [local] -[notifier] [remote] wanaccess="yes" wanport="8443" upnpEnabled="no" -apikey="_______________________BIG_API_KEY_HERE_________________________" +apikey="unraid_SfxcHvPqI5MIUE51KHJCb5m21QbIZeowqN3XT4QFHLJm0NQ2ZHAqUuMKW" localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" -email="test@example.com" -username="zspearmint" -avatar="https://via.placeholder.com/200" +email="" +username="Hi" +avatar="" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" accesstoken="" idtoken="" @@ -19,7 +18,6 @@ refreshtoken="" dynamicRemoteAccessType="DISABLED" ssoSubIds="" allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" -[upc] [connectionStatus] -minigraph="PRE_INIT" +minigraph="ERROR_RETRYING" upnpStatus="" diff --git a/api/src/store/listeners/config-listener.ts b/api/src/store/listeners/config-listener.ts index 0b182c8dbc..fdd691caaf 100644 --- a/api/src/store/listeners/config-listener.ts +++ b/api/src/store/listeners/config-listener.ts @@ -1,66 +1,22 @@ -import { startAppListening } from '@app/store/listeners/listener-middleware'; -import { isEqual } from 'lodash-es'; -import { logger } from '@app/core/log'; -import { - type ConfigType, - getWriteableConfig, -} from '@app/core/utils/files/config-file-normalizer'; -import { - loadConfigFile, - loginUser, - logoutUser, -} from '@app/store/modules/config'; -import { FileLoadStatus } from '@app/store/types'; -import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; -import { isFulfilled } from '@reduxjs/toolkit'; -import { environment } from '@app/environment'; import { writeFileSync } from 'fs'; -const actionIsLoginOrLogout = isFulfilled(logoutUser, loginUser); + +import type { ConfigType } from '@app/core/utils/files/config-file-normalizer'; +import { logger } from '@app/core/log'; +import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer'; +import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; +import { startAppListening } from '@app/store/listeners/listener-middleware'; +import { configUpdateActionsFlash, configUpdateActionsMemory } from '@app/store/modules/config'; export const enableConfigFileListener = (mode: ConfigType) => () => startAppListening({ - predicate(action, currentState, previousState) { - if (!environment.IS_MAIN_PROCESS) { - return false; - } - - if (currentState.config.status === FileLoadStatus.LOADED) { - const oldFlashConfig = previousState?.config.api.version - ? getWriteableConfig(previousState.config, mode) - : null; - const newFlashConfig = getWriteableConfig( - currentState.config, - mode - ); - - if ( - !isEqual(oldFlashConfig, newFlashConfig) && - action.type !== loadConfigFile.fulfilled.type && - action.type !== loadConfigFile.rejected.type - ) { - return true; - } - - if (actionIsLoginOrLogout(action) && mode === 'memory') { - logger.trace( - 'Logout / Login Action Encountered, writing memory config' - ); - return true; - } - } - - return false; - }, + matcher: mode === 'flash' ? configUpdateActionsFlash : configUpdateActionsMemory, async effect(_, { getState }) { const { paths, config } = getState(); const pathToWrite = - mode === 'flash' - ? paths['myservers-config'] - : paths['myservers-config-states']; + mode === 'flash' ? paths['myservers-config'] : paths['myservers-config-states']; const writeableConfig = getWriteableConfig(config, mode); - const serializedConfig = - safelySerializeObjectToIni(writeableConfig); + const serializedConfig = safelySerializeObjectToIni(writeableConfig); logger.debug('Writing updated config to %s', pathToWrite); writeFileSync(pathToWrite, serializedConfig); }, diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 674330c63f..c05991bc1d 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -4,7 +4,7 @@ import { writeFileSync } from 'fs'; import { access } from 'fs/promises'; import type { PayloadAction } from '@reduxjs/toolkit'; -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice, isAnyOf } from '@reduxjs/toolkit'; import { isEqual } from 'lodash-es'; import merge from 'lodash/merge'; @@ -24,6 +24,7 @@ import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access'; import { FileLoadStatus } from '@app/store/types'; import { type RecursivePartial } from '@app/types'; import { type MyServersConfig, type MyServersConfigMemory } from '@app/types/my-servers-config'; +import { isFulfilled } from '@app/utils'; export type SliceState = { status: FileLoadStatus; @@ -291,4 +292,24 @@ export const { setWanAccess, } = actions; +/** + * Actions that should trigger a flash write + */ +export const configUpdateActionsFlash = isAnyOf( + updateUserConfig, + updateAccessTokens, + updateAllowedOrigins, + setUpnpState, + setWanPortToValue, + setWanAccess, + setupRemoteAccessThunk.fulfilled, + logoutUser.fulfilled, + loginUser.fulfilled +); + +/** + * Actions that should trigger a memory write + */ +export const configUpdateActionsMemory = isAnyOf(configUpdateActionsFlash, setGraphqlConnectionStatus); + export const configReducer = reducer; diff --git a/api/src/store/watch/config-watch.ts b/api/src/store/watch/config-watch.ts index 14808f23b8..efd4cbc901 100644 --- a/api/src/store/watch/config-watch.ts +++ b/api/src/store/watch/config-watch.ts @@ -22,7 +22,8 @@ export const setupConfigPathWatch = () => { ignoreInitial: false, usePolling: CHOKIDAR_USEPOLLING === true, }) - .on('change', async () => { + .on('change', async (change) => { + logger.trace('Config File Changed, Reloading Config %s', change); await store.dispatch(loadConfigFile()); }) .on('unlink', async () => { diff --git a/api/src/unraid-api/auth/header.strategy.ts b/api/src/unraid-api/auth/header.strategy.ts index e7c0570909..1e36efaea2 100644 --- a/api/src/unraid-api/auth/header.strategy.ts +++ b/api/src/unraid-api/auth/header.strategy.ts @@ -35,14 +35,14 @@ export class ServerHeaderStrategy extends PassportStrategy(Strategy, 'server-htt try { const user = await this.authService.validateApiKeyCasbin(key); - this.logger.debug('API key validation successful', { + this.logger.debug('API key validation successful %o', { userId: user?.id, roles: user?.roles, }); return user; } catch (error) { - this.logger.error('API key validation failed', { + this.logger.error('API key validation failed %o', { errorType: error instanceof Error ? error.constructor.name : 'Unknown', message: error instanceof Error ? error.message : 'Unknown error', }); From dd0cf0d969ba4e1c86e642484013408744c4331f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 19:11:35 -0500 Subject: [PATCH 07/42] fix: reset config to be closer to default --- api/dev/Unraid.net/myservers.cfg | 2 +- api/dev/states/myservers.cfg | 2 +- api/src/store/modules/config.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index d66bffad26..4bbe990eec 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -8,7 +8,7 @@ wanport="8443" upnpEnabled="no" apikey="unraid_SfxcHvPqI5MIUE51KHJCb5m21QbIZeowqN3XT4QFHLJm0NQ2ZHAqUuMKW" localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" -email="ekbosley@gmail.com" +email="eli@gmail.com" username="Hi" avatar="" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index 4fad18602e..a9251b4fc2 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -9,7 +9,7 @@ upnpEnabled="no" apikey="unraid_SfxcHvPqI5MIUE51KHJCb5m21QbIZeowqN3XT4QFHLJm0NQ2ZHAqUuMKW" localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" email="" -username="Hi" +username="" avatar="" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" accesstoken="" diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index c05991bc1d..9fcef11c00 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -270,7 +270,7 @@ export const config = createSlice({ }); builder.addCase(setGraphqlConnectionStatus, (state, action) => { - state.connectionStatus.minigraph = action.payload.status; + state.connectionStatus.minigraph = action.payload.status; }); builder.addCase(setupRemoteAccessThunk.fulfilled, (state, action) => { From 3e3c282751bc4d7974486e7c96f98ab2f198c5d9 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 19:12:44 -0500 Subject: [PATCH 08/42] fix: back to default configs --- api/dev/Unraid.net/myservers.cfg | 6 +++--- api/dev/states/myservers.cfg | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index 4bbe990eec..c1b477aa81 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -6,10 +6,10 @@ extraOrigins="https://google.com,https://test.com" wanaccess="yes" wanport="8443" upnpEnabled="no" -apikey="unraid_SfxcHvPqI5MIUE51KHJCb5m21QbIZeowqN3XT4QFHLJm0NQ2ZHAqUuMKW" +apikey="_______________________BIG_API_KEY_HERE_________________________" localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" -email="eli@gmail.com" -username="Hi" +email="test@example.com" +username="https://via.placeholder.com/200" avatar="" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" accesstoken="" diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index a9251b4fc2..1af7aba986 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -6,10 +6,10 @@ extraOrigins="https://google.com,https://test.com" wanaccess="yes" wanport="8443" upnpEnabled="no" -apikey="unraid_SfxcHvPqI5MIUE51KHJCb5m21QbIZeowqN3XT4QFHLJm0NQ2ZHAqUuMKW" +apikey="_______________________BIG_API_KEY_HERE_________________________" localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" -email="" -username="" +email="test@example.com" +username="https://via.placeholder.com/200" avatar="" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" accesstoken="" @@ -19,5 +19,5 @@ dynamicRemoteAccessType="DISABLED" ssoSubIds="" allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" [connectionStatus] -minigraph="ERROR_RETRYING" +minigraph="PRE_INIT" upnpStatus="" From 00ce976bbc8957eab6c4f9078377cfa7483886a2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 22:02:54 -0500 Subject: [PATCH 09/42] feat: csv validation --- api/src/types/my-servers-config.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/api/src/types/my-servers-config.ts b/api/src/types/my-servers-config.ts index a7c224e2d7..36dea5e469 100644 --- a/api/src/types/my-servers-config.ts +++ b/api/src/types/my-servers-config.ts @@ -22,7 +22,23 @@ const RemoteConfigSchema = z.object({ idtoken: z.string(), refreshtoken: z.string(), dynamicRemoteAccessType: z.nativeEnum(DynamicRemoteAccessType), - ssoSubIds: z.string(), + ssoSubIds: z + .string() + .transform((val) => { + // If valid, return as is + if (val === '' || val.split(',').every((id) => id.trim().match(/^[a-zA-Z0-9-]+$/))) { + return val; + } + // Otherwise, replace with an empty string + return ''; + }) + .refine( + (val) => val === '' || val.split(',').every((id) => id.trim().match(/^[a-zA-Z0-9-]+$/)), + { + message: + 'ssoSubIds must be empty or a comma-separated list of alphanumeric strings with dashes', + } + ), }); const LocalConfigSchema = z.object({}); From 33b3c964f62d2ee4779c56ce85aed69d581cb9c8 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 22:11:47 -0500 Subject: [PATCH 10/42] fix: stop command exits --- api/src/unraid-api/cli/stop.command.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts index 4841821a12..bf5ec3770d 100644 --- a/api/src/unraid-api/cli/stop.command.ts +++ b/api/src/unraid-api/cli/stop.command.ts @@ -19,5 +19,6 @@ export class StopCommand extends CommandRunner { this.logger.warn(stderr); process.exit(1); } + process.exit(0); } } From 5dfd0a71daa652e10effa9db9ff9411bfb036415 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 22:21:04 -0500 Subject: [PATCH 11/42] fix: unit tests updated --- api/dev/Unraid.net/myservers.cfg | 4 ++-- .../files/config-file-normalizer.test.ts | 24 ------------------- .../utils/images/image-file-helpers.test.ts | 12 +++++----- .../modules/__snapshots__/config.test.ts.snap | 6 ----- api/src/__test__/store/modules/config.test.ts | 12 ---------- 5 files changed, 8 insertions(+), 50 deletions(-) diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index c1b477aa81..18976e3fd6 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -9,8 +9,8 @@ upnpEnabled="no" apikey="_______________________BIG_API_KEY_HERE_________________________" localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" email="test@example.com" -username="https://via.placeholder.com/200" -avatar="" +username="zspearmint" +avatar="https://via.placeholder.com/200" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" accesstoken="" idtoken="" diff --git a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts index dfffef72c3..c3fea33220 100644 --- a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts +++ b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts @@ -16,9 +16,6 @@ test('it creates a FLASH config with NO OPTIONAL values', () => { "version": "", }, "local": {}, - "notifier": { - "apikey": "", - }, "remote": { "accesstoken": "", "apikey": "", @@ -35,9 +32,6 @@ test('it creates a FLASH config with NO OPTIONAL values', () => { "wanaccess": "", "wanport": "", }, - "upc": { - "apikey": "", - }, } `); }); @@ -56,9 +50,6 @@ test('it creates a MEMORY config with NO OPTIONAL values', () => { "upnpStatus": "", }, "local": {}, - "notifier": { - "apikey": "", - }, "remote": { "accesstoken": "", "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", @@ -76,9 +67,6 @@ test('it creates a MEMORY config with NO OPTIONAL values', () => { "wanaccess": "", "wanport": "", }, - "upc": { - "apikey": "", - }, } `); }); @@ -101,9 +89,6 @@ test('it creates a FLASH config with OPTIONAL values', () => { "version": "", }, "local": {}, - "notifier": { - "apikey": "", - }, "remote": { "accesstoken": "", "apikey": "", @@ -120,9 +105,6 @@ test('it creates a FLASH config with OPTIONAL values', () => { "wanaccess": "", "wanport": "", }, - "upc": { - "apikey": "", - }, } `); }); @@ -148,9 +130,6 @@ test('it creates a MEMORY config with OPTIONAL values', () => { "upnpStatus": "Turned On", }, "local": {}, - "notifier": { - "apikey": "", - }, "remote": { "accesstoken": "", "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", @@ -168,9 +147,6 @@ test('it creates a MEMORY config with OPTIONAL values', () => { "wanaccess": "", "wanport": "", }, - "upc": { - "apikey": "", - }, } `); }); diff --git a/api/src/__test__/core/utils/images/image-file-helpers.test.ts b/api/src/__test__/core/utils/images/image-file-helpers.test.ts index 2cd25f9c3b..bf9b63830a 100644 --- a/api/src/__test__/core/utils/images/image-file-helpers.test.ts +++ b/api/src/__test__/core/utils/images/image-file-helpers.test.ts @@ -4,20 +4,20 @@ import { store } from "@app/store/index"; import { expect, test } from "vitest"; -test('get case path returns expected result', () => { - expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png') +test('get case path returns expected result', async () => { + await expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png') }) -test('get banner path returns null (state unloaded)', () => { - expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null') +test('get banner path returns null (state unloaded)', async () => { + await expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null') }) test('get banner path returns the banner (state loaded)', async() => { await store.dispatch(loadDynamixConfigFile()).unwrap(); - expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png'); + await expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png'); }) test('get banner path returns null when no banner (state loaded)', async () => { await store.dispatch(loadDynamixConfigFile()).unwrap(); - expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null'); + await expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null'); }); \ No newline at end of file diff --git a/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap b/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap index 6875a62928..0ac0907817 100644 --- a/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap +++ b/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap @@ -12,9 +12,6 @@ exports[`Before init returns default values for all fields 1`] = ` }, "local": {}, "nodeEnv": "test", - "notifier": { - "apikey": "", - }, "remote": { "accesstoken": "", "allowedOrigins": "", @@ -33,8 +30,5 @@ exports[`Before init returns default values for all fields 1`] = ` "wanport": "", }, "status": "UNLOADED", - "upc": { - "apikey": "", - }, } `; diff --git a/api/src/__test__/store/modules/config.test.ts b/api/src/__test__/store/modules/config.test.ts index dd98241799..484960a440 100644 --- a/api/src/__test__/store/modules/config.test.ts +++ b/api/src/__test__/store/modules/config.test.ts @@ -28,9 +28,6 @@ test('After init returns values from cfg file for all fields', async () => { }, local: {}, nodeEnv: 'test', - notifier: { - apikey: 'unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5', - }, remote: { accesstoken: '', allowedOrigins: '', @@ -49,9 +46,6 @@ test('After init returns values from cfg file for all fields', async () => { wanport: '8443', }, status: 'LOADED', - upc: { - apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', - }, }) ); }); @@ -82,9 +76,6 @@ test('updateUserConfig merges in changes to current state', async () => { }, local: {}, nodeEnv: 'test', - notifier: { - apikey: 'unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5', - }, remote: { accesstoken: '', allowedOrigins: '', @@ -103,9 +94,6 @@ test('updateUserConfig merges in changes to current state', async () => { wanport: '8443', }, status: 'LOADED', - upc: { - apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', - }, } as MyServersConfigMemory) ); }); From c6c9f7dc13f0dd7b54f43fa101f812f37ad6590b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 19 Jan 2025 23:14:03 -0500 Subject: [PATCH 12/42] feat: remove unused fields --- api/src/store/modules/config.ts | 2 +- api/src/unraid-api/cli/cli.module.ts | 4 ++- api/src/unraid-api/cli/config.command.ts | 25 +++++++++++++++++++ api/src/unraid-api/cli/restart.command.ts | 2 +- .../unraid-api/cli/validate-token.command.ts | 1 + 5 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 api/src/unraid-api/cli/config.command.ts diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 9fcef11c00..c05991bc1d 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -270,7 +270,7 @@ export const config = createSlice({ }); builder.addCase(setGraphqlConnectionStatus, (state, action) => { - state.connectionStatus.minigraph = action.payload.status; + state.connectionStatus.minigraph = action.payload.status; }); builder.addCase(setupRemoteAccessThunk.fulfilled, (state, action) => { diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index bd18093913..0f61d082ec 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -11,6 +11,7 @@ import { VersionCommand } from '@app/unraid-api/cli/version.command'; import { StatusCommand } from '@app/unraid-api/cli/status.command'; import { ValidateTokenCommand } from '@app/unraid-api/cli/validate-token.command'; import { LogsCommand } from '@app/unraid-api/cli/logs.command'; +import { ConfigCommand } from '@app/unraid-api/cli/config.command'; @Module({ providers: [ @@ -24,7 +25,8 @@ import { LogsCommand } from '@app/unraid-api/cli/logs.command'; VersionCommand, StatusCommand, ValidateTokenCommand, - LogsCommand + LogsCommand, + ConfigCommand ], }) export class CliModule {} diff --git a/api/src/unraid-api/cli/config.command.ts b/api/src/unraid-api/cli/config.command.ts new file mode 100644 index 0000000000..d18795e629 --- /dev/null +++ b/api/src/unraid-api/cli/config.command.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { readFile } from 'fs/promises'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { getters } from '@app/store/index'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +@Injectable() +@Command({ + name: 'config', + description: 'Display current configuration values', +}) +export class ConfigCommand extends CommandRunner { + constructor(private readonly logger: LogService) { + super(); + } + + async run(): Promise { + this.logger.log('\nDisk Configuration:'); + const diskConfig = await readFile(getters.paths()['myservers-config'], 'utf8'); + this.logger.log(diskConfig); + process.exit(0); + } +} diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index ca7e7b31c1..3e5e969212 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -14,7 +14,7 @@ export class RestartCommand extends CommandRunner { } async run(_): Promise { - const { stderr, stdout } = await execa(PM2_PATH, ['restart', ECOSYSTEM_PATH]); + const { stderr, stdout } = await execa(PM2_PATH, ['restart', ECOSYSTEM_PATH, '--update-env']); if (stderr) { this.logger.error(stderr); process.exit(1); diff --git a/api/src/unraid-api/cli/validate-token.command.ts b/api/src/unraid-api/cli/validate-token.command.ts index 0b0bc663e0..cf5f251539 100644 --- a/api/src/unraid-api/cli/validate-token.command.ts +++ b/api/src/unraid-api/cli/validate-token.command.ts @@ -79,6 +79,7 @@ export class ValidateTokenCommand extends CommandRunner { const possibleUserIds = configFile.remote.ssoSubIds.split(','); if (possibleUserIds.includes(username)) { this.logger.info(JSON.stringify({ error: null, valid: true, username })); + process.exit(0); } else { this.createErrorAndExit('Username on token does not match'); } From 694b2adeaece4303e53d432c2ac93ba0cd744f89 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 20 Jan 2025 12:04:26 -0500 Subject: [PATCH 13/42] feat: unraid single sign on with account app --- api/src/cli.ts | 4 +- api/src/core/sso/sso-remove.ts | 18 ++ api/src/core/sso/sso-setup.ts | 62 +++++ api/src/index.ts | 6 + api/src/unraid-api/cli/cli.module.ts | 10 +- api/src/unraid-api/cli/sso.command.ts | 22 ++ .../unraid-api/cli/validate-token.command.ts | 11 +- .../dynamix.my.servers/include/sso-login.php | 22 ++ .../dynamix.my.servers/include/state.php | 6 + web/_data/serverState.ts | 117 ++++----- web/components/SsoButton.ce.vue | 85 +++++++ web/nuxt.config.ts | 233 +++++++++--------- web/pages/index.vue | 6 + web/pages/webComponents.vue | 7 +- web/store/server.ts | 5 + web/types/server.ts | 1 + 16 files changed, 431 insertions(+), 184 deletions(-) create mode 100644 api/src/core/sso/sso-remove.ts create mode 100755 api/src/core/sso/sso-setup.ts create mode 100644 api/src/unraid-api/cli/sso.command.ts create mode 100644 plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php create mode 100644 web/components/SsoButton.ce.vue diff --git a/api/src/cli.ts b/api/src/cli.ts index 2903f84039..8002634504 100644 --- a/api/src/cli.ts +++ b/api/src/cli.ts @@ -9,14 +9,14 @@ import { cliLogger, internalLogger } from '@app/core/log'; import { CliModule } from '@app/unraid-api/cli/cli.module'; try { - const shellToUse = execSync('which bash'); + const shellToUse = execSync('which bash').toString().trim(); await CommandFactory.run(CliModule, { cliName: 'unraid-api', logger: false, completion: { fig: true, cmd: 'unraid-api', - nativeShell: { executablePath: shellToUse.toString('utf-8') }, + nativeShell: { executablePath: shellToUse }, }, }); } catch (error) { diff --git a/api/src/core/sso/sso-remove.ts b/api/src/core/sso/sso-remove.ts new file mode 100644 index 0000000000..c3e73323e2 --- /dev/null +++ b/api/src/core/sso/sso-remove.ts @@ -0,0 +1,18 @@ +import { existsSync, renameSync, unlinkSync } from 'node:fs'; + +export const removeSso = () => { + const path = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; + const backupPath = path + '.bak'; + + // Remove the SSO login inject file if it exists + if (existsSync(path)) { + unlinkSync(path); + } + + // Move the backup file to the original location + if (existsSync(backupPath)) { + renameSync(backupPath, path); + } + + console.log('Restored .login php file'); +}; diff --git a/api/src/core/sso/sso-setup.ts b/api/src/core/sso/sso-setup.ts new file mode 100755 index 0000000000..5eb8dcdd74 --- /dev/null +++ b/api/src/core/sso/sso-setup.ts @@ -0,0 +1,62 @@ +import { existsSync } from 'node:fs'; +import { copyFile, readFile, rename, unlink, writeFile } from 'node:fs/promises'; + +export const setupSso = async () => { + const path = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; + + // Define the new PHP function to insert + const newFunction = ` +function verifyUsernamePasswordAndSSO(string $username, string $password): bool { + if ($username != "root") return false; + + $output = exec("/usr/bin/getent shadow $username"); + if ($output === false) return false; + $credentials = explode(":", $output); + $valid = password_verify($password, $credentials[1]); + if ($valid) { + return true; + } + // We may have an SSO token, attempt validation + if (strlen($password) > 800) { + $safePassword = escapeshellarg($password); + $response = exec("/usr/local/bin/unraid-api sso validate-token $safePassword", $output, $code); + my_logger("SSO Login Response: $response"); + if ($code === 0 && $response && strpos($response, '"valid":true') !== false) { + return true; + } + } + return false; +}`; + + const tagToInject = ''; + + // Backup the original file if exists + if (existsSync(path + '.bak')) { + await copyFile(path + '.bak', path); + await unlink(path + '.bak'); + } + + // Read the file content + let fileContent = await readFile(path, 'utf-8'); + + // Backup the original content + await writeFile(path + '.bak', fileContent); + + // Add new function after the opening PHP tag ( tag + fileContent = fileContent.replace(/<\/body>/i, `${tagToInject}\n`); + + // Write the updated content back to the file + await writeFile(path, fileContent); + + console.log('Function replaced successfully.'); +}; diff --git a/api/src/index.ts b/api/src/index.ts index e3f5b89655..6fcd332548 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -14,6 +14,7 @@ import { WebSocket } from 'ws'; import { logger } from '@app/core/log'; import { setupLogRotation } from '@app/core/logrotate/setup-logrotate'; +import { setupSso } from '@app/core/sso/sso-setup'; import { fileExistsSync } from '@app/core/utils/files/file-exists'; import { environment, PORT } from '@app/environment'; import * as envVars from '@app/environment'; @@ -100,6 +101,11 @@ try { startMiddlewareListeners(); + // If the config contains SSO IDs, enable SSO + if (store.getState().config.remote.ssoSubIds) { + await setupSso(); + } + // On process exit stop HTTP server exitHook((signal) => { console.log('exithook', signal); diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 0f61d082ec..71a34cdb32 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -1,17 +1,18 @@ import { Module } from '@nestjs/common'; +import { ConfigCommand } from '@app/unraid-api/cli/config.command'; import { KeyCommand } from '@app/unraid-api/cli/key.command'; import { LogService } from '@app/unraid-api/cli/log.service'; +import { LogsCommand } from '@app/unraid-api/cli/logs.command'; import { ReportCommand } from '@app/unraid-api/cli/report.command'; import { RestartCommand } from '@app/unraid-api/cli/restart.command'; +import { SSOCommand } from '@app/unraid-api/cli/sso.command'; import { StartCommand } from '@app/unraid-api/cli/start.command'; +import { StatusCommand } from '@app/unraid-api/cli/status.command'; import { StopCommand } from '@app/unraid-api/cli/stop.command'; import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command'; import { VersionCommand } from '@app/unraid-api/cli/version.command'; -import { StatusCommand } from '@app/unraid-api/cli/status.command'; import { ValidateTokenCommand } from '@app/unraid-api/cli/validate-token.command'; -import { LogsCommand } from '@app/unraid-api/cli/logs.command'; -import { ConfigCommand } from '@app/unraid-api/cli/config.command'; @Module({ providers: [ @@ -24,9 +25,10 @@ import { ConfigCommand } from '@app/unraid-api/cli/config.command'; SwitchEnvCommand, VersionCommand, StatusCommand, + SSOCommand, ValidateTokenCommand, LogsCommand, - ConfigCommand + ConfigCommand, ], }) export class CliModule {} diff --git a/api/src/unraid-api/cli/sso.command.ts b/api/src/unraid-api/cli/sso.command.ts new file mode 100644 index 0000000000..ce4593c039 --- /dev/null +++ b/api/src/unraid-api/cli/sso.command.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { LogService } from '@app/unraid-api/cli/log.service'; +import { ValidateTokenCommand } from '@app/unraid-api/cli/validate-token.command'; + +@Injectable() +@Command({ + name: 'sso', + description: 'Main Command to Configure / Validate SSO Tokens', + subCommands: [ValidateTokenCommand], +}) +export class SSOCommand extends CommandRunner { + constructor(private readonly logger: LogService) { + super(); + } + + async run(): Promise { + this.logger.info('Please provide a subcommand or use --help for more information'); + } +} diff --git a/api/src/unraid-api/cli/validate-token.command.ts b/api/src/unraid-api/cli/validate-token.command.ts index cf5f251539..902fa28da5 100644 --- a/api/src/unraid-api/cli/validate-token.command.ts +++ b/api/src/unraid-api/cli/validate-token.command.ts @@ -1,14 +1,15 @@ import type { JWTPayload } from 'jose'; import { createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; -import { Command, CommandRunner } from 'nest-commander'; +import { CommandRunner, SubCommand } from 'nest-commander'; import { JWKS_LOCAL_PAYLOAD, JWKS_REMOTE_LINK } from '@app/consts'; import { store } from '@app/store'; import { loadConfigFile } from '@app/store/modules/config'; import { LogService } from '@app/unraid-api/cli/log.service'; -@Command({ +@SubCommand({ name: 'validate-token', + aliases: ['validate', 'v'], description: 'Returns JSON: { error: string | null, valid: boolean }', arguments: '', }) @@ -33,11 +34,13 @@ export class ValidateTokenCommand extends CommandRunner { async run(passedParams: string[]): Promise { if (passedParams.length !== 1) { - this.logger.error('Please pass token argument only'); - process.exit(1); + this.createErrorAndExit('Please pass token argument only'); } const token = passedParams[0]; + if (typeof token !== 'string' || token.trim() === '') { + this.createErrorAndExit('Invalid token provided'); + } let caughtError: null | unknown = null; let tokenPayload: null | JWTPayload = null; diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php new file mode 100644 index 0000000000..414c1d07c4 --- /dev/null +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php @@ -0,0 +1,22 @@ +getScriptTagHtml(); +?> + + + + \ No newline at end of file diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php index e09f6909d1..f66c07e10c 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php @@ -53,6 +53,10 @@ class ServerState "nokeyserver" => 'NO_KEY_SERVER', "withdrawn" => 'WITHDRAWN', ]; + /** + * SSO Sub IDs from the my servers config file. + */ + private $ssoSubIds = ''; private $osVersion; private $osVersionBranch; private $rebootDetails; @@ -193,6 +197,7 @@ private function getMyServersCfgValues() { $this->registered = !empty($this->myServersFlashCfg['remote']['apikey']) && $this->connectPluginInstalled; $this->registeredTime = $this->myServersFlashCfg['remote']['regWizTime'] ?? ''; $this->username = $this->myServersFlashCfg['remote']['username'] ?? ''; + $this->ssoSubIds = $this->myServersFlashCfg['remote']['ssoSubIds'] ?? ''; } private function getConnectKnownOrigins() { @@ -321,6 +326,7 @@ public function getServerState() "uptime" => 1000 * (time() - round(strtok(exec("cat /proc/uptime"), ' '))), "username" => $this->username, "wanFQDN" => @$this->nginxCfg['NGINX_WANFQDN'] ?? '', + "ssoSubIds" => $this->ssoSubIds ]; if ($this->combinedKnownOrigins) { diff --git a/web/_data/serverState.ts b/web/_data/serverState.ts index 945532889b..05abb341a8 100644 --- a/web/_data/serverState.ts +++ b/web/_data/serverState.ts @@ -1,3 +1,4 @@ +; // import dayjs, { extend } from 'dayjs'; // import customParseFormat from 'dayjs/plugin/customParseFormat'; // import relativeTime from 'dayjs/plugin/relativeTime'; @@ -6,11 +7,10 @@ // import QueryStringAddon from 'wretch/addons/queryString'; // import { OS_RELEASES } from '~/helpers/urls'; -import type { - Server, - ServerState, - // ServerUpdateOsResponse, -} from "~/types/server"; +import type { Server, ServerState +// ServerUpdateOsResponse, +} from '~/types/server'; + // dayjs plugins // extend(customParseFormat); @@ -44,10 +44,10 @@ import type { // EBLACKLISTED2 // ENOCONN -const state: ServerState = "ENOKEYFILE" as ServerState; -const currentFlashGuid = "1111-1111-YIJD-ZACK1234TEST"; // this is the flash drive that's been booted from -const regGuid = "1111-1111-YIJD-ZACK1234TEST"; // this guid is registered in key server -const keyfileBase64 = ""; +const state: ServerState = 'ENOKEYFILE' as ServerState; +const currentFlashGuid = '1111-1111-YIJD-ZACK1234TEST'; // this is the flash drive that's been booted from +const regGuid = '1111-1111-YIJD-ZACK1234TEST'; // this guid is registered in key server +const keyfileBase64 = ''; // const randomGuid = `1111-1111-${makeid(4)}-123412341234`; // this guid is registered in key server // const newGuid = `1234-1234-${makeid(4)}-123412341234`; // this is a new USB, not registered @@ -65,50 +65,50 @@ let expireTime = 0; let regExp: number | undefined; let regDevs = 0; -let regTy = ""; +let regTy = ''; switch (state) { - case "EEXPIRED": + case 'EEXPIRED': expireTime = uptime; // 1 hour ago break; - case "ENOCONN": + case 'ENOCONN': break; - case "TRIAL": + case 'TRIAL': expireTime = oneHourFromNow; // in 1 hour - regTy = "Trial"; + regTy = 'Trial'; break; - case "BASIC": + case 'BASIC': regDevs = 6; - regTy = "Basic"; + regTy = 'Basic'; break; - case "PLUS": + case 'PLUS': regDevs = 12; - regTy = "Plus"; + regTy = 'Plus'; break; - case "PRO": + case 'PRO': regDevs = -1; - regTy = "Pro"; + regTy = 'Pro'; break; - case "STARTER": + case 'STARTER': regDevs = 6; regExp = ninetyDaysAgo; - regTy = "Starter"; + regTy = 'Starter'; break; - case "UNLEASHED": + case 'UNLEASHED': regDevs = -1; regExp = ninetyDaysAgo; - regTy = "Unleashed"; + regTy = 'Unleashed'; break; - case "LIFETIME": + case 'LIFETIME': regDevs = -1; - regTy = "Lifetime"; + regTy = 'Lifetime'; break; } // const connectPluginInstalled = 'dynamix.unraid.net.staging.plg'; -const connectPluginInstalled = "dynamix.unraid.net.staging.plg"; +const connectPluginInstalled = 'dynamix.unraid.net.staging.plg'; -const osVersion = "7.0.0-beta.2.10"; -const osVersionBranch = "stable"; +const osVersion = '7.0.0-beta.2.10'; +const osVersionBranch = 'stable'; // const parsedRegExp = regExp ? dayjs(regExp).format('YYYY-MM-DD') : undefined; // const mimicWebguiUnraidCheck = async (): Promise => { @@ -134,62 +134,63 @@ const osVersionBranch = "stable"; export const serverState: Server = { activationCodeData: { - "code": "CC2KP3TDRF", - "partnerName": "OEM Partner", - "partnerUrl": "https://unraid.net/OEM+Partner", - "sysModel": "OEM Partner v1", - "comment": "OEM Partner NAS", - "caseIcon": "case-model.png", - "header": "#ffffff", - "headermetacolor": "#eeeeee", - "background": "#000000", - "showBannerGradient": "yes", - "partnerLogo": true, + code: 'CC2KP3TDRF', + partnerName: 'OEM Partner', + partnerUrl: 'https://unraid.net/OEM+Partner', + sysModel: 'OEM Partner v1', + comment: 'OEM Partner NAS', + caseIcon: 'case-model.png', + header: '#ffffff', + headermetacolor: '#eeeeee', + background: '#000000', + showBannerGradient: 'yes', + partnerLogo: true, }, - apiKey: "unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810", - avatar: "https://source.unsplash.com/300x300/?portrait", + apiKey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', + avatar: 'https://source.unsplash.com/300x300/?portrait', config: { id: 'config-id', error: null, valid: false, }, connectPluginInstalled, - description: "DevServer9000", + description: 'DevServer9000', deviceCount: 3, expireTime, flashBackupActivated: !!connectPluginInstalled, - flashProduct: "SanDisk_3.2Gen1", - flashVendor: "USB", + flashProduct: 'SanDisk_3.2Gen1', + flashVendor: 'USB', guid: currentFlashGuid, // "guid": "0781-5583-8355-81071A2B0211", inIframe: false, // keyfile: 'DUMMY_KEYFILE', keyfile: keyfileBase64, - lanIp: "192.168.254.36", - license: "", - locale: "en_US", // en_US, ja - name: "dev-static", + lanIp: '192.168.254.36', + license: '', + locale: 'en_US', // en_US, ja + name: 'dev-static', osVersion, osVersionBranch, registered: connectPluginInstalled ? true : false, // registered: false, regGen: 0, regTm: twoDaysAgo, - regTo: "Zack Spear", + regTo: 'Zack Spear', regTy, regDevs, regExp, regGuid, - site: "http://localhost:4321", + site: 'http://localhost:4321', + ssoSubIds: '1234567890,0987654321,297294e2-b31c-4bcc-a441-88aee0ad609f', state, theme: { banner: false, bannerGradient: false, - bgColor: "", + bgColor: '', descriptionShow: true, - metaColor: "", - name: "white", - textColor: "", + metaColor: '', + name: 'white', + textColor: '', }, // updateOsResponse: { // version: '6.12.6', @@ -201,6 +202,6 @@ export const serverState: Server = { // sha256: '2f5debaf80549029cf6dfab0db59180e7e3391c059e6521aace7971419c9c4bf', // }, uptime, - username: "zspearmint", - wanFQDN: "", -}; + username: 'zspearmint', + wanFQDN: '', +}; \ No newline at end of file diff --git a/web/components/SsoButton.ce.vue b/web/components/SsoButton.ce.vue new file mode 100644 index 0000000000..abb3f18988 --- /dev/null +++ b/web/components/SsoButton.ce.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index b4a78b4fcb..83170261ff 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -40,119 +40,122 @@ const charsToReserve = '_$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01 // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ - ssr: false, - - devServer: { - port: 4321, - }, - - devtools: { - enabled: true, - }, - - modules: [ - "@vueuse/nuxt", - "@pinia/nuxt", - "@nuxtjs/tailwindcss", - "nuxt-custom-elements", - "@nuxt/eslint", - "shadcn-nuxt", - ], - - ignore: ['/webGui/images'], - - components: [ - { path: "~/components/Brand", prefix: "Brand" }, - { path: "~/components/ConnectSettings", prefix: "ConnectSettings" }, - { path: "~/components/Ui", prefix: "Ui" }, - { path: "~/components/UserProfile", prefix: "Upc" }, - { path: "~/components/UpdateOs", prefix: "UpdateOs" }, - "~/components", - ], - - // typescript: { - // typeCheck: true - // }, - shadcn: { - prefix: "", - componentDir: "./components/shadcn", - }, - - vite: { - plugins: [ - !process.env.VITE_ALLOW_CONSOLE_LOGS && - removeConsole({ - includes: ["log", "warn", "error", "info", "debug"], - }), - ], - build: { - minify: "terser", - terserOptions: { - mangle: { - reserved: terserReservations(charsToReserve), - toplevel: true, - }, - }, - }, - }, - - customElements: { - entries: [ - { - name: "UnraidComponents", - tags: [ - { - name: "UnraidI18nHost", - path: "@/components/I18nHost.ce", - }, - { - name: "UnraidAuth", - path: "@/components/Auth.ce", - }, - { - name: "UnraidConnectSettings", - path: "@/components/ConnectSettings/ConnectSettings.ce", - }, - { - name: "UnraidDownloadApiLogs", - path: "@/components/DownloadApiLogs.ce", - }, - { - name: "UnraidHeaderOsVersion", - path: "@/components/HeaderOsVersion.ce", - }, - { - name: "UnraidModals", - path: "@/components/Modals.ce", - }, - { - name: "UnraidUserProfile", - path: "@/components/UserProfile.ce", - }, - { - name: "UnraidUpdateOs", - path: "@/components/UpdateOs.ce", - }, - { - name: "UnraidDowngradeOs", - path: "@/components/DowngradeOs.ce", - }, - { - name: "UnraidRegistration", - path: "@/components/Registration.ce", - }, - { - name: "UnraidWanIpCheck", - path: "@/components/WanIpCheck.ce", - }, - { - name: "UnraidWelcomeModal", - path: "@/components/WelcomeModal.ce", - }, - ], - }, - ], - }, - - compatibilityDate: "2024-12-05" + ssr: false, + + devServer: { + port: 4321, + }, + + devtools: { + enabled: true, + }, + + modules: [ + '@vueuse/nuxt', + '@pinia/nuxt', + '@nuxtjs/tailwindcss', + 'nuxt-custom-elements', + '@nuxt/eslint', + 'shadcn-nuxt', + ], + + ignore: ['/webGui/images'], + + components: [ + { path: '~/components/Brand', prefix: 'Brand' }, + { path: '~/components/ConnectSettings', prefix: 'ConnectSettings' }, + { path: '~/components/Ui', prefix: 'Ui' }, + { path: '~/components/UserProfile', prefix: 'Upc' }, + { path: '~/components/UpdateOs', prefix: 'UpdateOs' }, + '~/components', + ], + + // typescript: { + // typeCheck: true + // }, + shadcn: { + prefix: '', + componentDir: './components/shadcn', + }, + + vite: { + plugins: [ + !process.env.VITE_ALLOW_CONSOLE_LOGS && + removeConsole({ + includes: ['log', 'warn', 'error', 'info', 'debug'], + }), + ], + build: { + minify: 'terser', + terserOptions: { + mangle: { + reserved: terserReservations(charsToReserve), + toplevel: true, + }, + }, + }, + }, + + customElements: { + entries: [ + { + name: 'UnraidComponents', + tags: [ + { + name: 'UnraidI18nHost', + path: '@/components/I18nHost.ce', + }, + { + name: 'UnraidAuth', + path: '@/components/Auth.ce', + }, + { + name: 'UnraidConnectSettings', + path: '@/components/ConnectSettings/ConnectSettings.ce', + }, + { + name: 'UnraidDownloadApiLogs', + path: '@/components/DownloadApiLogs.ce', + }, + { + name: 'UnraidHeaderOsVersion', + path: '@/components/HeaderOsVersion.ce', + }, + { + name: 'UnraidModals', + path: '@/components/Modals.ce', + }, + { + name: 'UnraidUserProfile', + path: '@/components/UserProfile.ce', + }, + { + name: 'UnraidUpdateOs', + path: '@/components/UpdateOs.ce', + }, + { + name: 'UnraidDowngradeOs', + path: '@/components/DowngradeOs.ce', + }, + { + name: 'UnraidRegistration', + path: '@/components/Registration.ce', + }, + { + name: 'UnraidWanIpCheck', + path: '@/components/WanIpCheck.ce', + }, + { + name: 'UnraidWelcomeModal', + path: '@/components/WelcomeModal.ce', + }, + { name: 'UnraidSsoButton', + path: '@/components/SsoButton.ce' + }, + ], + }, + ], + }, + + compatibilityDate: '2024-12-05', }); diff --git a/web/pages/index.vue b/web/pages/index.vue index e3c52db23f..05ca38bfad 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -4,6 +4,7 @@ import { BrandButton, BrandLogo } from '@unraid/ui'; import { serverState } from '~/_data/serverState'; import type { SendPayloads } from '~/store/callback'; import AES from 'crypto-js/aes'; +import SsoButtonCe from '~/components/SsoButton.ce.vue'; const { registerEntry } = useCustomElements(); onBeforeMount(() => { @@ -152,6 +153,11 @@ onMounted(() => { > +
+
+

SSO Button Component

+ +
diff --git a/web/pages/webComponents.vue b/web/pages/webComponents.vue index 0b03597cbc..4ceaa3f99d 100644 --- a/web/pages/webComponents.vue +++ b/web/pages/webComponents.vue @@ -75,7 +75,12 @@ onBeforeMount(() => {

ModalsCe

- + +
+

+ SSOSignInButtonCe +

+ diff --git a/web/store/server.ts b/web/store/server.ts index 8de3ee4037..b4dde9b4b0 100644 --- a/web/store/server.ts +++ b/web/store/server.ts @@ -155,6 +155,7 @@ export const useServerStore = defineStore("server", () => { return today.isAfter(parsedUpdateExpirationDate, "day"); }); const site = ref(""); + const ssoSubIds = ref(""); const state = ref(); const theme = ref(); watch(theme, (newVal) => { @@ -1208,6 +1209,9 @@ export const useServerStore = defineStore("server", () => { if (typeof data?.regTo !== "undefined") { regTo.value = data.regTo; } + if (typeof data?.ssoSubIds !== "undefined") { + ssoSubIds.value = data.ssoSubIds; + } if (typeof data.activationCodeData !== "undefined") { const activationCodeStore = useActivationCodeStore(); @@ -1474,6 +1478,7 @@ export const useServerStore = defineStore("server", () => { parsedRegExp, regUpdatesExpired, site, + ssoSubIds, state, theme, updateOsIgnoredReleases, diff --git a/web/types/server.ts b/web/types/server.ts index 70200b8aa6..e9c28d392f 100644 --- a/web/types/server.ts +++ b/web/types/server.ts @@ -117,6 +117,7 @@ export interface Server { username?: string; wanFQDN?: string; wanIp?: string; + ssoSubIds?: string; } export interface ServerAccountCallbackSendPayload { From 7b1fa999f9eef46a5562679d560481f773299bfd Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 20 Jan 2025 12:11:53 -0500 Subject: [PATCH 14/42] feat: enable PR releases on non-mainline merges --- .github/workflows/main.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5b4899709..d5cc621137 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -242,8 +242,7 @@ jobs: release-pull-request: if: | - github.event_name == 'pull_request' && - github.event.pull_request.base.ref == 'main' + github.event_name == 'pull_request' runs-on: ubuntu-latest needs: [build-plugin] steps: From 6e3fe1d56dc12f1e671ab7791dc67719e66a4619 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 20 Jan 2025 12:32:56 -0500 Subject: [PATCH 15/42] feat: dont pass entire server state for privacy --- api/src/core/sso/auth-request-setup.ts | 61 +++++++++++++++++++ api/src/index.ts | 2 + .../dynamix.my.servers/include/sso-login.php | 2 +- .../dynamix.my.servers/include/state.php | 2 +- web/components/SsoButton.ce.vue | 30 ++------- 5 files changed, 71 insertions(+), 26 deletions(-) create mode 100644 api/src/core/sso/auth-request-setup.ts diff --git a/api/src/core/sso/auth-request-setup.ts b/api/src/core/sso/auth-request-setup.ts new file mode 100644 index 0000000000..29834a4d3a --- /dev/null +++ b/api/src/core/sso/auth-request-setup.ts @@ -0,0 +1,61 @@ +import { existsSync, write } from 'fs'; +import { readdir, readFile, writeFile } from 'fs/promises'; +import path from 'path'; + +import { logger } from '@app/core/log'; + +// Define constants +const AUTH_REQUEST_FILE = '/usr/local/emhttp/auth-request.php'; +const WEB_COMPS_DIR = '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/'; + +export const setupAuthRequest = async () => { + // Function to log debug messages + // Find all .js files in WEB_COMPS_DIR + const getJSFiles = async (dir) => { + const jsFiles: string[] = []; + + const findFiles = async (currentDir) => { + const files = await readdir(currentDir, { withFileTypes: true }); + for (const file of files) { + const fullPath = path.join(currentDir, file.name); + if (file.isDirectory()) { + findFiles(fullPath); + } else if (file.isFile() && file.name.endsWith('.js')) { + jsFiles.push(fullPath.replace('/usr/local/emhttp', '')); + } + } + }; + + await findFiles(dir); + return jsFiles; + }; + + const JS_FILES = await getJSFiles(WEB_COMPS_DIR); + logger.debug(`Found ${JS_FILES.length} .js files in ${WEB_COMPS_DIR}`); + + const FILES_TO_ADD = ['/webGui/images/partner-logo.svg', ...JS_FILES]; + + if (existsSync(AUTH_REQUEST_FILE)) { + const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8'); + + if (fileContent.includes('$arrWhitelist')) { + const backupFile = `${AUTH_REQUEST_FILE}.bak`; + await writeFile(backupFile, fileContent); + logger.debug(`Backup of ${AUTH_REQUEST_FILE} created at ${backupFile}`); + + const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); + + const updatedContent = fileContent.replace( + /(\$arrWhitelist\s*=\s*\[)/, + `$1\n${filesToAddString}` + ); + + await writeFile(AUTH_REQUEST_FILE, updatedContent); + logger.debug(`Default values and .js files from ${WEB_COMPS_DIR} added to $arrWhitelist.`); + } else { + logger.debug(`$arrWhitelist array not found in the file.`); + } + } else { + logger.debug(`File ${AUTH_REQUEST_FILE} not found.`); + } +}; diff --git a/api/src/index.ts b/api/src/index.ts index 6fcd332548..8000c99147 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -34,6 +34,7 @@ import { setupVarRunWatch } from '@app/store/watch/var-run-watch'; import { bootstrapNestServer } from '@app/unraid-api/main'; import { setupNewMothershipSubscription } from './mothership/subscribe-to-mothership'; +import { setupAuthRequest } from '@app/core/sso/auth-request-setup'; let server: NestFastifyApplication | null = null; @@ -103,6 +104,7 @@ try { // If the config contains SSO IDs, enable SSO if (store.getState().config.remote.ssoSubIds) { + await setupAuthRequest(); await setupSso(); } diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php index 414c1d07c4..508eff431e 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php @@ -18,5 +18,5 @@ ?> - + \ No newline at end of file diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php index f66c07e10c..fed4f58549 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php @@ -56,7 +56,7 @@ class ServerState /** * SSO Sub IDs from the my servers config file. */ - private $ssoSubIds = ''; + public $ssoSubIds = ''; private $osVersion; private $osVersionBranch; private $rebootDetails; diff --git a/web/components/SsoButton.ce.vue b/web/components/SsoButton.ce.vue index abb3f18988..3463427357 100644 --- a/web/components/SsoButton.ce.vue +++ b/web/components/SsoButton.ce.vue @@ -1,32 +1,11 @@ + From 2b0a4fd69f693bc4916753d67320eb7bd71b4829 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 23 Jan 2025 11:32:01 -0500 Subject: [PATCH 33/42] feat: disable button on submit --- web/components/SsoButton.ce.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/components/SsoButton.ce.vue b/web/components/SsoButton.ce.vue index 3ef8f9fc30..125b796263 100644 --- a/web/components/SsoButton.ce.vue +++ b/web/components/SsoButton.ce.vue @@ -134,9 +134,13 @@ const navigateToExternalSSOUrl = () => {

Or

- +
From 96fdeace1e3563743061b6202e833262b41271c1 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 23 Jan 2025 12:02:04 -0500 Subject: [PATCH 34/42] feat: cleanup disclaimer and command to add users --- .../cli/sso/add-sso-user.command.ts | 20 +++++++++++++------ .../cli/sso/add-sso-user.questions.ts | 17 ++++++++++++---- plugin/plugins/dynamix.unraid.net.plg | 4 ---- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/api/src/unraid-api/cli/sso/add-sso-user.command.ts b/api/src/unraid-api/cli/sso/add-sso-user.command.ts index a9640d1f41..148e8d27f0 100644 --- a/api/src/unraid-api/cli/sso/add-sso-user.command.ts +++ b/api/src/unraid-api/cli/sso/add-sso-user.command.ts @@ -28,13 +28,21 @@ export class AddSSOUserCommand extends CommandRunner { } async run(_input: string[], options: AddSSOUserCommandOptions): Promise { - options = await this.inquirerService.prompt(AddSSOUserQuestionSet.name, options); + try { + options = await this.inquirerService.prompt(AddSSOUserQuestionSet.name, options); - if (options.disclaimer === 'y') { - await store.dispatch(loadConfigFile()); - store.dispatch(addSsoUser(options.username)); - writeConfigSync('flash'); - this.logger.info('User added ' + options.username); + if (options.disclaimer === 'y' && options.username) { + await store.dispatch(loadConfigFile()); + store.dispatch(addSsoUser(options.username)); + writeConfigSync('flash'); + this.logger.info('User added ' + options.username); + } + } catch (e: unknown) { + if (e instanceof Error) { + this.logger.error('Error adding user: ' + e.message); + } else { + this.logger.error('Error adding user'); + } } } diff --git a/api/src/unraid-api/cli/sso/add-sso-user.questions.ts b/api/src/unraid-api/cli/sso/add-sso-user.questions.ts index c9228349d2..2e3d6c37af 100644 --- a/api/src/unraid-api/cli/sso/add-sso-user.questions.ts +++ b/api/src/unraid-api/cli/sso/add-sso-user.questions.ts @@ -1,4 +1,5 @@ import { Question, QuestionSet } from 'nest-commander'; +import { v4 as uuidv4 } from 'uuid'; @@ -9,7 +10,12 @@ export class AddSSOUserQuestionSet { static name = 'add-user'; @Question({ - message: 'Are you sure you wish to add a user for SSO - this will enable single sign on in Unraid and has certain security implications? (y/n)', + message: `Enabling Single Sign-On (SSO) will simplify authentication by centralizing access to your Unraid server. However, this comes with certain security considerations: if your SSO account is compromised, unauthorized access to your server could occur. + +Please note: your existing username and password will continue to work alongside SSO. + +Are you sure you want to proceed with adding a user for SSO? (y/n) +`, name: 'disclaimer', validate(input) { if (!input) { @@ -29,14 +35,17 @@ export class AddSSOUserQuestionSet { } @Question({ - message: 'What is the cognito username (NOT YOUR UNRAID USERNAME)? Find it in your Unraid Account at https://account.unraid.net', + message: + "What is your Unique Unraid Account ID? Find it in your Unraid Account at https://account.unraid.net/settings\n", name: 'username', validate(input) { if (!input) { return 'Username is required'; } - if (!/^[a-zA-Z0-9-]+$/.test(input)) { - return 'Username must be alphanumeric and can include dashes.'; + const randomUUID = uuidv4(); + + if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(input)) { + return `Username must be in the format of a UUID (e.g., ${randomUUID}).`; } return true; }, diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index f3e5a64d33..50a2a395ef 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -759,10 +759,6 @@ if ([[ -n "${email}" && (-z "${apikey}" || "${#apikey}" -ne "64") ]]); then }' "${CFG}">"${CFG}-new" && mv "${CFG}-new" "${CFG}" CFG_CLEANED=1 echo "⚠️ Automatically signed out of Unraid.net" fi -# if there wasn't an email or the CFG was cleaned -if [[ -z "${email}" ]] || [[ CFG_CLEANED -eq 1 ]]; then - echo "✨ Sign In to Unraid.net to use Unraid Connect ✨" -fi # configure flash backup to stop when the system starts shutting down [[ ! -d /etc/rc.d/rc6.d ]] && mkdir /etc/rc.d/rc6.d From 69bf12f47cab39f83899f1593facd366f6264919 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 23 Jan 2025 13:00:32 -0500 Subject: [PATCH 35/42] feat: remove sso user command --- api/src/store/modules/config.ts | 20 ++++++++--- api/src/unraid-api/cli/cli.module.ts | 4 +++ .../cli/sso/add-sso-user.questions.ts | 4 --- .../cli/sso/remove-sso-user.command.ts | 34 +++++++++++++++++++ .../cli/sso/remove-sso-user.questions.ts | 28 +++++++++++++++ api/src/unraid-api/cli/sso/sso.command.ts | 3 +- 6 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 api/src/unraid-api/cli/sso/remove-sso-user.command.ts create mode 100644 api/src/unraid-api/cli/sso/remove-sso-user.questions.ts diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 6fa919c262..81187f25ad 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -1,5 +1,4 @@ import { F_OK } from 'constants'; -import { randomBytes } from 'crypto'; import { writeFileSync } from 'fs'; import { access } from 'fs/promises'; @@ -24,7 +23,6 @@ import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access'; import { FileLoadStatus } from '@app/store/types'; import { type RecursivePartial } from '@app/types'; import { type MyServersConfig, type MyServersConfigMemory } from '@app/types/my-servers-config'; -import { isFulfilled } from '@app/utils'; export type SliceState = { status: FileLoadStatus; @@ -219,7 +217,18 @@ export const config = createSlice({ const stateAsArray = state.remote.ssoSubIds.split(','); stateAsArray.push(action.payload); state.remote.ssoSubIds = stateAsArray.join(','); - } + }, + removeSsoUser(state, action: PayloadAction) { + if (action.payload === null) { + state.remote.ssoSubIds = ''; + return; + } + if (!state.remote.ssoSubIds.includes(action.payload)) { + return; + } + const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== action.payload); + state.remote.ssoSubIds = stateAsArray.join(','); + }, }, extraReducers(builder) { builder.addCase(loadConfigFile.pending, (state) => { @@ -300,12 +309,14 @@ export const { setUpnpState, setWanPortToValue, setWanAccess, + removeSsoUser, } = actions; /** * Actions that should trigger a flash write */ export const configUpdateActionsFlash = isAnyOf( + addSsoUser, updateUserConfig, updateAccessTokens, updateAllowedOrigins, @@ -314,7 +325,8 @@ export const configUpdateActionsFlash = isAnyOf( setWanAccess, setupRemoteAccessThunk.fulfilled, logoutUser.fulfilled, - loginUser.fulfilled + loginUser.fulfilled, + removeSsoUser ); /** diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 9f60ea2c70..cd2c901db6 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -17,11 +17,15 @@ import { StatusCommand } from '@app/unraid-api/cli/status.command'; import { StopCommand } from '@app/unraid-api/cli/stop.command'; import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command'; import { VersionCommand } from '@app/unraid-api/cli/version.command'; +import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command'; +import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions'; @Module({ providers: [ AddSSOUserCommand, AddSSOUserQuestionSet, + RemoveSSOUserCommand, + RemoveSSOUserQuestionSet, LogService, StartCommand, StopCommand, diff --git a/api/src/unraid-api/cli/sso/add-sso-user.questions.ts b/api/src/unraid-api/cli/sso/add-sso-user.questions.ts index 2e3d6c37af..fe2d9b2abb 100644 --- a/api/src/unraid-api/cli/sso/add-sso-user.questions.ts +++ b/api/src/unraid-api/cli/sso/add-sso-user.questions.ts @@ -1,10 +1,6 @@ import { Question, QuestionSet } from 'nest-commander'; import { v4 as uuidv4 } from 'uuid'; - - - - @QuestionSet({ name: 'add-user' }) export class AddSSOUserQuestionSet { static name = 'add-user'; diff --git a/api/src/unraid-api/cli/sso/remove-sso-user.command.ts b/api/src/unraid-api/cli/sso/remove-sso-user.command.ts new file mode 100644 index 0000000000..3c249f9345 --- /dev/null +++ b/api/src/unraid-api/cli/sso/remove-sso-user.command.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; + +import { CommandRunner, InquirerService, Option, OptionChoiceFor, SubCommand } from 'nest-commander'; + +import { store } from '@app/store/index'; +import { loadConfigFile, removeSsoUser } from '@app/store/modules/config'; +import { LogService } from '@app/unraid-api/cli/log.service'; +import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions'; + +interface RemoveSSOUserCommandOptions { + username: string; +} + +@Injectable() +@SubCommand({ + name: 'remove-user', + aliases: ['remove', 'r'], + description: 'Remove a user (or all users) from SSO', +}) +export class RemoveSSOUserCommand extends CommandRunner { + constructor( + private readonly logger: LogService, + private readonly inquirerService: InquirerService + ) { + super(); + } + public async run(_input: string[], options: RemoveSSOUserCommandOptions): Promise { + await store.dispatch(loadConfigFile()); + console.log('options', options); + options = await this.inquirerService.prompt(RemoveSSOUserQuestionSet.name, options); + store.dispatch(removeSsoUser(options.username === 'all' ? null : options.username)); + this.logger.info('User/s removed ' + options.username); + } +} diff --git a/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts b/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts new file mode 100644 index 0000000000..2d1c91a64d --- /dev/null +++ b/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts @@ -0,0 +1,28 @@ +import { ChoicesFor, Question, QuestionSet, } from 'nest-commander'; + +import { store } from '@app/store/index'; +import { loadConfigFile } from '@app/store/modules/config'; + + +@QuestionSet({ name: 'remove-user' }) +export class RemoveSSOUserQuestionSet { + static name = 'remove-user'; + + @Question({ + message: `Please select from the following list of users to remove from SSO, or enter all to remove all users from SSO.\n`, + name: 'username', + type: 'list', + }) + parseName(val: string) { + return val; + } + + @ChoicesFor({ name: 'username' }) + async choicesForUsername() { + await store.dispatch(loadConfigFile()); + const users = store.getState().config.remote.ssoSubIds.split(',').filter((user) => user !== ''); + + users.push('all'); + return users; + } +} diff --git a/api/src/unraid-api/cli/sso/sso.command.ts b/api/src/unraid-api/cli/sso/sso.command.ts index 220da33549..d1c19aaa61 100644 --- a/api/src/unraid-api/cli/sso/sso.command.ts +++ b/api/src/unraid-api/cli/sso/sso.command.ts @@ -5,12 +5,13 @@ import { Command, CommandRunner } from 'nest-commander'; import { LogService } from '@app/unraid-api/cli/log.service'; import { ValidateTokenCommand } from '@app/unraid-api/cli/sso/validate-token.command'; import { AddSSOUserCommand } from '@app/unraid-api/cli/sso/add-sso-user.command'; +import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command'; @Injectable() @Command({ name: 'sso', description: 'Main Command to Configure / Validate SSO Tokens', - subCommands: [ValidateTokenCommand, AddSSOUserCommand], + subCommands: [ValidateTokenCommand, AddSSOUserCommand, RemoveSSOUserCommand], }) export class SSOCommand extends CommandRunner { constructor(private readonly logger: LogService) { From 568c6c860972a87c99a54de7562fccbd72820f74 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 23 Jan 2025 13:16:06 -0500 Subject: [PATCH 36/42] feat: remove sso user options --- api/src/store/modules/config.ts | 2 +- api/src/unraid-api/cli/sso/remove-sso-user.command.ts | 9 +++++++-- .../unraid-api/cli/sso/remove-sso-user.questions.ts | 10 ++++++---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 81187f25ad..13261afca0 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -214,7 +214,7 @@ export const config = createSlice({ if (state.remote.ssoSubIds.includes(action.payload)) { return; } - const stateAsArray = state.remote.ssoSubIds.split(','); + const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== ''); stateAsArray.push(action.payload); state.remote.ssoSubIds = stateAsArray.join(','); }, diff --git a/api/src/unraid-api/cli/sso/remove-sso-user.command.ts b/api/src/unraid-api/cli/sso/remove-sso-user.command.ts index 3c249f9345..669c1e58b7 100644 --- a/api/src/unraid-api/cli/sso/remove-sso-user.command.ts +++ b/api/src/unraid-api/cli/sso/remove-sso-user.command.ts @@ -4,6 +4,7 @@ import { CommandRunner, InquirerService, Option, OptionChoiceFor, SubCommand } f import { store } from '@app/store/index'; import { loadConfigFile, removeSsoUser } from '@app/store/modules/config'; +import { writeConfigSync } from '@app/store/sync/config-disk-sync'; import { LogService } from '@app/unraid-api/cli/log.service'; import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions'; @@ -26,9 +27,13 @@ export class RemoveSSOUserCommand extends CommandRunner { } public async run(_input: string[], options: RemoveSSOUserCommandOptions): Promise { await store.dispatch(loadConfigFile()); - console.log('options', options); options = await this.inquirerService.prompt(RemoveSSOUserQuestionSet.name, options); store.dispatch(removeSsoUser(options.username === 'all' ? null : options.username)); - this.logger.info('User/s removed ' + options.username); + if (options.username === 'all') { + this.logger.info('All users removed from SSO'); + } else { + this.logger.info('User removed: ' + options.username); + } + writeConfigSync('flash'); } } diff --git a/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts b/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts index 2d1c91a64d..cea993d187 100644 --- a/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts +++ b/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts @@ -1,11 +1,11 @@ import { ChoicesFor, Question, QuestionSet, } from 'nest-commander'; import { store } from '@app/store/index'; -import { loadConfigFile } from '@app/store/modules/config'; - +import { LogService } from '@app/unraid-api/cli/log.service'; @QuestionSet({ name: 'remove-user' }) export class RemoveSSOUserQuestionSet { + constructor(private readonly logger: LogService) {} static name = 'remove-user'; @Question({ @@ -19,9 +19,11 @@ export class RemoveSSOUserQuestionSet { @ChoicesFor({ name: 'username' }) async choicesForUsername() { - await store.dispatch(loadConfigFile()); const users = store.getState().config.remote.ssoSubIds.split(',').filter((user) => user !== ''); - + if (users.length === 0) { + this.logger.error('No SSO Users Found'); + process.exit(0); + } users.push('all'); return users; } From ab3a35427813ca6e0339e4f2f4a40d99fa638070 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 23 Jan 2025 13:16:33 -0500 Subject: [PATCH 37/42] fix: remove unused login entries --- web/pages/login.vue | 8 -------- 1 file changed, 8 deletions(-) diff --git a/web/pages/login.vue b/web/pages/login.vue index 9e4f840d1d..100c2a5c74 100644 --- a/web/pages/login.vue +++ b/web/pages/login.vue @@ -1,13 +1,5 @@