mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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, () => {}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
api/src/store/getters/index.ts
Normal file
17
api/src/store/getters/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user