feat: cleanup config entries

This commit is contained in:
Eli Bosley
2025-01-19 15:10:45 -05:00
parent e9bd18a409
commit c2e29dfb5f
7 changed files with 125 additions and 207 deletions

View File

@@ -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"

View File

@@ -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=""

View File

@@ -42,23 +42,3 @@ export const getWriteableConfig = <T extends ConfigType>(
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<MyServersConfigMemory>, // 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);
};

View File

@@ -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<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey'| 'localApiKey'>,
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey'| 'localApiKey'>,
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey' | 'localApiKey'>,
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey' | 'localApiKey'>,
{ 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<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');
// 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>
): 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<RecursivePartial<MyServersConfig>>({
filePath: path,
type: 'ini',
})
: {};
const newConfigFile = getWriteableConfig(
parseConfig<MyServersConfig>({ 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<RecursivePartial<MyServersConfig>>
) {
updateUserConfig(state, action: PayloadAction<RecursivePartial<MyServersConfig>>) {
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;
});

View File

@@ -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)');
}
};

View File

@@ -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<typeof MyServersConfigSchema>;
export type MyServersConfigMemory = z.infer<typeof MyServersConfigMemorySchema>;
export type MyServersConfigMemoryWithMandatoryHiddenFields = z.infer<
typeof MyServersConfigMemoryWithMandatoryHiddenFieldsSchema
>;

View File

@@ -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;