diff --git a/api/README.md b/api/README.md index 76ae5b6d3..7163bea8b 100644 --- a/api/README.md +++ b/api/README.md @@ -31,7 +31,7 @@ Options: --environment production/staging/development Set the working environment. --log-level ALL/TRACE/DEBUG/INFO/WARN/ERROR/FATAL/MARK/OFF Set the log level. - Copyright © 2022 Lime Technology, Inc. + Copyright © 2024 Lime Technology, Inc. ``` @@ -55,4 +55,4 @@ unraid-api report -vv If you found this file you're likely a developer. If you'd like to know more about the API and when it's available please join [our discord](https://discord.unraid.net/). ## License -Copyright 2019-2022 Lime Technology Inc. All rights reserved. +Copyright Lime Technology Inc. All rights reserved. diff --git a/api/src/core/utils/plugins/wrapper.php b/api/src/core/utils/plugins/wrapper.php new file mode 100644 index 000000000..8673665f0 --- /dev/null +++ b/api/src/core/utils/plugins/wrapper.php @@ -0,0 +1,18 @@ + diff --git a/api/src/graphql/resolvers/query/cloud/check-api.ts b/api/src/graphql/resolvers/query/cloud/check-api.ts new file mode 100644 index 000000000..d7cc48703 --- /dev/null +++ b/api/src/graphql/resolvers/query/cloud/check-api.ts @@ -0,0 +1,12 @@ +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 => { + logger.trace('Cloud endpoint: Checking API'); + const valid = isApiKeyValid(); + const error = valid ? null : getters.apiKey().status; + + return { valid, error }; +}; diff --git a/api/src/graphql/resolvers/query/cloud/check-cloud.ts b/api/src/graphql/resolvers/query/cloud/check-cloud.ts new file mode 100644 index 000000000..680ccb510 --- /dev/null +++ b/api/src/graphql/resolvers/query/cloud/check-cloud.ts @@ -0,0 +1,100 @@ +import { FIVE_DAYS_SECS, MOTHERSHIP_GRAPHQL_LINK, ONE_DAY_SECS } from '@app/consts'; +import { logger } from '@app/core/log'; +import { checkDNS } from '@app/graphql/resolvers/query/cloud/check-dns'; +import { checkMothershipAuthentication } from '@app/graphql/resolvers/query/cloud/check-mothership-authentication'; +import { getters, store } from '@app/store'; +import { getCloudCache, getDnsCache } from '@app/store/getters'; +import { setCloudCheck, setDNSCheck } from '@app/store/modules/cache'; +import { got } from 'got'; +import { type CloudResponse, MinigraphStatus } from '@app/graphql/generated/api/types'; +import { API_VERSION } from '@app/environment'; + +const mothershipBaseUrl = new URL(MOTHERSHIP_GRAPHQL_LINK).origin; + +const createGotOptions = (apiVersion: string, apiKey: string) => ({ + timeout: { + request: 5_000, + }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-unraid-api-version': apiVersion, + 'x-api-key': apiKey, + }, +}); + +/** + * This is mainly testing the user's network config + * If they cannot resolve this they may have it blocked or have a routing issue + */ +const checkCanReachMothership = async (apiVersion: string, apiKey: string): Promise => { + const mothershipCanBeResolved = await got.head(mothershipBaseUrl, createGotOptions(apiVersion, apiKey)).then(() => true).catch(() => false); + if (!mothershipCanBeResolved) throw new Error(`Unable to connect to ${mothershipBaseUrl}`); +}; + +/** + * Run a more performant cloud check with permanent DNS checking + */ +const fastCloudCheck = async (): Promise => { + const result = { status: 'ok', error: null, ip: 'FAST_CHECK_NO_IP_FOUND' }; + + const cloudIp = getDnsCache()?.cloudIp ?? null; + if (cloudIp) { + result.ip = cloudIp; + } else { + try { + result.ip = (await checkDNS()).cloudIp; + logger.debug('DNS_CHECK_RESULT', await checkDNS()); + store.dispatch(setDNSCheck({ cloudIp: result.ip, ttl: FIVE_DAYS_SECS, error: null })); + } catch (error: unknown) { + logger.warn('Failed to fetch DNS, but Minigraph is connected - continuing'); + result.ip = `ERROR: ${error instanceof Error ? error.message : 'Unknown Error'}`; + // Don't set an error since we're actually connected to the cloud + store.dispatch(setDNSCheck({ cloudIp: result.ip, ttl: ONE_DAY_SECS, error: null })); + } + } + + return result; +}; + +export const checkCloud = async (): Promise => { + logger.trace('Cloud endpoint: Checking mothership'); + + try { + const config = getters.config(); + const apiVersion = API_VERSION; + const apiKey = config.remote.apikey; + const graphqlStatus = getters.minigraph().status; + const result = { status: 'ok', error: null, ip: 'NO_IP_FOUND' }; + + // If minigraph is connected, skip the follow cloud checks + if (graphqlStatus === MinigraphStatus.CONNECTED) { + return await fastCloudCheck(); + } + + // Check GraphQL Conneciton State, if it's broken, run these checks + if (!apiKey) throw new Error('API key is missing'); + + const oldCheckResult = getCloudCache(); + if (oldCheckResult) { + logger.trace('Using cached result for cloud check', oldCheckResult); + return oldCheckResult; + } + + // Check DNS + result.ip = (await checkDNS()).cloudIp; + // Check if we can reach mothership + await checkCanReachMothership(apiVersion, apiKey); + + // Check auth, rate limiting, etc. + await checkMothershipAuthentication(apiVersion, apiKey); + + // Cache for 10 minutes + store.dispatch(setCloudCheck(result)); + + return result; + } catch (error: unknown) { + if (!(error instanceof Error)) throw new Error(`Unknown Error "${error as string}"`); + return { status: 'error', error: error.message }; + } +}; diff --git a/api/src/graphql/resolvers/query/cloud/check-dns.ts b/api/src/graphql/resolvers/query/cloud/check-dns.ts index e40222fb4..0d083cdbd 100644 --- a/api/src/graphql/resolvers/query/cloud/check-dns.ts +++ b/api/src/graphql/resolvers/query/cloud/check-dns.ts @@ -1,8 +1,3 @@ -/*! - * Copyright 2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import { MOTHERSHIP_GRAPHQL_LINK } from '@app/consts'; import { store } from '@app/store'; import { getDnsCache } from '@app/store/getters'; diff --git a/api/src/graphql/resolvers/query/cloud/create-response.ts b/api/src/graphql/resolvers/query/cloud/create-response.ts new file mode 100644 index 000000000..0a5c79443 --- /dev/null +++ b/api/src/graphql/resolvers/query/cloud/create-response.ts @@ -0,0 +1,14 @@ +export type Cloud = { + error: string | null; + apiKey: { valid: true; error: null } | { valid: false; error: string }; + minigraphql: { + status: 'connected' | 'disconnected'; + }; + cloud: { status: 'ok'; error: null; ip: string } | { status: 'error'; error: string }; + allowedOrigins: string[]; +}; + +export const createResponse = (cloud: Omit): Cloud => ({ + ...cloud, + error: cloud.apiKey.error ?? cloud.cloud.error, +}); diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts index 5dd086461..24dd80dd6 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -22,7 +22,8 @@ import { fileExists } from '@app/core/utils/files/file-exists'; import { encode as encodeIni } from 'ini'; import { v7 as uuidv7 } from 'uuid'; import { CHOKIDAR_USEPOLLING } from '@app/environment'; -import { emptyDir, statSync } from 'fs-extra'; +import { emptyDir } from 'fs-extra'; +import { statSync } from 'fs'; import { execa } from 'execa'; import { AppError } from '@app/core/errors/app-error'; import { SortFn } from '@app/unraid-api/types/util';