diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index e76722fdd..5b9ec0581 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 621129eaa..6f20d6ff5 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 d7f500913..755ed4e47 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 57dea7d0c..91123a22c 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 { logger } from '@app/core/log'; -import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status'; -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 { access } from 'fs/promises'; + +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { isEqual } from 'lodash-es'; -import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access'; +import merge from 'lodash/merge'; + +import type { Owner } from '@app/graphql/generated/api/types'; +import { logger } from '@app/core/log'; +import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub'; +import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer'; +import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; +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(); +>('config/load-config-file', async (filePath, { getState, rejectWithValue }) => { + try { + const { paths, config } = getState(); - const path = filePath ?? paths['myservers-config']; + const path = filePath ?? paths['myservers-config']; - const fileExists = await access(path, F_OK) - .then(() => true) - .catch(() => false); - if (!fileExists) { - throw new Error('Config File Missing'); - } + const fileExists = await access(path, F_OK) + .then(() => true) + .catch(() => false); + if (!fileExists) { + throw new Error('Config File Missing'); + } - const file = fileExists - ? parseConfig>({ - filePath: path, - type: 'ini', - }) - : {}; + const newConfigFile = getWriteableConfig( + parseConfig({ filePath: path, type: 'ini' }), + 'flash' + ); - const newConfigFile = generateApiKeysIfNotExistent(file); - - 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 25ef6c685..14808f23b 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 { watch } from 'chokidar'; -import { loadConfigFile, logoutUser } from '@app/store/modules/config'; -import { logger } from '@app/core/log'; import { existsSync, writeFileSync } from 'fs'; + +import { watch } from 'chokidar'; + +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 { 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 60dbf1d56..a7c224e2d 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 68ad9d877..0b0bc663e 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;