diff --git a/api/Dockerfile b/api/Dockerfile index 1f8745800..3f065e7c3 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -6,6 +6,8 @@ FROM node:18.17.1-alpine As development # Install build tools and dependencies RUN apk add --no-cache \ bash \ + # Real PS Command (needed for some dependencies) + procps \ alpine-sdk \ python3 \ libvirt-dev \ diff --git a/api/src/__test__/store/modules/config.test.ts b/api/src/__test__/store/modules/config.test.ts new file mode 100644 index 000000000..3638ed925 --- /dev/null +++ b/api/src/__test__/store/modules/config.test.ts @@ -0,0 +1,154 @@ +import { test, expect } from 'vitest'; +import { store } from '@app/store'; + +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": { + "2Fa": "", + "showT2Fa": "", + }, + "nodeEnv": "test", + "notifier": { + "apikey": "", + }, + "remote": { + "2Fa": "", + "accesstoken": "", + "allowedOrigins": "", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "refreshtoken": "", + "regWizTime": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "status": "UNLOADED", + "upc": { + "apikey": "", + }, + } + `); +}, 10_000); + +test('After init returns values from cfg file for all fields', async () => { + const { loadConfigFile } = await import('@app/store/modules/config'); + + // Load cfg into store + await store.dispatch(loadConfigFile()); + + // Check if store has cfg contents loaded + const state = store.getState().config; + expect(state).toMatchObject( + expect.objectContaining({ + api: { + extraOrigins: '', + version: expect.any(String), + }, + connectionStatus: { + minigraph: 'PRE_INIT', + upnpStatus: '', + }, + local: { + '2Fa': '', + showT2Fa: '', + }, + nodeEnv: 'test', + notifier: { + apikey: 'unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5', + }, + remote: { + '2Fa': '', + accesstoken: '', + allowedOrigins: '', + apikey: '_______________________BIG_API_KEY_HERE_________________________', + avatar: 'https://via.placeholder.com/200', + dynamicRemoteAccessType: 'DISABLED', + email: 'test@example.com', + idtoken: '', + refreshtoken: '', + regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0', + upnpEnabled: 'no', + username: 'zspearmint', + wanaccess: 'yes', + wanport: '8443', + }, + status: 'LOADED', + upc: { + apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', + }, + }) + ); +}); + +test('updateUserConfig merges in changes to current state', async () => { + const { loadConfigFile, updateUserConfig } = await import( + '@app/store/modules/config' + ); + + // Load cfg into store + await store.dispatch(loadConfigFile()); + + // Update store + store.dispatch( + updateUserConfig({ + remote: { avatar: 'https://via.placeholder.com/200' }, + }) + ); + + const state = store.getState().config; + expect(state).toMatchObject( + expect.objectContaining({ + api: { + extraOrigins: '', + version: expect.any(String), + }, + connectionStatus: { + minigraph: 'PRE_INIT', + upnpStatus: '', + }, + local: { + '2Fa': '', + showT2Fa: '', + }, + nodeEnv: 'test', + notifier: { + apikey: 'unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5', + }, + remote: { + '2Fa': '', + accesstoken: '', + allowedOrigins: '', + apikey: '_______________________BIG_API_KEY_HERE_________________________', + avatar: 'https://via.placeholder.com/200', + dynamicRemoteAccessType: 'DISABLED', + email: 'test@example.com', + idtoken: '', + refreshtoken: '', + regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0', + upnpEnabled: 'no', + username: 'zspearmint', + wanaccess: 'yes', + wanport: '8443', + }, + status: 'LOADED', + upc: { + apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', + }, + }) + ); +}); diff --git a/api/src/environment.ts b/api/src/environment.ts index b16651713..7050b271e 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -13,4 +13,5 @@ export const GRAPHQL_INTROSPECTION = Boolean( ); export const PORT = process.env.PORT ?? '/var/run/unraid-api.sock'; export const DRY_RUN = process.env.DRY_RUN === 'true'; -export const BYPASS_PERMISSION_CHECKS = process.env.BYPASS_PERMISSION_CHECKS === 'true'; \ No newline at end of file +export const BYPASS_PERMISSION_CHECKS = process.env.BYPASS_PERMISSION_CHECKS === 'true'; +export const LOG_CORS = process.env.LOG_CORS === 'true'; \ No newline at end of file diff --git a/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts b/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts index 8d209aa5a..4180d5a7e 100644 --- a/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts +++ b/api/src/graphql/resolvers/mutation/connect/connect-sign-in.ts @@ -1,10 +1,10 @@ import { ensurePermission } from '@app/core/utils/index'; +import { NODE_ENV } from '@app/environment'; import { type MutationResolvers } from '@app/graphql/generated/api/types'; import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types'; import { validateApiKeyWithKeyServer } from '@app/mothership/api-key/validate-api-key-with-keyserver'; import { getters, store } from '@app/store/index'; -import { setApiKeyState } from '@app/store/modules/apikey'; -import { loginUser, signIn } from '@app/store/modules/config'; +import { loginUser } from '@app/store/modules/config'; import { FileLoadStatus } from '@app/store/types'; import { GraphQLError } from 'graphql'; import { decodeJwt } from 'jose'; @@ -21,7 +21,7 @@ export const connectSignIn: MutationResolvers['connectSignIn'] = async ( }); if (getters.emhttp().status === FileLoadStatus.LOADED) { - const result = await validateApiKeyWithKeyServer({ + const result = NODE_ENV === 'development' ? API_KEY_STATUS.API_KEY_VALID : await validateApiKeyWithKeyServer({ apiKey: args.input.apiKey, flashGuid: getters.emhttp().var.flashGuid, }); @@ -43,22 +43,14 @@ export const connectSignIn: MutationResolvers['connectSignIn'] = async ( ) { throw new GraphQLError('Missing User Attributes'); } - store.dispatch(setApiKeyState(API_KEY_STATUS.API_KEY_VALID)); - store.dispatch( - signIn({ - apikey: args.input.apiKey, - username: userInfo.preferred_username, - email: userInfo.email, - avatar: - typeof userInfo.avatar === 'string' ? userInfo.avatar : '', - }) - ); + // @TODO once we deprecate old sign in method, switch this to do all validation requests await store.dispatch( loginUser({ - avatar: '', + avatar: typeof userInfo.avatar === 'string' ? userInfo.avatar : '', username: userInfo.preferred_username, email: userInfo.email, + apikey: args.input.apiKey, }) ); return true; diff --git a/api/src/graphql/resolvers/mutation/connect/connect-sign-out.ts b/api/src/graphql/resolvers/mutation/connect/connect-sign-out.ts index 16b7a7217..2ad685ac3 100644 --- a/api/src/graphql/resolvers/mutation/connect/connect-sign-out.ts +++ b/api/src/graphql/resolvers/mutation/connect/connect-sign-out.ts @@ -1,7 +1,7 @@ import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; import { type MutationResolvers } from '@app/graphql/generated/api/types'; import { store } from '@app/store/index'; -import { logoutUser, signOut } from '@app/store/modules/config'; +import { logoutUser } from '@app/store/modules/config'; export const connectSignOut: MutationResolvers['connectSignOut'] = async ( _, @@ -14,7 +14,6 @@ export const connectSignOut: MutationResolvers['connectSignOut'] = async ( action: 'update', }); - store.dispatch(signOut()); await store.dispatch(logoutUser({ reason: 'Manual Sign Out With API' })); return true; }; diff --git a/api/src/index.ts b/api/src/index.ts index 9704be8bc..e83ed0ac8 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -22,6 +22,8 @@ import { type BaseContext, type ApolloServer } from '@apollo/server'; import { setupDynamixConfigWatch } from '@app/store/watch/dynamix-config-watch'; import { setupVarRunWatch } from '@app/store/watch/var-run-watch'; import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file'; +import { startMiddlewareListeners } from '@app/store/listeners/listener-middleware'; +import { validateApiKeyIfPresent } from '@app/store/listeners/api-key-listener'; let server: ApolloServer; @@ -64,14 +66,11 @@ void am( setupRegistrationKeyWatch(); // Start listening to docker events - await setupVarRunWatch(); + setupVarRunWatch(); // Start listening to dynamix config file changes setupDynamixConfigWatch(); - // Try and load the HTTP server - logger.debug('Starting HTTP server'); - // Disabled until we need the access token to work // TokenRefresh.init(); @@ -83,6 +82,10 @@ void am( PingTimeoutJobs.init(); + startMiddlewareListeners(); + + await validateApiKeyIfPresent(); + // On process exit stop HTTP server - this says it supports async but it doesnt seem to exitHook(() => { // If port is unix socket, delete socket before exiting diff --git a/api/src/mothership/api-key/retry-validate-api-key.ts b/api/src/mothership/api-key/retry-validate-api-key.ts new file mode 100644 index 000000000..119ba9b1e --- /dev/null +++ b/api/src/mothership/api-key/retry-validate-api-key.ts @@ -0,0 +1,25 @@ +import { THIRTY_MINUTES_MS } from '@app/consts'; +import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types'; +import { apiKeyCheckJob } from '@app/mothership/jobs/api-key-check-jobs'; +import { type AppDispatch, type RootState } from '@app/store/index'; +import { isApiKeyLoading } from '@app/store/getters/index'; +import pRetry from 'p-retry'; +import { setApiKeyState } from '@app/store/modules/apikey'; +import { keyServerLogger } from '@app/core/log'; + +export const retryValidateApiKey = async (getState: () => RootState, dispatch: AppDispatch): Promise => { + // Start job here + if (isApiKeyLoading(getState())) { + keyServerLogger.warn('Already running API Key validation, not starting another job'); + } else { + keyServerLogger.info('Starting API Key Validation Job'); + dispatch(setApiKeyState(API_KEY_STATUS.PENDING_VALIDATION)); + await pRetry(async count => apiKeyCheckJob(getState, dispatch, count), { + retries: 20_000, + minTimeout: 2_000, + maxTimeout: THIRTY_MINUTES_MS, + randomize: true, + factor: 2, + }); + } +}; diff --git a/api/src/mothership/api-key/validate-api-key-with-keyserver.ts b/api/src/mothership/api-key/validate-api-key-with-keyserver.ts new file mode 100644 index 000000000..b6d8895c4 --- /dev/null +++ b/api/src/mothership/api-key/validate-api-key-with-keyserver.ts @@ -0,0 +1,60 @@ +import { KEYSERVER_VALIDATION_ENDPOINT } from '@app/consts'; +import { keyServerLogger as ksLog } from '@app/core/log'; +import { sendFormToKeyServer } from '@app/core/utils/misc/send-form-to-keyserver'; +import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types'; +import { type Response } from 'got'; + +/** + * Perform a web validation of the API Key + * @param state + * @returns + */ +export const validateApiKeyWithKeyServer = async ({ flashGuid, apiKey }: { flashGuid: string; apiKey: string }): Promise => { + // If we're still loading config state, just return the config is loading + + ksLog.log('Validating API Key with KeyServer'); + + // Send apiKey, etc. to key-server for verification + let response: Response; + try { + response = await sendFormToKeyServer(KEYSERVER_VALIDATION_ENDPOINT, { + guid: flashGuid, + apikey: apiKey, + }); + } catch (error: unknown) { + ksLog.addContext('networkError', error); + ksLog.error('Caught error reaching Key Server'); + ksLog.removeContext('networkError'); + + return API_KEY_STATUS.NETWORK_ERROR; + } + + ksLog.trace('Got response back from key-server while validating API key'); + + if (response.statusCode !== 200) { + ksLog.warn('Error while validating API key with key-server', response); + return API_KEY_STATUS.INVALID_KEYSERVER_RESPONSE; + } + + // Get response data + let data: { valid: boolean }; + try { + data = JSON.parse(response.body); + } catch (error: unknown) { + ksLog.warn('Failed to parse Keyserver response body', error); + return API_KEY_STATUS.INVALID_KEYSERVER_RESPONSE; + } + + const { valid } = data; + + if (typeof valid === 'boolean') { + if (valid) { + return API_KEY_STATUS.API_KEY_VALID; + } + + return API_KEY_STATUS.API_KEY_INVALID; + } + + ksLog.warn('Returned data from keyserver appears to be invalid', data); + return API_KEY_STATUS.INVALID_KEYSERVER_RESPONSE; +}; diff --git a/api/src/mothership/graphql-client.ts b/api/src/mothership/graphql-client.ts new file mode 100644 index 000000000..6aeef0c69 --- /dev/null +++ b/api/src/mothership/graphql-client.ts @@ -0,0 +1,211 @@ +import { FIVE_MINUTES_MS, MOTHERSHIP_GRAPHQL_LINK } from '@app/consts'; +import { minigraphLogger } from '@app/core/log'; +import { + getMothershipConnectionParams, + getMothershipWebsocketHeaders, +} from '@app/mothership/utils/get-mothership-websocket-headers'; +import { getters, store } from '@app/store'; +import { type Client, createClient } from 'graphql-ws'; +import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status'; +import { + ApolloClient, + InMemoryCache, + type NormalizedCacheObject, +} from '@apollo/client/core/core.cjs'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { MinigraphStatus } from '@app/graphql/generated/api/types'; +import { API_VERSION } from '@app/environment'; +import { + receivedMothershipPing, + setMothershipTimeout, +} from '@app/store/modules/minigraph'; +import { logoutUser } from '@app/store/modules/config'; +import { RetryLink } from '@apollo/client/link/retry'; +import { ErrorLink } from '@apollo/client/link/error'; +import { isApiKeyValid } from '@app/store/getters/index'; +import { buildDelayFunction } from '@app/mothership/utils/delay-function'; +import { WebSocket } from 'ws'; + +const getWebsocketWithMothershipHeaders = () => { + return class WebsocketWithMothershipHeaders extends WebSocket { + constructor(address, protocols) { + super(address, protocols, { + headers: getMothershipWebsocketHeaders(), + }); + } + } +}; + +const delayFn = buildDelayFunction({ + jitter: true, + max: FIVE_MINUTES_MS, + initial: 10_000, +}); + +/** + * Checks that API_VERSION, config.remote.apiKey, emhttp.var.flashGuid, and emhttp.var.version are all set before returning true\ + * Also checks that the API Key has passed Validation from Keyserver + * @returns boolean, are variables set + */ +export const isAPIStateDataFullyLoaded = (state = store.getState()) => { + const { config, emhttp } = state; + return ( + Boolean(API_VERSION) && + Boolean(config.remote.apikey) && + Boolean(emhttp.var.flashGuid) && + Boolean(emhttp.var.version) + ); +}; + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class GraphQLClient { + public static instance: ApolloClient | null = null; + public static client: Client | null = null; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + /** + * Get a singleton GraphQL instance (if possible given loaded state) + * @returns ApolloClient instance or null, if state is not valid + */ + public static getInstance(): ApolloClient | null { + const isStateValid = isAPIStateDataFullyLoaded() && isApiKeyValid(); + if (!isStateValid) { + minigraphLogger.error( + 'GraphQL Client is not valid. Returning null for instance' + ); + return null; + } + + return GraphQLClient.instance; + } + + /** + * This function is used to create a new Apollo instance (if it is possible to do so) + * This is used in order to facilitate a single instance existing at a time + * @returns Apollo Instance (if creation was possible) + */ + public static createSingletonInstance = () => { + const isStateValid = isAPIStateDataFullyLoaded() && isApiKeyValid(); + + if (!GraphQLClient.instance && isStateValid) { + minigraphLogger.debug("Creating a new Apollo Client Instance"); + GraphQLClient.instance = GraphQLClient.createGraphqlClient(); + } + + return GraphQLClient.instance; + }; + + public static clearInstance = async () => { + if (this.instance) { + this.instance?.stop(); + } + + if (GraphQLClient.client) { + await GraphQLClient.client.dispose(); + GraphQLClient.client = null; + } + + GraphQLClient.instance = null; + GraphQLClient.client = null; + }; + + static createGraphqlClient() { + GraphQLClient.client = createClient({ + url: MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws'), + webSocketImpl: getWebsocketWithMothershipHeaders(), + connectionParams: () => getMothershipConnectionParams(), + }); + const wsLink = new GraphQLWsLink(GraphQLClient.client); + + const retryLink = new RetryLink({ + delay(count, operation, error) { + if ( + error instanceof Error && + error.message.includes('API Key Invalid') + ) { + void store.dispatch( + logoutUser({ reason: 'Invalid API Key on Mothership' }) + ); + } + + const getDelay = delayFn(count); + store.dispatch(setMothershipTimeout(getDelay)); + minigraphLogger.info('Delay currently is', getDelay); + return getDelay; + }, + attempts: { max: Infinity }, + }); + const errorLink = new ErrorLink((handler) => { + if (handler.graphQLErrors) { + // GQL Error Occurred, we should log and move on + minigraphLogger.info( + 'GQL Error Encountered %o', + handler.graphQLErrors + ); + } else if (handler.networkError) { + minigraphLogger.error( + 'Network Error Encountered %s', + handler.networkError.message + ); + if ( + getters.minigraph().status !== + MinigraphStatus.ERROR_RETRYING + ) { + store.dispatch( + setGraphqlConnectionStatus({ + status: MinigraphStatus.ERROR_RETRYING, + error: handler.networkError.message, + }) + ); + } + } + }); + const apolloClient = new ApolloClient({ + link: retryLink.concat(errorLink).concat(wsLink), + cache: new InMemoryCache(), + defaultOptions: { + watchQuery: { + fetchPolicy: 'no-cache', + errorPolicy: 'all', + }, + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all', + }, + }, + }); + // Maybe a listener to initiate this + GraphQLClient.client.on('connecting', () => { + store.dispatch( + setGraphqlConnectionStatus({ + status: MinigraphStatus.CONNECTING, + error: null, + }) + ); + minigraphLogger.info( + 'Connecting to %s', + MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws') + ); + }); + GraphQLClient.client.on('connected', () => { + store.dispatch( + setGraphqlConnectionStatus({ + status: MinigraphStatus.CONNECTED, + error: null, + }) + ); + minigraphLogger.info( + 'Connected to %s', + MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws') + ); + }); + + GraphQLClient.client.on('ping', () => { + // Received ping from mothership + minigraphLogger.trace('ping'); + store.dispatch(receivedMothershipPing()); + }); + return apolloClient; + } +} diff --git a/api/src/originMiddleware.ts b/api/src/originMiddleware.ts index 2878ed8ea..a3c117e56 100644 --- a/api/src/originMiddleware.ts +++ b/api/src/originMiddleware.ts @@ -1,6 +1,7 @@ import { type NextFunction, type Request, type Response } from 'express'; import { logger } from '@app/core'; import { getAllowedOrigins } from '@app/common/allowed-origins'; +import { LOG_CORS } from '@app/environment'; const getOriginGraphqlError = () => ({ data: null, @@ -39,13 +40,11 @@ export const originMiddleware = ( next(); return; } else { - logger.addContext('origins', allowedOrigins.join(', ')) - logger.trace( - `Current Origin: ${ - origin ?? 'undefined' - }` - ); - logger.removeContext('origins') + if (LOG_CORS) { + logger.addContext('origins', allowedOrigins.join(', ')); + logger.trace(`Current Origin: ${origin ?? 'undefined'}`); + logger.removeContext('origins'); + } } // Disallow requests with no origin @@ -56,7 +55,9 @@ export const originMiddleware = ( return; } - logger.trace(`📒 Checking "${origin}" for CORS access.`); + if (LOG_CORS) { + logger.trace(`📒 Checking "${origin}" for CORS access.`); + } // Only allow known origins if (!allowedOrigins.includes(origin)) { @@ -68,6 +69,8 @@ export const originMiddleware = ( return; } - logger.trace('✔️ Origin check passed, granting CORS!'); + if (LOG_CORS) { + logger.trace('✔️ Origin check passed, granting CORS!'); + } next(); }; diff --git a/api/src/server.ts b/api/src/server.ts index 45bae4863..176af5841 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -48,6 +48,8 @@ watch(configFilePath).on('all', updatePubsub); watch(customImageFilePath).on('all', updatePubsub); export const createApolloExpressServer = async () => { + // Try and load the HTTP server + graphqlLogger.debug('Starting HTTP server'); const app = express(); const httpServer = http.createServer(app); @@ -207,10 +209,10 @@ export const createApolloExpressServer = async () => { apolloServerPluginOnExit, ApolloServerPluginDrainHttpServer({ httpServer }), ], - introspection: GRAPHQL_INTROSPECTION + introspection: GRAPHQL_INTROSPECTION, }); - await apolloServer.start() + await apolloServer.start(); app.get('/graphql/api/logs', getLogs); @@ -347,7 +349,7 @@ export const createApolloExpressServer = async () => { res.status(error.status ?? 500).send(error); } ); - - httpServer.listen(PORT); + + httpServer.listen(PORT); return apolloServer; }; diff --git a/api/src/store/listeners/api-key-listener.ts b/api/src/store/listeners/api-key-listener.ts new file mode 100644 index 000000000..fa9ab3052 --- /dev/null +++ b/api/src/store/listeners/api-key-listener.ts @@ -0,0 +1,16 @@ +import { retryValidateApiKey } from '@app/mothership/api-key/retry-validate-api-key'; +import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client'; +import { isApiKeyLoading } from '@app/store/getters/index'; +import { store } from '@app/store/index'; + +export const validateApiKeyIfPresent = async () => { + const currentState = store.getState(); + if ( + currentState.config.remote?.apikey && + currentState.emhttp.var.flashGuid && + isAPIStateDataFullyLoaded(currentState) && + !isApiKeyLoading(currentState) + ) { + await retryValidateApiKey(store.getState, store.dispatch); + } +}; diff --git a/api/src/store/listeners/listener-middleware.ts b/api/src/store/listeners/listener-middleware.ts index b6aaed68d..65bbaeae1 100644 --- a/api/src/store/listeners/listener-middleware.ts +++ b/api/src/store/listeners/listener-middleware.ts @@ -9,11 +9,6 @@ import { enableUpnpListener } from '@app/store/listeners/upnp-listener'; import { enableAllowedOriginListener } from '@app/store/listeners/allowed-origin-listener'; import { enableConfigFileListener } from '@app/store/listeners/config-listener'; import { enableVersionListener } from '@app/store/listeners/version-listener'; -import { enableApiKeyListener } from '@app/store/listeners/api-key-listener'; -import { - enableLoginListener, - enableLogoutListener, -} from '@app/store/listeners/login-logout-listener'; import { enableMothershipJobsListener } from '@app/store/listeners/mothership-subscription-listener'; import { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-listener'; import { enableArrayEventListener } from '@app/store/listeners/array-event-listener'; @@ -37,18 +32,17 @@ export const addAppListener = addListener as TypedAddListener< AppDispatch >; -// Begin listening for events -enableConfigFileListener('flash')(); -enableConfigFileListener('memory')(); -enableLoginListener(); -enableLogoutListener(); -enableApiKeyListener(); -enableUpnpListener(); -enableAllowedOriginListener(); -enableVersionListener(); -enableMothershipJobsListener(); -enableDynamicRemoteAccessListener(); -enableArrayEventListener(); -enableWanAccessChangeListener(); -enableServerStateListener(); -enableNotificationPathListener(); +export const startMiddlewareListeners = () => { + // Begin listening for events + enableConfigFileListener('flash')(); + enableConfigFileListener('memory')(); + enableUpnpListener(); + enableAllowedOriginListener(); + enableVersionListener(); + enableMothershipJobsListener(); + enableDynamicRemoteAccessListener(); + enableArrayEventListener(); + enableWanAccessChangeListener(); + enableServerStateListener(); + enableNotificationPathListener(); +} diff --git a/api/src/store/modules/apikey.ts b/api/src/store/modules/apikey.ts new file mode 100644 index 000000000..e37b00dbd --- /dev/null +++ b/api/src/store/modules/apikey.ts @@ -0,0 +1,34 @@ +import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types'; +import { loginUser, logoutUser } from '@app/store/modules/config'; +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +interface ApiKeyInitialState { + status: API_KEY_STATUS; +} + +const initialState: ApiKeyInitialState = { + status: API_KEY_STATUS.NO_API_KEY, +}; + +const apiKey = createSlice({ + name: 'apiKey', + initialState, + reducers: { + setApiKeyState(state, action: PayloadAction) { + state.status = action.payload; + }, + }, + extraReducers(builder) { + builder.addCase(loginUser.fulfilled, (state) => { + state.status = API_KEY_STATUS.API_KEY_VALID; + }); + builder.addCase(logoutUser.fulfilled, (state) => { + state.status = API_KEY_STATUS.NO_API_KEY; + }); + }, +}); + +const { actions, reducer } = apiKey; + +export const { setApiKeyState } = actions; +export const apiKeyReducer = reducer; diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index aa1d5257b..6d4198e33 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -70,8 +70,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); @@ -263,33 +263,6 @@ export const config = createSlice({ setWanAccess(state, action: PayloadAction<'yes' | 'no'>) { state.remote.wanaccess = action.payload; }, - signIn: ( - state, - action: PayloadAction< - Pick< - MyServersConfig['remote'], - 'apikey' - > & Partial> - > - ) => { - state.remote.apikey = action.payload.apikey; - state.remote.idtoken = action.payload.idtoken ?? ''; - state.remote.accesstoken = action.payload.accesstoken ?? '' - state.remote.refreshtoken = action.payload.refreshtoken ?? '' - state.remote.email = action.payload.email ?? '', - state.remote.username = action.payload.username ?? '', - state.remote.avatar = action.payload.avatar ?? '' - }, - signOut: (state) => { - state.remote.apikey = ''; - state.remote.idtoken = ''; - state.remote.accesstoken = ''; - state.remote.refreshtoken = ''; - state.remote.email = ''; - state.remote.username = ''; - state.remote.avatar = ''; - } }, extraReducers(builder) { builder.addCase(loadConfigFile.pending, (state) => { @@ -323,13 +296,28 @@ export const config = createSlice({ } }); - builder.addCase(logoutUser.pending, (state) => { + builder.addCase(loginUser.fulfilled, (state, action) => { + merge(state, { + remote: { + apikey: action.payload.apikey, + email: action.payload.email, + username: action.payload.username, + avatar: action.payload.avatar, + }, + }); + }); + + builder.addCase(logoutUser.fulfilled, (state) => { merge(state, { remote: { apikey: '', avatar: '', email: '', username: '', + idtoken: '', + accessToken: '', + refreshToken: '', + dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED, }, }); }); @@ -348,8 +336,6 @@ export const { setUpnpState, setWanPortToValue, setWanAccess, - signIn, - signOut } = actions; export const configReducer = reducer; diff --git a/api/src/store/modules/minigraph.ts b/api/src/store/modules/minigraph.ts new file mode 100644 index 000000000..8addcde25 --- /dev/null +++ b/api/src/store/modules/minigraph.ts @@ -0,0 +1,98 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { MinigraphStatus } from '@app/graphql/generated/api/types'; +import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status'; +import { loginUser, logoutUser } from '@app/store/modules/config'; +import { minigraphLogger } from '@app/core/log'; +import { KEEP_ALIVE_INTERVAL_MS } from '@app/consts'; + +export type MinigraphClientState = { + status: MinigraphStatus; + error: string | null; + lastPing: number | null; + selfDisconnectedSince: number | null; + timeout: number | null; + timeoutStart: number | null; +}; + +const initialState: MinigraphClientState = { + status: MinigraphStatus.PRE_INIT, + error: null, + lastPing: null, + selfDisconnectedSince: null, + timeout: null, + timeoutStart: null, +}; + +export const mothership = createSlice({ + name: 'mothership', + initialState, + reducers: { + setMothershipTimeout(state, action: PayloadAction) { + state.timeout = action.payload; + state.timeoutStart = Date.now(); + }, + receivedMothershipPing(state) { + state.lastPing = Date.now(); + }, + setSelfDisconnected(state) { + minigraphLogger.error( + `Received disconnect event for own server, waiting for ${ + KEEP_ALIVE_INTERVAL_MS / 1_000 + } seconds before setting disconnected` + ); + state.selfDisconnectedSince = Date.now(); + }, + setSelfReconnected(state) { + minigraphLogger.error( + 'Received connected event for own server, clearing disconnection timeout' + ); + state.selfDisconnectedSince = null; + }, + }, + extraReducers(builder) { + builder.addCase(setGraphqlConnectionStatus, (state, action) => { + minigraphLogger.debug( + 'GraphQL Connection Status: ', + action.payload + ); + state.status = action.payload.status; + state.error = action.payload.error; + if ( + [ + MinigraphStatus.CONNECTED, + MinigraphStatus.CONNECTING, + ].includes(action.payload.status) + ) { + state.error = null; + state.timeout = null; + state.lastPing = null; + state.selfDisconnectedSince = null; + state.timeoutStart = null; + } + }); + builder.addCase(loginUser.pending, (state) => { + state.timeout = null; + state.timeoutStart = null; + state.lastPing = null; + state.selfDisconnectedSince = null; + state.status = MinigraphStatus.PRE_INIT; + state.error = + 'Connecting - refresh the page for an updated status.'; + }); + builder.addCase(logoutUser.pending, (state) => { + state.error = null; + state.timeout = null; + state.lastPing = null; + state.selfDisconnectedSince = null; + state.timeoutStart = null; + state.status = MinigraphStatus.PRE_INIT; + }); + }, +}); + +export const { + setMothershipTimeout, + receivedMothershipPing, + setSelfDisconnected, + setSelfReconnected, +} = mothership.actions; diff --git a/api/src/types/my-servers-config.d.ts b/api/src/types/my-servers-config.d.ts new file mode 100644 index 000000000..f5739e939 --- /dev/null +++ b/api/src/types/my-servers-config.d.ts @@ -0,0 +1,69 @@ +import { type MinigraphStatus } from '@app/graphql/generated/api/types'; +import { type DynamicRemoteAccessType } from '@app/remoteAccess/types'; + +interface MyServersConfig extends Record { + api: { + version: string; + extraOrigins?: string; + }; + local: { + '2Fa'?: string; + 'showT2Fa'?: string; + }; + notifier: { + apikey: string; + }; + remote: { + '2Fa'?: string; + wanaccess: string; + wanport: string; + upnpEnabled?: string; + apikey: 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; + }; + local: MyServersConfig['local'] & { + '2Fa': string; + 'showT2Fa': string; + }; + remote: MyServersConfig['remote'] & { + '2Fa': string; + 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; + }; +} +