feat(web): rm api-key validation from connect sign in (#986)

* feat(api): rm api-key validation from connect sign in

This will now happen at the mothership layer.

* chore(api): rm redundant validate-api-key helper

* chore(api): rm obsolete api-key-check-job tests

* chore(api): suppress noisy notification loading logs

* feat(api): rm client-side mothership api key validation

refactor(api): encapsulate mothership jobs lifecycle

* fix(api): mothership gql client lifecycle & error handling

the api would crash when an invalid mothership api key was detected/invalidated *after* the inital server start/connection.

* refactor(api): rm mothership API_KEY_STATUS enum
This commit is contained in:
Pujit Mehrotra
2025-01-08 10:25:28 -05:00
committed by GitHub
parent 0042f14ab3
commit 939383e4ef
21 changed files with 284 additions and 521 deletions

View File

@@ -1,110 +0,0 @@
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
import * as apiKeyCheckJobs from '@app/mothership/jobs/api-key-check-jobs';
import * as apiKeyValidator from '@app/mothership/api-key/validate-api-key-with-keyserver';
import { describe, expect, it, vi } from 'vitest';
import { type RecursivePartial } from '@app/types/index';
import { type RootState } from '@app/store/index';
describe('apiKeyCheckJob Tests', () => {
it('API Check Job (with success)', async () => {
const getState = vi.fn<[], RecursivePartial<RootState>>().mockReturnValue({
apiKey: { status: API_KEY_STATUS.PENDING_VALIDATION },
config: { remote: { apikey: '_______________________BIG_API_KEY_HERE_________________________' } },
emhttp: { var: { flashGuid: 'my-flash-guid', version: '6.11.5' } },
});
const dispatch = vi.fn();
const validationSpy = vi.spyOn(apiKeyValidator, 'validateApiKeyWithKeyServer').mockResolvedValue(API_KEY_STATUS.API_KEY_VALID);
await expect(apiKeyCheckJobs.apiKeyCheckJob(getState, dispatch)).resolves.toBe(true);
expect(validationSpy).toHaveBeenCalledOnce();
expect(dispatch).toHaveBeenLastCalledWith({
payload: API_KEY_STATUS.API_KEY_VALID,
type: 'apiKey/setApiKeyState',
});
});
it('API Check Job (with invalid length key)', async () => {
// Setup state
const getState = vi.fn<[], RecursivePartial<RootState>>().mockReturnValue({
apiKey: { status: API_KEY_STATUS.PENDING_VALIDATION },
config: { remote: { apikey: 'too-short-key' } },
emhttp: { var: { flashGuid: 'my-flash-guid', version: '6.11.5' } },
});
const dispatch = vi.fn();
const validationSpy = vi.spyOn(apiKeyValidator, 'validateApiKeyWithKeyServer').mockResolvedValue(API_KEY_STATUS.API_KEY_VALID);
await expect(apiKeyCheckJobs.apiKeyCheckJob(getState, dispatch)).resolves.toBe(false);
expect(dispatch).toHaveBeenCalledWith(expect.any(Function));
expect(validationSpy).not.toHaveBeenCalled();
});
it('API Check Job (with a failure that throws an error - NETWORK_ERROR)', async () => {
const getState = vi.fn<[], RecursivePartial<RootState>>().mockReturnValue({
apiKey: { status: API_KEY_STATUS.PENDING_VALIDATION },
config: { remote: { apikey: '_______________________BIG_API_KEY_HERE_________________________' } },
emhttp: { var: { flashGuid: 'my-flash-guid', version: '6.11.5' } },
});
const dispatch = vi.fn();
const validationSpy = vi.spyOn(apiKeyValidator, 'validateApiKeyWithKeyServer')
.mockResolvedValueOnce(API_KEY_STATUS.NETWORK_ERROR);
await expect(apiKeyCheckJobs.apiKeyCheckJob(getState, dispatch)).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Keyserver Failure, must retry]`);
expect(validationSpy).toHaveBeenCalledOnce();
expect(dispatch).toHaveBeenCalledWith({
payload: API_KEY_STATUS.NETWORK_ERROR,
type: 'apiKey/setApiKeyState',
});
});
it('API Check Job (with a failure that throws an error - INVALID_RESPONSE)', async () => {
const getState = vi.fn<[], RecursivePartial<RootState>>().mockReturnValue({
apiKey: { status: API_KEY_STATUS.PENDING_VALIDATION },
config: { remote: { apikey: '_______________________BIG_API_KEY_HERE_________________________' } },
emhttp: { var: { flashGuid: 'my-flash-guid', version: '6.11.5' } },
});
const dispatch = vi.fn();
const validationSpy = vi.spyOn(apiKeyValidator, 'validateApiKeyWithKeyServer')
.mockResolvedValueOnce(API_KEY_STATUS.INVALID_KEYSERVER_RESPONSE);
await expect(apiKeyCheckJobs.apiKeyCheckJob(getState, dispatch)).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Keyserver Failure, must retry]`);
expect(validationSpy).toHaveBeenCalledOnce();
expect(dispatch).toHaveBeenCalledWith({
payload: API_KEY_STATUS.INVALID_KEYSERVER_RESPONSE,
type: 'apiKey/setApiKeyState',
});
}, 10_000);
it('API Check Job (with failure that results in a log out)', async () => {
const getState = vi.fn<[], RecursivePartial<RootState>>().mockReturnValue({
apiKey: { status: API_KEY_STATUS.PENDING_VALIDATION },
config: { remote: { apikey: '_______________________BIG_API_KEY_HERE_________________________' } },
emhttp: { var: { flashGuid: 'my-flash-guid', version: '6.11.5' } },
});
const dispatch = vi.fn();
const validationSpy = vi.spyOn(apiKeyValidator, 'validateApiKeyWithKeyServer')
.mockResolvedValue(API_KEY_STATUS.API_KEY_INVALID);
await expect(apiKeyCheckJobs.apiKeyCheckJob(getState, dispatch)).resolves.toBe(false);
expect(validationSpy).toHaveBeenCalledOnce();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(expect.any(Function));
}, 10_000);
});

View File

@@ -1,10 +1,7 @@
import { decodeJwt } from 'jose';
import type { ConnectSignInInput } from '@app/graphql/generated/api/types';
import { NODE_ENV } from '@app/environment';
import { Role } 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 { loginUser } from '@app/store/modules/config';
import { FileLoadStatus } from '@app/store/types';
@@ -12,17 +9,6 @@ import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
export const connectSignIn = async (input: ConnectSignInInput): Promise<boolean> => {
if (getters.emhttp().status === FileLoadStatus.LOADED) {
const result =
NODE_ENV === 'development'
? API_KEY_STATUS.API_KEY_VALID
: await validateApiKeyWithKeyServer({
apiKey: input.apiKey,
flashGuid: getters.emhttp().var.flashGuid,
});
if (result !== API_KEY_STATUS.API_KEY_VALID) {
throw new Error(`Validating API Key Failed with Error: ${result}`);
}
const userInfo = input.idToken ? decodeJwt(input.idToken) : (input.userInfo ?? null);
if (

View File

@@ -1,12 +1,7 @@
import { logger } from '@app/core/log';
import { getters } from '@app/store';
import { type ApiKeyResponse } from '@app/graphql/generated/api/types';
import { isApiKeyValid } from '@app/store/getters/index';
export const checkApi = async (): Promise<ApiKeyResponse> => {
logger.trace('Cloud endpoint: Checking API');
const valid = isApiKeyValid();
const error = valid ? null : getters.apiKey().status;
return { valid, error };
return { valid: true };
};

View File

@@ -21,7 +21,6 @@ import { PingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs';
import { store } from '@app/store';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file';
import { shutdownApiEvent } from '@app/store/actions/shutdown-api-event';
import { validateApiKeyIfPresent } from '@app/store/listeners/api-key-listener';
import { startMiddlewareListeners } from '@app/store/listeners/listener-middleware';
import { loadConfigFile } from '@app/store/modules/config';
import { loadStateFiles } from '@app/store/modules/emhttp';
@@ -33,6 +32,8 @@ import { StateManager } from '@app/store/watch/state-watch';
import { setupVarRunWatch } from '@app/store/watch/var-run-watch';
import { bootstrapNestServer } from '@app/unraid-api/main';
import { setupNewMothershipSubscription } from './mothership/subscribe-to-mothership';
let server: NestFastifyApplication<RawServerDefault> | null = null;
const unlinkUnixPort = () => {
@@ -72,6 +73,8 @@ try {
// Load my dynamix config file into store
await store.dispatch(loadDynamixConfigFile());
await setupNewMothershipSubscription();
// Start listening to file updates
StateManager.getInstance();
@@ -92,15 +95,12 @@ try {
// Start webserver
server = await bootstrapNestServer();
PingTimeoutJobs.init();
startMiddlewareListeners();
await validateApiKeyIfPresent();
// On process exit stop HTTP server
exitHook(() => {
console.log('exithook');
exitHook((signal) => {
console.log('exithook', signal);
server?.close?.();
// If port is unix socket, delete socket before exiting
unlinkUnixPort();
@@ -113,7 +113,9 @@ try {
await new Promise(() => {});
} catch (error: unknown) {
if (error instanceof Error) {
logger.error('API-ERROR %s %s', error.message, error.stack);
logger.error(error, 'API-ERROR');
} else {
logger.error(error, 'Encountered unexpected error');
}
if (server) {
await server?.close?.();

View File

@@ -1,25 +0,0 @@
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<void> => {
// 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,
});
}
};

View File

@@ -1,58 +0,0 @@
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<API_KEY_STATUS> => {
// If we're still loading config state, just return the config is loading
ksLog.info('Validating API Key with KeyServer');
// Send apiKey, etc. to key-server for verification
let response: Response<string>;
try {
response = await sendFormToKeyServer(KEYSERVER_VALIDATION_ENDPOINT, {
guid: flashGuid,
apikey: apiKey,
});
} catch (error: unknown) {
ksLog.error({ error }, 'Caught error reaching Key Server');
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;
};

View File

@@ -1,30 +1,25 @@
import type { NormalizedCacheObject } from '@apollo/client/core/index.js';
import type { Client, Event as ClientEvent } from 'graphql-ws';
import { ApolloClient, ApolloLink, InMemoryCache, Observable } from '@apollo/client/core/index.js';
import { ErrorLink } from '@apollo/client/link/error/index.js';
import { RetryLink } from '@apollo/client/link/retry/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { createClient } from 'graphql-ws';
import { WebSocket } from 'ws';
import { FIVE_MINUTES_MS } from '@app/consts';
import { minigraphLogger } from '@app/core/log';
import { API_VERSION, MOTHERSHIP_GRAPHQL_LINK } from '@app/environment';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { buildDelayFunction } from '@app/mothership/utils/delay-function';
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/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { API_VERSION, MOTHERSHIP_GRAPHQL_LINK } 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/index.js';
import { ErrorLink } from '@apollo/client/link/error/index.js';
import { isApiKeyValid } from '@app/store/getters/index';
import { buildDelayFunction } from '@app/mothership/utils/delay-function';
import { WebSocket } from 'ws';
import { receivedMothershipPing, setMothershipTimeout } from '@app/store/modules/minigraph';
const getWebsocketWithMothershipHeaders = () => {
return class WebsocketWithMothershipHeaders extends WebSocket {
@@ -56,11 +51,14 @@ export const isAPIStateDataFullyLoaded = (state = store.getState()) => {
Boolean(emhttp.var.version)
);
};
const isInvalidApiKeyError = (error: unknown) =>
error instanceof Error && error.message.includes('API Key Invalid');
export class GraphQLClient {
public static instance: ApolloClient<NormalizedCacheObject> | null = null;
public static client: Client | null = null;
private constructor() {}
/**
@@ -68,11 +66,9 @@ export class GraphQLClient {
* @returns ApolloClient instance or null, if state is not valid
*/
public static getInstance(): ApolloClient<NormalizedCacheObject> | null {
const isStateValid = isAPIStateDataFullyLoaded() && isApiKeyValid();
const isStateValid = isAPIStateDataFullyLoaded();
if (!isStateValid) {
minigraphLogger.error(
'GraphQL Client is not valid. Returning null for instance'
);
minigraphLogger.error('GraphQL Client is not valid. Returning null for instance');
return null;
}
@@ -85,7 +81,7 @@ export class GraphQLClient {
* @returns Apollo Instance (if creation was possible)
*/
public static createSingletonInstance = () => {
const isStateValid = isAPIStateDataFullyLoaded() && isApiKeyValid();
const isStateValid = isAPIStateDataFullyLoaded();
if (!GraphQLClient.instance && isStateValid) {
minigraphLogger.debug('Creating a new Apollo Client Instance');
@@ -97,71 +93,34 @@ export class GraphQLClient {
public static clearInstance = async () => {
if (this.instance) {
await this.instance.clearStore();
this.instance?.stop();
}
if (GraphQLClient.client) {
GraphQLClient.clearClientEventHandlers();
GraphQLClient.client.terminate();
await GraphQLClient.client.dispose();
GraphQLClient.client = null;
}
GraphQLClient.instance = null;
GraphQLClient.client = null;
minigraphLogger.trace('Cleared GraphQl client & instance');
};
static createGraphqlClient() {
/** a graphql-ws client to communicate with mothership if user opts-in */
GraphQLClient.client = createClient({
url: MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws'),
webSocketImpl: getWebsocketWithMothershipHeaders(),
connectionParams: () => getMothershipConnectionParams(),
});
const wsLink = new GraphQLWsLink(GraphQLClient.client);
const { appErrorLink, retryLink, errorLink } = GraphQLClient.createApolloLinks();
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: %i', 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),
link: ApolloLink.from([appErrorLink, retryLink, errorLink, wsLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
@@ -174,40 +133,149 @@ export class GraphQLClient {
},
},
});
GraphQLClient.initEventHandlers();
return apolloClient;
}
/**
* Creates and configures Apollo links for error handling and retries
*
* @returns Object containing configured Apollo links:
* - appErrorLink: Prevents errors from bubbling "up" & potentially crashing the API
* - retryLink: Handles retrying failed operations with exponential backoff
* - errorLink: Handles GraphQL and network errors, including API key validation and connection status updates
*/
static createApolloLinks() {
/** prevents errors from bubbling beyond this link/apollo instance & potentially crashing the api */
const appErrorLink = new ApolloLink((operation, forward) => {
return new Observable((observer) => {
forward(operation).subscribe({
next: (result) => observer.next(result),
error: (error) => {
minigraphLogger.warn('Apollo error, will not retry: %s', error?.message);
observer.complete();
},
complete: () => observer.complete(),
});
});
});
const retryLink = new RetryLink({
delay(count, operation, error) {
const getDelay = delayFn(count);
store.dispatch(setMothershipTimeout(getDelay));
minigraphLogger.info('Delay currently is: %i', getDelay);
return getDelay;
},
attempts: {
max: Infinity,
retryIf: (error) => !isInvalidApiKeyError(error),
},
});
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) {
/**----------------------------------------------
* Handling of Network Errors
*
* When the handler has a void return,
* the network error will bubble up
* (i.e. left in the `ApolloLink.from` array).
*
* The underlying operation/request
* may be retried per the retry link.
*
* If the error is not retried, it will bubble
* into the appErrorLink and terminate there.
*---------------------------------------------**/
minigraphLogger.error(handler.networkError, 'Network Error');
const error = handler.networkError;
if (error?.message?.includes('to be an array of GraphQL errors, but got')) {
minigraphLogger.warn('detected malformed graphql error in websocket message');
}
if (isInvalidApiKeyError(error)) {
store
.dispatch(logoutUser({ reason: 'Invalid API Key on Mothership' }))
.catch((err) => {
minigraphLogger.error(err, 'Error during logout');
});
} else if (getters.minigraph().status !== MinigraphStatus.ERROR_RETRYING) {
store.dispatch(
setGraphqlConnectionStatus({
status: MinigraphStatus.ERROR_RETRYING,
error: handler.networkError.message,
})
);
}
}
});
return { appErrorLink, retryLink, errorLink } as const;
}
/**
* Initialize event handlers for the GraphQL client websocket connection
*
* Sets up handlers for:
* - 'connecting': Updates store with connecting status and logs connection attempt
* - 'error': Logs any GraphQL client errors
* - 'connected': Updates store with connected status and logs successful connection
* - 'ping': Handles ping messages from mothership to track connection health
*
* @param client - The GraphQL client instance to attach handlers to. Defaults to GraphQLClient.client
* @returns void
*/
private static initEventHandlers(client = GraphQLClient.client): void {
if (!client) return;
// Maybe a listener to initiate this
GraphQLClient.client.on('connecting', () => {
client.on('connecting', () => {
store.dispatch(
setGraphqlConnectionStatus({
status: MinigraphStatus.CONNECTING,
error: null,
})
);
minigraphLogger.info(
'Connecting to %s',
MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws')
);
minigraphLogger.info('Connecting to %s', MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws'));
});
GraphQLClient.client.on('error', (err) => {
client.on('error', (err) => {
minigraphLogger.error('GraphQL Client Error: %o', err);
})
GraphQLClient.client.on('connected', () => {
});
client.on('connected', () => {
store.dispatch(
setGraphqlConnectionStatus({
status: MinigraphStatus.CONNECTED,
error: null,
})
);
minigraphLogger.info(
'Connected to %s',
MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws')
);
minigraphLogger.info('Connected to %s', MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws'));
});
GraphQLClient.client.on('ping', () => {
client.on('ping', () => {
// Received ping from mothership
minigraphLogger.trace('ping');
store.dispatch(receivedMothershipPing());
});
return apolloClient;
}
/**
* Clears event handlers from the GraphQL client websocket connection
*
* Removes handlers for the specified events by replacing them with empty functions.
* This ensures no lingering event handlers remain when disposing of a client.
*
* @param client - The GraphQL client instance to clear handlers from. Defaults to GraphQLClient.client
* @param events - Array of event types to clear handlers for. Defaults to ['connected', 'connecting', 'error', 'ping']
* @returns void
*/
private static clearClientEventHandlers(
client = GraphQLClient.client,
events: ClientEvent[] = ['connected', 'connecting', 'error', 'ping']
): void {
if (!client) return;
events.forEach((eventName) => client.on(eventName, () => {}));
}
}

View File

@@ -1,63 +0,0 @@
import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client';
import { keyServerLogger } from '@app/core/log';
import { validateApiKeyWithKeyServer } from '@app/mothership/api-key/validate-api-key-with-keyserver';
import { type RootState, type AppDispatch } from '@app/store/index';
import { setApiKeyState } from '@app/store/modules/apikey';
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
import { logoutUser } from '@app/store/modules/config';
import { isApiKeyValid } from '@app/store/getters/index';
import { isApiKeyCorrectLength } from '@app/mothership/api-key/is-api-key-correct-length';
import { NODE_ENV } from '@app/environment';
export const apiKeyCheckJob = async (
getState: () => RootState,
dispatch: AppDispatch,
count?: number
): Promise<boolean> => {
keyServerLogger.debug('Running keyserver validation number: %s', count);
const state = getState();
if (state.apiKey.status === API_KEY_STATUS.NO_API_KEY) {
// Stop Job
return false;
}
if (isAPIStateDataFullyLoaded(state)) {
if (isApiKeyValid(state)) {
return true;
}
if (!isApiKeyCorrectLength(state.config.remote.apikey)) {
keyServerLogger.error('API Key has invalid length, logging you out.');
await dispatch(logoutUser({ reason: 'API Key has invalid length' }));
return false;
}
if (['development'].includes(NODE_ENV)) {
keyServerLogger.debug('In dev environment, marking API Key as Valid');
dispatch(setApiKeyState(API_KEY_STATUS.API_KEY_VALID));
return true;
}
const validationResponse = await validateApiKeyWithKeyServer({
flashGuid: state.emhttp.var.flashGuid,
apiKey: state.config.remote.apikey,
});
switch (validationResponse) {
case API_KEY_STATUS.API_KEY_VALID:
keyServerLogger.info('Stopping API Key Job as Keyserver Marked API Key Valid');
dispatch(setApiKeyState(validationResponse));
return true;
case API_KEY_STATUS.API_KEY_INVALID:
await dispatch(logoutUser({ reason: 'Invalid API Key' }));
return false;
default:
keyServerLogger.info('Request failed with status:', validationResponse);
dispatch(setApiKeyState(validationResponse));
throw new Error('Keyserver Failure, must retry');
}
} else {
keyServerLogger.warn('State Data Has Not Fully Loaded, this should not be possible');
dispatch(setApiKeyState(API_KEY_STATUS.NO_API_KEY));
return false;
}
};

View File

@@ -1,3 +1,5 @@
import { Cron, Expression, Initializer } from '@reflet/cron';
import { KEEP_ALIVE_INTERVAL_MS, ONE_MINUTE_MS } from '@app/consts';
import { minigraphLogger, mothershipLogger, remoteAccessLogger } from '@app/core/log';
import { DynamicRemoteAccessType, MinigraphStatus } from '@app/graphql/generated/api/types';
@@ -6,25 +8,21 @@ import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-sta
import { store } from '@app/store/index';
import { setRemoteAccessRunningType } from '@app/store/modules/dynamic-remote-access';
import { clearSubscription } from '@app/store/modules/remote-graphql';
import { Cron, Expression, Initializer } from '@reflet/cron';
export class PingTimeoutJobs extends Initializer<typeof PingTimeoutJobs> {
@Cron.PreventOverlap
@Cron(Expression.EVERY_MINUTE)
@Cron.Start
async checkForPingTimeouts() {
const state = store.getState()
const state = store.getState();
if (!isAPIStateDataFullyLoaded(state)) {
mothershipLogger.warn(
'State data not fully loaded, but job has been started'
);
mothershipLogger.warn('State data not fully loaded, but job has been started');
return;
}
// Check for ping timeouts in remote graphql events
const subscriptionsToClear = state.remoteGraphQL.subscriptions.filter(
(subscription) =>
Date.now() - subscription.lastPing > KEEP_ALIVE_INTERVAL_MS
(subscription) => Date.now() - subscription.lastPing > KEEP_ALIVE_INTERVAL_MS
);
if (subscriptionsToClear.length > 0) {
mothershipLogger.debug(
@@ -36,9 +34,7 @@ export class PingTimeoutJobs extends Initializer<typeof PingTimeoutJobs> {
);
}
subscriptionsToClear.forEach((sub) =>
store.dispatch(clearSubscription(sub.sha256))
);
subscriptionsToClear.forEach((sub) => store.dispatch(clearSubscription(sub.sha256)));
// Check for ping timeouts in mothership
if (
@@ -61,13 +57,10 @@ export class PingTimeoutJobs extends Initializer<typeof PingTimeoutJobs> {
// Check for ping timeouts from mothership events
if (
state.minigraph.selfDisconnectedSince &&
Date.now() - state.minigraph.selfDisconnectedSince >
KEEP_ALIVE_INTERVAL_MS &&
Date.now() - state.minigraph.selfDisconnectedSince > KEEP_ALIVE_INTERVAL_MS &&
state.minigraph.status === MinigraphStatus.CONNECTED
) {
minigraphLogger.error(
`SELF DISCONNECTION EVENT NEVER CLEARED, SOCKET MUST BE RECONNECTED`
);
minigraphLogger.error(`SELF DISCONNECTION EVENT NEVER CLEARED, SOCKET MUST BE RECONNECTED`);
store.dispatch(
setGraphqlConnectionStatus({
status: MinigraphStatus.PING_FAILURE,
@@ -77,12 +70,33 @@ export class PingTimeoutJobs extends Initializer<typeof PingTimeoutJobs> {
}
// Check for ping timeouts in remote access
if (state.dynamicRemoteAccess.lastPing && Date.now() - state.dynamicRemoteAccess.lastPing > ONE_MINUTE_MS) {
remoteAccessLogger.error(
`NO PINGS RECEIVED IN 1 MINUTE, REMOTE ACCESS MUST BE DISABLED`
);
if (
state.dynamicRemoteAccess.lastPing &&
Date.now() - state.dynamicRemoteAccess.lastPing > ONE_MINUTE_MS
) {
remoteAccessLogger.error(`NO PINGS RECEIVED IN 1 MINUTE, REMOTE ACCESS MUST BE DISABLED`);
store.dispatch(setRemoteAccessRunningType(DynamicRemoteAccessType.DISABLED));
}
}
}
let pingTimeoutJobs: ReturnType<typeof PingTimeoutJobs.init<PingTimeoutJobs>> | null = null;
export const initPingTimeoutJobs = (): boolean => {
if (!pingTimeoutJobs) {
pingTimeoutJobs = PingTimeoutJobs.init();
}
pingTimeoutJobs.startAll();
return pingTimeoutJobs.get('checkForPingTimeouts').running ?? false;
};
export const stopPingTimeoutJobs = () => {
minigraphLogger.trace('Stopping Ping Timeout Jobs');
if (!pingTimeoutJobs) {
minigraphLogger.warn('PingTimeoutJobs Handler not found.');
return;
}
pingTimeoutJobs.stopAll();
pingTimeoutJobs.clear();
pingTimeoutJobs = null;
};

View File

@@ -1,20 +1,15 @@
import { minigraphLogger, mothershipLogger } from '@app/core/log';
import { GraphQLClient } from './graphql-client';
import { store } from '@app/store';
import {
EVENTS_SUBSCRIPTION,
RemoteGraphQL_Fragment,
} from '@app/graphql/mothership/subscriptions';
import { ClientType } from '@app/graphql/generated/client/graphql';
import { notNull } from '@app/utils';
import { useFragment } from '@app/graphql/generated/client/fragment-masking';
import { ClientType } from '@app/graphql/generated/client/graphql';
import { EVENTS_SUBSCRIPTION, RemoteGraphQL_Fragment } from '@app/graphql/mothership/subscriptions';
import { store } from '@app/store';
import { handleRemoteGraphQLEvent } from '@app/store/actions/handle-remote-graphql-event';
import {
setSelfDisconnected,
setSelfReconnected,
} from '@app/store/modules/minigraph';
import { setSelfDisconnected, setSelfReconnected } from '@app/store/modules/minigraph';
import { notNull } from '@app/utils';
import { GraphQLClient } from './graphql-client';
import { initPingTimeoutJobs, PingTimeoutJobs } from './jobs/ping-timeout-jobs';
import { getMothershipConnectionParams } from './utils/get-mothership-websocket-headers';
export const subscribeToEvents = async (apiKey: string) => {
minigraphLogger.info('Subscribing to Events');
@@ -29,15 +24,9 @@ export const subscribeToEvents = async (apiKey: string) => {
});
eventsSub.subscribe(async ({ data, errors }) => {
if (errors) {
mothershipLogger.error(
'GraphQL Error with events subscription: %s',
errors.join(',')
);
mothershipLogger.error('GraphQL Error with events subscription: %s', errors.join(','));
} else if (data) {
mothershipLogger.trace(
{ events: data.events },
'Got events from mothership'
);
mothershipLogger.trace({ events: data.events }, 'Got events from mothership');
for (const event of data.events?.filter(notNull) ?? []) {
switch (event.__typename) {
@@ -71,15 +60,10 @@ export const subscribeToEvents = async (apiKey: string) => {
}
case 'RemoteGraphQLEvent': {
const eventAsRemoteGraphQLEvent = useFragment(
RemoteGraphQL_Fragment,
event
);
const eventAsRemoteGraphQLEvent = useFragment(RemoteGraphQL_Fragment, event);
// No need to check API key here anymore
void store.dispatch(
handleRemoteGraphQLEvent(eventAsRemoteGraphQLEvent)
);
void store.dispatch(handleRemoteGraphQLEvent(eventAsRemoteGraphQLEvent));
break;
}
@@ -90,3 +74,16 @@ export const subscribeToEvents = async (apiKey: string) => {
}
});
};
export const setupNewMothershipSubscription = async (state = store.getState()) => {
await GraphQLClient.clearInstance();
if (getMothershipConnectionParams(state)?.apiKey) {
minigraphLogger.trace('Creating Graphql client');
const client = GraphQLClient.createSingletonInstance();
if (client) {
minigraphLogger.trace('Connecting to mothership');
await subscribeToEvents(state.config.remote.apikey);
initPingTimeoutJobs();
}
}
};

View File

@@ -3,7 +3,6 @@ import { API_VERSION } from '@app/environment';
import { ClientType } from '@app/graphql/generated/client/graphql';
import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client';
import { store } from '@app/store';
import { isApiKeyValid } from '@app/store/getters/index';
import { type OutgoingHttpHeaders } from 'node:http2';
@@ -17,8 +16,7 @@ interface MothershipWebsocketHeaders extends OutgoingHttpHeaders {
export const getMothershipWebsocketHeaders = (state = store.getState()): MothershipWebsocketHeaders | OutgoingHttpHeaders => {
const { config, emhttp } = state;
if (isAPIStateDataFullyLoaded(state) && isApiKeyValid(state)) {
if (isAPIStateDataFullyLoaded(state)) {
const headers: MothershipWebsocketHeaders = {
'x-api-key': config.remote.apikey,
'x-flash-guid': emhttp.var.flashGuid,
@@ -29,7 +27,6 @@ export const getMothershipWebsocketHeaders = (state = store.getState()): Mothers
logger.debug('Mothership websocket headers: %o', headers);
return headers;
}
return {};
};
@@ -43,7 +40,7 @@ interface MothershipConnectionParams extends Record<string, unknown> {
export const getMothershipConnectionParams = (state = store.getState()): MothershipConnectionParams | Record<string, unknown> => {
const { config, emhttp } = state;
if (isAPIStateDataFullyLoaded(state) && isApiKeyValid(state)) {
if (isAPIStateDataFullyLoaded(state)) {
return {
clientType: ClientType.API,
apiKey: config.remote.apikey,

View File

@@ -0,0 +1,17 @@
import { getters, store } from '@app/store';
import { CacheKeys, type DNSCheck } from '@app/store/types';
import { type CloudResponse } from '@app/graphql/generated/api/types';
export const getCloudCache = (): CloudResponse | undefined => {
const { nodeCache } = getters.cache();
return nodeCache.get(CacheKeys.checkCloud);
};
export const getDnsCache = (): DNSCheck | undefined => {
const { nodeCache } = getters.cache();
return nodeCache.get(CacheKeys.checkDns);
};
export const hasRemoteSubscription = (sha256: string, state = store.getState()): boolean => {
return state.remoteGraphQL.subscriptions.some(sub => sub.sha256 === sha256);
}

View File

@@ -8,7 +8,6 @@ import { cache } from '@app/store/modules/cache';
import { docker } from '@app/store/modules/docker';
import { upnp } from '@app/store/modules/upnp';
import { listenerMiddleware } from '@app/store/listeners/listener-middleware';
import { apiKeyReducer } from '@app/store/modules/apikey';
import { dynamicRemoteAccessReducer } from '@app/store/modules/dynamic-remote-access';
import { remoteGraphQLReducer } from '@app/store/modules/remote-graphql';
import { dynamix } from '@app/store/modules/dynamix';
@@ -16,7 +15,6 @@ import { notificationReducer } from '@app/store/modules/notifications';
export const store = configureStore({
reducer: {
apiKey: apiKeyReducer,
config: configReducer,
dynamicRemoteAccess: dynamicRemoteAccessReducer,
minigraph: mothership.reducer,
@@ -40,7 +38,6 @@ export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const getters = {
apiKey: () => store.getState().apiKey,
cache: () => store.getState().cache,
config: () => store.getState().config,
docker: () => store.getState().docker,

View File

@@ -1,16 +0,0 @@
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);
}
};

View File

@@ -30,11 +30,11 @@ export const addAppListener = addListener as TypedAddListener<RootState, AppDisp
export const startMiddlewareListeners = () => {
// Begin listening for events
enableLocalApiKeyListener();
enableMothershipJobsListener();
enableConfigFileListener('flash')();
enableConfigFileListener('memory')();
enableUpnpListener();
enableVersionListener();
enableMothershipJobsListener();
enableDynamicRemoteAccessListener();
enableArrayEventListener();
enableWanAccessChangeListener();

View File

@@ -1,8 +1,5 @@
import { logger } from '@app/core/log';
import { NODE_ENV } from '@app/environment';
import { Role } 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 } from '@app/store/index';
import { startAppListening } from '@app/store/listeners/listener-middleware';
import { updateUserConfig } from '@app/store/modules/config';
@@ -23,18 +20,6 @@ export const enableLocalApiKeyListener = () =>
const { remote } = getters.config();
const { apikey, username } = remote;
// Validate the API key with the key server
const validationResult =
NODE_ENV === 'development'
? API_KEY_STATUS.API_KEY_VALID
: await validateApiKeyWithKeyServer({
apiKey: apikey as string,
flashGuid: getters.emhttp().var.flashGuid,
});
if (validationResult !== API_KEY_STATUS.API_KEY_VALID) {
throw new Error('API key validation failed');
}
const apiKeyService = new ApiKeyService();
// Create local API key
const localApiKey = await apiKeyService.create(

View File

@@ -1,34 +1,41 @@
import { startAppListening } from '@app/store/listeners/listener-middleware';
import { subscribeToEvents } from '@app/mothership/subscribe-to-mothership';
import { getMothershipConnectionParams } from '@app/mothership/utils/get-mothership-websocket-headers';
import isEqual from 'lodash/isEqual';
import { GraphQLClient } from '@app/mothership/graphql-client';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status';
import { minigraphLogger } from '@app/core/log';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { setupNewMothershipSubscription } from '@app/mothership/subscribe-to-mothership';
import { getMothershipConnectionParams } from '@app/mothership/utils/get-mothership-websocket-headers';
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status';
import { startAppListening } from '@app/store/listeners/listener-middleware';
export const enableMothershipJobsListener = () => startAppListening({
predicate(action, currentState, previousState) {
// This event happens on first app load, or if a user signs out and signs back in, etc
if (!isEqual(getMothershipConnectionParams(currentState), getMothershipConnectionParams(previousState)) && getMothershipConnectionParams(currentState)?.apiKey) {
minigraphLogger.info('Connecting / Reconnecting Mothership Due to Changed Config File or First Load')
return true;
}
export const enableMothershipJobsListener = () =>
startAppListening({
predicate(action, currentState, previousState) {
const newConnectionParams = !isEqual(
getMothershipConnectionParams(currentState),
getMothershipConnectionParams(previousState)
);
const apiKey = getMothershipConnectionParams(currentState)?.apiKey;
if (setGraphqlConnectionStatus.match(action) && [MinigraphStatus.PING_FAILURE, MinigraphStatus.PRE_INIT].includes(action.payload.status)) {
minigraphLogger.info('Reconnecting Mothership - PING_FAILURE / PRE_INIT - SetGraphQLConnectionStatus Event')
return true;
}
// This event happens on first app load, or if a user signs out and signs back in, etc
if (newConnectionParams && apiKey) {
minigraphLogger.info('Connecting / Reconnecting Mothership Due to Changed Config File');
return true;
}
return false;
}, async effect(_, { getState }) {
await GraphQLClient.clearInstance();
if (getMothershipConnectionParams(getState())?.apiKey) {
const client = GraphQLClient.createSingletonInstance();
if (client) {
await subscribeToEvents(getState().config.remote.apikey);
}
if (
setGraphqlConnectionStatus.match(action) &&
[MinigraphStatus.PING_FAILURE, MinigraphStatus.PRE_INIT].includes(action.payload.status)
) {
minigraphLogger.info(
'Reconnecting Mothership - PING_FAILURE / PRE_INIT - SetGraphQLConnectionStatus Event'
);
return true;
}
}
},
});
return false;
},
async effect(_, { getState }) {
minigraphLogger.trace('Renewing mothership subscription');
await setupNewMothershipSubscription(getState());
},
});

View File

@@ -1,34 +0,0 @@
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<API_KEY_STATUS>) {
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;

View File

@@ -25,6 +25,8 @@ import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub';
import { isEqual } from 'lodash-es';
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access';
import { NODE_ENV } from '@app/environment';
import { GraphQLClient } from '@app/mothership/graphql-client';
import { stopPingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs';
export type SliceState = {
status: FileLoadStatus;
@@ -105,6 +107,8 @@ export const logoutUser = createAsyncThunk<
};
// Publish to owner endpoint
await pubsub.publish(PUBSUB_CHANNEL.OWNER, { owner });
stopPingTimeoutJobs();
await GraphQLClient.clearInstance();
});
/**

View File

@@ -52,7 +52,7 @@ export const mothership = createSlice({
extraReducers(builder) {
builder.addCase(setGraphqlConnectionStatus, (state, action) => {
minigraphLogger.debug(
'GraphQL Connection Status: ',
'GraphQL Connection Status: %o',
action.payload
);
state.status = action.payload.status;

View File

@@ -98,7 +98,7 @@ export class NotificationsService {
private async handleNotificationAdd(path: string) {
// The path looks like /{notification base path}/{type}/{notification id}
const type = path.includes('/unread/') ? NotificationType.UNREAD : NotificationType.ARCHIVE;
this.logger.debug(`Adding ${type} Notification: ${path}`);
// this.logger.debug(`Adding ${type} Notification: ${path}`);
const notification = await this.loadNotificationFile(path, NotificationType[type]);
this.increment(notification.importance, NotificationsService.overview[type.toLowerCase()]);
@@ -632,7 +632,7 @@ export class NotificationsService {
type: 'ini',
});
this.logger.verbose(`Loaded notification ini file from ${path}}`);
// this.logger.verbose(`Loaded notification ini file from ${path}}`);
const notification: Notification = this.notificationFileToGqlNotification(
{ id: this.getIdFromPath(path), type },
@@ -722,7 +722,7 @@ export class NotificationsService {
this.logger.warn(`[formatTimestamp] Could not parse date from timestamp: ${date}`);
return timestamp;
}
this.logger.debug(`[formatTimestamp] ${settings.date} :: ${settings.time} :: ${date}`);
// this.logger.debug(`[formatTimestamp] ${settings.date} :: ${settings.time} :: ${date}`);
return formatDatetime(date, {
dateFormat: settings.date,
timeFormat: settings.time,