diff --git a/api/.env.development b/api/.env.development index 35a4b30d71..00925516fb 100644 --- a/api/.env.development +++ b/api/.env.development @@ -19,12 +19,14 @@ PATHS_LOGS_FILE=./dev/log/graphql-api.log PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file PATHS_OIDC_JSON=./dev/configs/oidc.local.json PATHS_LOCAL_SESSION_FILE=./dev/local-session +PATHS_CONNECT_STATUS=./dev/states/connectStatus.json # Connect status file for development ENVIRONMENT="development" NODE_ENV="development" PORT="3001" PLAYGROUND=true INTROSPECTION=true -MOTHERSHIP_GRAPHQL_LINK="http://authenticator:3000/graphql" +MOTHERSHIP_GRAPHQL_LINK="wss://preview.mothership2.unraid.net" +MOTHERSHIP_BASE_URL="https://preview.mothership2.unraid.net" NODE_TLS_REJECT_UNAUTHORIZED=0 BYPASS_PERMISSION_CHECKS=false BYPASS_CORS_CHECKS=true diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index acaf5daa92..e09b0f3f55 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,5 +1,5 @@ { - "version": "4.25.3", + "version": "4.27.2", "extraOrigins": [], "sandbox": true, "ssoSubIds": [], diff --git a/api/dev/configs/connect.json b/api/dev/configs/connect.json index a853ba3f91..ab7694ea62 100644 --- a/api/dev/configs/connect.json +++ b/api/dev/configs/connect.json @@ -2,7 +2,7 @@ "wanaccess": true, "wanport": 8443, "upnpEnabled": false, - "apikey": "", + "apikey": "_______________________LOCAL_API_KEY_HERE_________________________", "localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________", "email": "test@example.com", "username": "zspearmint", diff --git a/api/dev/states/connectStatus.json b/api/dev/states/connectStatus.json new file mode 100644 index 0000000000..573dc765cb --- /dev/null +++ b/api/dev/states/connectStatus.json @@ -0,0 +1,7 @@ +{ + "connectionStatus": "PRE_INIT", + "error": null, + "lastPing": null, + "allowedOrigins": "", + "timestamp": 1764601989840 +} \ No newline at end of file diff --git a/api/scripts/build.ts b/api/scripts/build.ts index 924b3f4ca3..9e7082e0c8 100755 --- a/api/scripts/build.ts +++ b/api/scripts/build.ts @@ -7,7 +7,7 @@ import { exit } from 'process'; import type { PackageJson } from 'type-fest'; import { $, cd } from 'zx'; -import { getDeploymentVersion } from './get-deployment-version.js'; +import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js'; type ApiPackageJson = PackageJson & { version: string; diff --git a/api/src/unraid-api/unraid-file-modifier/file-modification.ts b/api/src/unraid-api/unraid-file-modifier/file-modification.ts index 0dcfd0e32c..26216af250 100644 --- a/api/src/unraid-api/unraid-file-modifier/file-modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/file-modification.ts @@ -8,6 +8,7 @@ import { applyPatch, createPatch, parsePatch, reversePatch } from 'diff'; import { coerce, compare, gte, lte } from 'semver'; import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js'; +import { NODE_ENV } from '@app/environment.js'; export type ModificationEffect = 'nginx:reload'; @@ -225,6 +226,14 @@ export abstract class FileModification { throw new Error('Invalid file modification configuration'); } + // Skip file modifications in development mode + if (NODE_ENV === 'development') { + return { + shouldApply: false, + reason: 'File modifications are disabled in development mode', + }; + } + const fileExists = await access(this.filePath, constants.R_OK | constants.W_OK) .then(() => true) .catch(() => false); diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts index cca3cae831..6d29ded880 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts @@ -8,6 +8,7 @@ import { import { ConfigService } from '@nestjs/config'; import type { ModificationEffect } from '@app/unraid-api/unraid-file-modifier/file-modification.js'; +import { NODE_ENV } from '@app/environment.js'; import { FileModificationEffectService } from '@app/unraid-api/unraid-file-modifier/file-modification-effect.service.js'; import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js'; @@ -29,6 +30,11 @@ export class UnraidFileModificationService */ async onModuleInit() { try { + if (NODE_ENV === 'development') { + this.logger.log('Skipping file modifications in development mode'); + return; + } + this.logger.log('Loading file modifications...'); const mods = await this.loadModifications(); await this.applyModifications(mods); diff --git a/packages/unraid-api-plugin-connect/codegen.ts b/packages/unraid-api-plugin-connect/codegen.ts index 56fde40e16..3965c70f85 100644 --- a/packages/unraid-api-plugin-connect/codegen.ts +++ b/packages/unraid-api-plugin-connect/codegen.ts @@ -29,26 +29,7 @@ const config: CodegenConfig = { }, }, generates: { - // Generate Types for Mothership GraphQL Client - 'src/graphql/generated/client/': { - documents: './src/graphql/**/*.ts', - schema: { - [process.env.MOTHERSHIP_GRAPHQL_LINK ?? 'https://staging.mothership.unraid.net/ws']: { - headers: { - origin: 'https://forums.unraid.net', - }, - }, - }, - preset: 'client', - presetConfig: { - gqlTagName: 'graphql', - }, - config: { - useTypeImports: true, - withObjectType: true, - }, - plugins: [{ add: { content: '/* eslint-disable */' } }], - }, + // No longer generating mothership GraphQL types since we switched to WebSocket-based UnraidServerClient }, }; diff --git a/packages/unraid-api-plugin-connect/package.json b/packages/unraid-api-plugin-connect/package.json index 9cac4d8789..fb526208b5 100644 --- a/packages/unraid-api-plugin-connect/package.json +++ b/packages/unraid-api-plugin-connect/package.json @@ -13,7 +13,7 @@ "build": "tsc", "prepare": "npm run build", "format": "prettier --write \"src/**/*.{ts,js,json}\"", - "codegen": "MOTHERSHIP_GRAPHQL_LINK='https://staging.mothership.unraid.net/ws' graphql-codegen --config codegen.ts" + "codegen": "graphql-codegen --config codegen.ts" }, "keywords": [ "unraid", @@ -57,6 +57,7 @@ "jose": "6.0.13", "lodash-es": "4.17.21", "nest-authz": "2.17.0", + "pify": "^6.1.0", "prettier": "3.6.2", "rimraf": "6.0.1", "rxjs": "7.8.2", diff --git a/packages/unraid-api-plugin-connect/src/connection-status/cloud.service.ts b/packages/unraid-api-plugin-connect/src/connection-status/cloud.service.ts index 343800665e..727ac579cc 100644 --- a/packages/unraid-api-plugin-connect/src/connection-status/cloud.service.ts +++ b/packages/unraid-api-plugin-connect/src/connection-status/cloud.service.ts @@ -204,7 +204,7 @@ export class CloudService { } private async hardCheckDns() { - const mothershipGqlUri = this.configService.getOrThrow('MOTHERSHIP_GRAPHQL_LINK'); + const mothershipGqlUri = this.configService.getOrThrow('MOTHERSHIP_BASE_URL'); const hostname = new URL(mothershipGqlUri).host; const lookup = promisify(lookupDNS); const resolve = promisify(resolveDNS); diff --git a/packages/unraid-api-plugin-connect/src/connection-status/connect-status-writer.service.ts b/packages/unraid-api-plugin-connect/src/connection-status/connect-status-writer.service.ts index 011078eb75..cc04321358 100644 --- a/packages/unraid-api-plugin-connect/src/connection-status/connect-status-writer.service.ts +++ b/packages/unraid-api-plugin-connect/src/connection-status/connect-status-writer.service.ts @@ -1,7 +1,8 @@ import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OnEvent } from '@nestjs/event-emitter'; -import { unlink, writeFile } from 'fs/promises'; +import { mkdir, unlink, writeFile } from 'fs/promises'; +import { dirname } from 'path'; import { ConfigType, ConnectionMetadata } from '../config/connect.config.js'; import { EVENTS } from '../helper/nest-tokens.js'; @@ -13,8 +14,8 @@ export class ConnectStatusWriterService implements OnApplicationBootstrap, OnMod private logger = new Logger(ConnectStatusWriterService.name); get statusFilePath() { - // Use environment variable if provided, otherwise use default path - return process.env.PATHS_CONNECT_STATUS_FILE_PATH ?? '/var/local/emhttp/connectStatus.json'; + // Use environment variable if set, otherwise default to /var/local/emhttp/connectStatus.json + return this.configService.get('PATHS_CONNECT_STATUS') || '/var/local/emhttp/connectStatus.json'; } async onApplicationBootstrap() { @@ -59,6 +60,10 @@ export class ConnectStatusWriterService implements OnApplicationBootstrap, OnMod const data = JSON.stringify(statusData, null, 2); this.logger.verbose(`Writing connection status: ${data}`); + // Ensure the directory exists before writing + const dir = dirname(this.statusFilePath); + await mkdir(dir, { recursive: true }); + await writeFile(this.statusFilePath, data); this.logger.verbose(`Status written to ${this.statusFilePath}`); } catch (error) { diff --git a/packages/unraid-api-plugin-connect/src/graphql/remote-response.ts b/packages/unraid-api-plugin-connect/src/graphql/remote-response.ts index 00129db974..b15980a4d0 100644 --- a/packages/unraid-api-plugin-connect/src/graphql/remote-response.ts +++ b/packages/unraid-api-plugin-connect/src/graphql/remote-response.ts @@ -1,5 +1,5 @@ // Import from the generated directory -import { graphql } from '../graphql/generated/client/gql.js'; +import { graphql } from './generated/client/gql.js'; export const SEND_REMOTE_QUERY_RESPONSE = graphql(/* GraphQL */ ` mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) { diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/local-graphql-executor.service.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/local-graphql-executor.service.ts new file mode 100644 index 0000000000..509ff5d1f1 --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/local-graphql-executor.service.ts @@ -0,0 +1,162 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { gql } from '@apollo/client/core/index.js'; +import { parse, print, visit } from 'graphql'; + +import { + CANONICAL_INTERNAL_CLIENT_TOKEN, + type CanonicalInternalClientService, +} from '@unraid/shared'; + +interface GraphQLExecutor { + execute(params: { + query: string + variables?: Record + operationName?: string + operationType?: 'query' | 'mutation' | 'subscription' + }): Promise + stopSubscription?(operationId: string): Promise +} + +/** + * Local GraphQL executor that maps remote queries to local API calls + */ +@Injectable() +export class LocalGraphQLExecutor implements GraphQLExecutor { + private readonly logger = new Logger(LocalGraphQLExecutor.name); + + constructor( + @Inject(CANONICAL_INTERNAL_CLIENT_TOKEN) + private readonly internalClient: CanonicalInternalClientService, + ) {} + + async execute(params: { + query: string + variables?: Record + operationName?: string + operationType?: 'query' | 'mutation' | 'subscription' + }): Promise { + const { query, variables, operationName, operationType } = params; + + try { + this.logger.debug(`Executing ${operationType} operation: ${operationName || 'unnamed'}`); + this.logger.verbose(`Query: ${query}`); + this.logger.verbose(`Variables: ${JSON.stringify(variables)}`); + + // Transform remote query to local query by removing "remote" prefixes + const localQuery = this.transformRemoteQueryToLocal(query); + + // Execute the transformed query against local API + const client = await this.internalClient.getClient(); + const result = await client.query({ + query: gql`${localQuery}`, + variables, + }); + + return { + data: result.data, + }; + } catch (error: any) { + this.logger.error(`GraphQL execution error: ${error?.message}`); + return { + errors: [ + { + message: error?.message || 'Unknown error', + extensions: { code: 'EXECUTION_ERROR' }, + }, + ], + }; + } + } + + /** + * Transform remote GraphQL query to local query by removing "remote" prefixes + */ + private transformRemoteQueryToLocal(query: string): string { + try { + // Parse the GraphQL query + const document = parse(query); + + // Transform the document by removing "remote" prefixes + const transformedDocument = visit(document, { + // Transform operation names (e.g., remoteGetDockerInfo -> getDockerInfo) + OperationDefinition: (node) => { + if (node.name?.value.startsWith('remote')) { + return { + ...node, + name: { + ...node.name, + value: this.removeRemotePrefix(node.name.value), + }, + }; + } + return node; + }, + // Transform field names (e.g., remoteGetDockerInfo -> docker, remoteGetVms -> vms) + Field: (node) => { + if (node.name.value.startsWith('remote')) { + return { + ...node, + name: { + ...node.name, + value: this.transformRemoteFieldName(node.name.value), + }, + }; + } + return node; + }, + }); + + // Convert back to string + return print(transformedDocument); + } catch (error) { + this.logger.error(`Failed to parse/transform GraphQL query: ${error}`); + throw error; + } + } + + /** + * Remove "remote" prefix from operation names + */ + private removeRemotePrefix(name: string): string { + if (name.startsWith('remote')) { + // remoteGetDockerInfo -> getDockerInfo + return name.slice(6); // Remove "remote" + } + return name; + } + + /** + * Transform remote field names to local equivalents + */ + private transformRemoteFieldName(fieldName: string): string { + // Handle common patterns + if (fieldName === 'remoteGetDockerInfo') { + return 'docker'; + } + if (fieldName === 'remoteGetVms') { + return 'vms'; + } + if (fieldName === 'remoteGetSystemInfo') { + return 'system'; + } + + // Generic transformation: remove "remoteGet" and convert to camelCase + if (fieldName.startsWith('remoteGet')) { + const baseName = fieldName.slice(9); // Remove "remoteGet" + return baseName.charAt(0).toLowerCase() + baseName.slice(1); + } + + // Remove "remote" prefix as fallback + if (fieldName.startsWith('remote')) { + const baseName = fieldName.slice(6); // Remove "remote" + return baseName.charAt(0).toLowerCase() + baseName.slice(1); + } + + return fieldName; + } + + async stopSubscription(operationId: string): Promise { + this.logger.debug(`Stopping subscription: ${operationId}`); + // Subscription cleanup logic would go here + } +} diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership-subscription.handler.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership-subscription.handler.ts index fefc358bdc..d83a3720e6 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership-subscription.handler.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership-subscription.handler.ts @@ -14,207 +14,145 @@ import { useFragment } from '../graphql/generated/client/index.js'; import { SEND_REMOTE_QUERY_RESPONSE } from '../graphql/remote-response.js'; import { parseGraphQLQuery } from '../helper/parse-graphql.js'; import { MothershipConnectionService } from './connection.service.js'; -import { MothershipGraphqlClientService } from './graphql.client.js'; +import { UnraidServerClientService } from './unraid-server-client.service.js'; -type SubscriptionProxy = { +interface SubscriptionInfo { sha256: string; - body: string; -}; - -type ActiveSubscription = { - subscription: Subscription; + createdAt: number; lastPing: number; -}; + operationId?: string; +} @Injectable() export class MothershipSubscriptionHandler { constructor( @Inject(CANONICAL_INTERNAL_CLIENT_TOKEN) private readonly internalClientService: CanonicalInternalClientService, - private readonly mothershipClient: MothershipGraphqlClientService, + private readonly mothershipClient: UnraidServerClientService, private readonly connectionService: MothershipConnectionService ) {} private readonly logger = new Logger(MothershipSubscriptionHandler.name); - private subscriptions: Map = new Map(); - private mothershipSubscription: Subscription | null = null; + private readonly activeSubscriptions = new Map(); removeSubscription(sha256: string) { - this.subscriptions.get(sha256)?.subscription.unsubscribe(); - const removed = this.subscriptions.delete(sha256); - // If this line outputs false, the subscription did not exist in the map. - this.logger.debug(`Removed subscription ${sha256}: ${removed}`); - this.logger.verbose(`Current active subscriptions: ${this.subscriptions.size}`); + const subscription = this.activeSubscriptions.get(sha256); + if (subscription) { + this.logger.debug(`Removing subscription ${sha256}`); + this.activeSubscriptions.delete(sha256); + + // Stop the subscription via the UnraidServerClient if it has an operationId + const client = this.mothershipClient.getClient(); + if (client && subscription.operationId) { + // Note: We can't directly call stopSubscription on the client since it's private + // This would need to be exposed or handled differently in a real implementation + this.logger.debug(`Should stop subscription with operationId: ${subscription.operationId}`); + } + } else { + this.logger.debug(`Subscription ${sha256} not found`); + } } clearAllSubscriptions() { - this.logger.verbose('Clearing all active subscriptions'); - this.subscriptions.forEach(({ subscription }) => { - subscription.unsubscribe(); - }); - this.subscriptions.clear(); - this.logger.verbose(`Current active subscriptions: ${this.subscriptions.size}`); + this.logger.verbose(`Clearing ${this.activeSubscriptions.size} active subscriptions`); + + // Stop all subscriptions via the UnraidServerClient + const client = this.mothershipClient.getClient(); + if (client) { + for (const [sha256, subscription] of this.activeSubscriptions.entries()) { + if (subscription.operationId) { + this.logger.debug(`Should stop subscription with operationId: ${subscription.operationId}`); + } + } + } + + this.activeSubscriptions.clear(); } clearStaleSubscriptions({ maxAgeMs }: { maxAgeMs: number }) { - if (this.subscriptions.size === 0) { - return; - } - const totalSubscriptions = this.subscriptions.size; - let numOfStaleSubscriptions = 0; const now = Date.now(); - this.subscriptions - .entries() - .filter(([, { lastPing }]) => { - return now - lastPing > maxAgeMs; - }) - .forEach(([sha256]) => { + const staleSubscriptions: string[] = []; + + for (const [sha256, subscription] of this.activeSubscriptions.entries()) { + const age = now - subscription.lastPing; + if (age > maxAgeMs) { + staleSubscriptions.push(sha256); + } + } + + if (staleSubscriptions.length > 0) { + this.logger.verbose(`Clearing ${staleSubscriptions.length} stale subscriptions older than ${maxAgeMs}ms`); + + for (const sha256 of staleSubscriptions) { this.removeSubscription(sha256); - numOfStaleSubscriptions++; - }); - this.logger.verbose( - `Cleared ${numOfStaleSubscriptions}/${totalSubscriptions} subscriptions (older than ${maxAgeMs}ms)` - ); + } + } else { + this.logger.verbose(`No stale subscriptions found (${this.activeSubscriptions.size} active)`); + } } pingSubscription(sha256: string) { - const subscription = this.subscriptions.get(sha256); + const subscription = this.activeSubscriptions.get(sha256); if (subscription) { subscription.lastPing = Date.now(); + this.logger.verbose(`Updated ping for subscription ${sha256}`); } else { - this.logger.warn(`Subscription ${sha256} not found; cannot ping`); + this.logger.verbose(`Ping for unknown subscription ${sha256}`); } } - public async addSubscription({ sha256, body }: SubscriptionProxy) { - if (this.subscriptions.has(sha256)) { - throw new Error(`Subscription already exists for SHA256: ${sha256}`); - } - const parsedBody = parseGraphQLQuery(body); - const client = await this.internalClientService.getClient(); - const observable = client.subscribe({ - query: parsedBody.query, - variables: parsedBody.variables, - }); - const subscription = observable.subscribe({ - next: async (val) => { - this.logger.verbose(`Subscription ${sha256} received value: %O`, val); - if (!val.data) return; - const result = await this.mothershipClient.sendQueryResponse(sha256, { - data: val.data, - }); - this.logger.verbose(`Subscription ${sha256} published result: %O`, result); - }, - error: async (err) => { - this.logger.warn(`Subscription ${sha256} error: %O`, err); - await this.mothershipClient.sendQueryResponse(sha256, { - errors: err, - }); - }, - }); - this.subscriptions.set(sha256, { - subscription, - lastPing: Date.now(), - }); - this.logger.verbose(`Added subscription ${sha256}`); - return { + addSubscription(sha256: string, operationId?: string) { + const now = Date.now(); + const subscription: SubscriptionInfo = { sha256, - subscription, + createdAt: now, + lastPing: now, + operationId }; + + this.activeSubscriptions.set(sha256, subscription); + this.logger.debug(`Added subscription ${sha256} ${operationId ? `with operationId: ${operationId}` : ''}`); } - async executeQuery(sha256: string, body: string) { - const internalClient = await this.internalClientService.getClient(); - const parsedBody = parseGraphQLQuery(body); - const queryInput = { - query: parsedBody.query, - variables: parsedBody.variables, - }; - this.logger.verbose(`Executing query: %O`, queryInput); - - const result = await internalClient.query(queryInput); - if (result.error) { - this.logger.warn(`Query returned error: %O`, result.error); - this.mothershipClient.sendQueryResponse(sha256, { - errors: result.error, - }); - return result; - } - this.mothershipClient.sendQueryResponse(sha256, { - data: result.data, - }); - return result; - } - - async safeExecuteQuery(sha256: string, body: string) { - try { - return await this.executeQuery(sha256, body); - } catch (error) { - this.logger.error(error); - this.mothershipClient.sendQueryResponse(sha256, { - errors: error, - }); - } + stopMothershipSubscription() { + this.logger.verbose('Stopping mothership subscription (not implemented yet)'); } - async handleRemoteGraphQLEvent(event: RemoteGraphQlEventFragmentFragment) { - const { body, type, sha256 } = event.remoteGraphQLEventData; - switch (type) { - case RemoteGraphQlEventType.REMOTE_QUERY_EVENT: - return this.safeExecuteQuery(sha256, body); - case RemoteGraphQlEventType.REMOTE_SUBSCRIPTION_EVENT: - return this.addSubscription(event.remoteGraphQLEventData); - case RemoteGraphQlEventType.REMOTE_SUBSCRIPTION_EVENT_PING: - return this.pingSubscription(sha256); - default: - return; + async subscribeToMothershipEvents() { + this.logger.log('Subscribing to mothership events via UnraidServerClient'); + + // For now, just log that we're connected + // The UnraidServerClient handles the WebSocket connection automatically + const client = this.mothershipClient.getClient(); + if (client) { + this.logger.log('UnraidServerClient is connected and handling mothership communication'); + } else { + this.logger.warn('UnraidServerClient is not available'); } } - stopMothershipSubscription() { - this.mothershipSubscription?.unsubscribe(); - this.mothershipSubscription = null; - } - - async subscribeToMothershipEvents(client = this.mothershipClient.getClient()) { - if (!client) { - this.logger.error('Mothership client unavailable. State might not be loaded.'); - return; - } - const subscription = client.subscribe({ - query: EVENTS_SUBSCRIPTION, - fetchPolicy: 'no-cache', - }); - this.mothershipSubscription = subscription.subscribe({ - next: (event) => { - if (event.errors) { - this.logger.error(`Error received from mothership: %O`, event.errors); - return; - } - if (!event.data) return; - const { events } = event.data; - for (const event of events?.filter(isDefined) ?? []) { - const { __typename: eventType } = event; - if (eventType === 'ClientConnectedEvent') { - if ( - event.connectedData.type === ClientType.API && - event.connectedData.apiKey === this.connectionService.getApiKey() - ) { - this.connectionService.clearDisconnectedTimestamp(); - } - } else if (eventType === 'ClientDisconnectedEvent') { - if ( - event.disconnectedData.type === ClientType.API && - event.disconnectedData.apiKey === this.connectionService.getApiKey() - ) { - this.connectionService.setDisconnectedTimestamp(); - } - } else if (eventType === 'RemoteGraphQLEvent') { - const remoteGraphQLEvent = useFragment(RemoteGraphQL_Fragment, event); - return this.handleRemoteGraphQLEvent(remoteGraphQLEvent); - } + async executeQuery(sha256: string, body: string) { + this.logger.debug(`Request to execute query ${sha256}: ${body} (simplified implementation)`); + + try { + // For now, just return a success response + // TODO: Implement actual query execution via the UnraidServerClient + return { + data: { + message: 'Query executed successfully (simplified)', + sha256, } - }, - }); + }; + } catch (error: any) { + this.logger.error(`Error executing query ${sha256}:`, error); + return { + errors: [ + { + message: `Query execution failed: ${error?.message || 'Unknown error'}`, + extensions: { code: 'EXECUTION_ERROR' }, + }, + ], + }; + } } } diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts index 237479aa3f..f6fbe6a1f1 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts @@ -2,12 +2,12 @@ import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@ne import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js'; import { MothershipConnectionService } from './connection.service.js'; -import { MothershipGraphqlClientService } from './graphql.client.js'; +import { UnraidServerClientService } from './unraid-server-client.service.js'; import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js'; /** * Controller for (starting and stopping) the mothership stack: - * - GraphQL client (to mothership) + * - UnraidServerClient (websocket communication with mothership) * - Subscription handler (websocket communication with mothership) * - Timeout checker (to detect if the connection to mothership is lost) * - Connection service (controller for connection state & metadata) @@ -16,7 +16,7 @@ import { MothershipSubscriptionHandler } from './mothership-subscription.handler export class MothershipController implements OnModuleDestroy, OnApplicationBootstrap { private readonly logger = new Logger(MothershipController.name); constructor( - private readonly clientService: MothershipGraphqlClientService, + private readonly clientService: UnraidServerClientService, private readonly connectionService: MothershipConnectionService, private readonly subscriptionHandler: MothershipSubscriptionHandler, private readonly timeoutCheckerJob: TimeoutCheckerJob @@ -36,7 +36,9 @@ export class MothershipController implements OnModuleDestroy, OnApplicationBoots async stop() { this.timeoutCheckerJob.stop(); this.subscriptionHandler.stopMothershipSubscription(); - await this.clientService.clearInstance(); + if (this.clientService.getClient()) { + this.clientService.getClient()?.disconnect(); + } this.connectionService.resetMetadata(); this.subscriptionHandler.clearAllSubscriptions(); } @@ -46,13 +48,13 @@ export class MothershipController implements OnModuleDestroy, OnApplicationBoots */ async initOrRestart() { await this.stop(); - const { state } = this.connectionService.getIdentityState(); + const identityState = this.connectionService.getIdentityState(); this.logger.verbose('cleared, got identity state'); - if (!state.apiKey) { - this.logger.warn('No API key found; cannot setup mothership subscription'); + if (!identityState.isLoaded || !identityState.state.apiKey) { + this.logger.warn('No API key found; cannot setup mothership connection'); return; } - await this.clientService.createClientInstance(); + await this.clientService.reconnect(); await this.subscriptionHandler.subscribeToMothershipEvents(); this.timeoutCheckerJob.start(); } diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts index 267b438262..d5ee472999 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts @@ -1,23 +1,23 @@ import { Module } from '@nestjs/common'; - - import { CloudResolver } from '../connection-status/cloud.resolver.js'; import { CloudService } from '../connection-status/cloud.service.js'; import { ConnectStatusWriterService } from '../connection-status/connect-status-writer.service.js'; import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js'; import { RemoteAccessModule } from '../remote-access/remote-access.module.js'; import { MothershipConnectionService } from './connection.service.js'; -import { MothershipGraphqlClientService } from './graphql.client.js'; +import { LocalGraphQLExecutor } from './local-graphql-executor.service.js'; import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js'; import { MothershipController } from './mothership.controller.js'; import { MothershipHandler } from './mothership.events.js'; +import { UnraidServerClientService } from './unraid-server-client.service.js'; @Module({ imports: [RemoteAccessModule], providers: [ ConnectStatusWriterService, MothershipConnectionService, - MothershipGraphqlClientService, + LocalGraphQLExecutor, + UnraidServerClientService, MothershipHandler, MothershipSubscriptionHandler, TimeoutCheckerJob, diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/unraid-server-client.service.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/unraid-server-client.service.ts new file mode 100644 index 0000000000..421881e992 --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/unraid-server-client.service.ts @@ -0,0 +1,480 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { WebSocket } from 'ws'; + +import { MothershipConnectionService } from './connection.service.js'; +import { LocalGraphQLExecutor } from './local-graphql-executor.service.js'; + +/** + * Unraid server client for connecting to the new mothership architecture + * This handles GraphQL requests from the mothership and executes them using a local Apollo client + */ + + + +interface GraphQLResponse { + operationId: string + messageId?: string + event: 'query_response' + type: 'data' | 'error' | 'complete' + payload: any + requestHash?: string +} + +interface GraphQLExecutor { + execute(params: { + query: string + variables?: Record + operationName?: string + operationType?: 'query' | 'mutation' | 'subscription' + }): Promise + stopSubscription?(operationId: string): Promise +} + + +export class UnraidServerClient { + private ws: WebSocket | null = null + private reconnectAttempts = 0 + private readonly initialReconnectDelay = 1000 // 1 second + private readonly maxReconnectDelay = 30 * 60 * 1000 // 30 minutes + private pingInterval: NodeJS.Timeout | null = null + private reconnectTimeout: NodeJS.Timeout | null = null + private shouldReconnect = true + + constructor( + private mothershipUrl: string, + private apiKey: string, + private executor: GraphQLExecutor, + ) {} + + async connect(): Promise { + this.shouldReconnect = true + + return new Promise((resolve, reject) => { + try { + const wsUrl = `${this.mothershipUrl}/ws/server` + this.ws = new WebSocket(wsUrl, [], { + headers: { + 'X-API-Key': this.apiKey, + }, + }) + + this.ws.onopen = () => { + console.log('Connected to mothership') + this.reconnectAttempts = 0 + this.setupPingInterval() + resolve() + } + + this.ws.onmessage = (event) => { + const data = typeof event.data === 'string' ? event.data : event.data.toString() + this.handleGraphQLRequest(data) + } + + this.ws.onclose = (event) => { + console.log('Disconnected from mothership:', event.code, event.reason) + this.clearPingInterval() + + if (this.shouldReconnect) { + this.scheduleReconnect() + } else { + console.log('Reconnection disabled, not scheduling reconnect') + } + } + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error) + reject(error) + } + } catch (error) { + reject(error) + } + }) + } + + private async handleGraphQLRequest(data: string) { + try { + // Handle plaintext ping/pong messages first + if (data.trim() === 'ping') { + this.sendPong() + return + } + + if (data.trim() === 'pong') { + console.log('Received pong from mothership') + return + } + + // Try to parse as JSON for structured messages + let message: any + try { + message = JSON.parse(data) + } catch (parseError) { + // Not valid JSON, could be other plaintext message + console.log('Received non-JSON message from mothership:', data.trim()) + return + } + + // Handle JSON ping/pong messages (fallback) + if (message.type === 'ping' || message.ping) { + this.sendPong() + return + } + + if (message.type === 'pong' || message.pong || JSON.stringify(message) === '"pong"') { + console.log('Received pong from mothership') + return + } + + // Handle new event-based GraphQL requests + if (message.event === 'remote_query' || message.event === 'subscription_start' || message.event === 'subscription_stop') { + await this.handleNewFormatGraphQLRequest(message) + return + } + + // Handle messages routed from RouterDO + if (message.event === 'route_message') { + await this.handleRouteMessage(message) + return + } + + // Handle request type messages (legacy format) + if (message.type === 'request') { + await this.handleRequestMessage(message) + return + } + + // Handle unknown message types + console.warn('Unknown message event received from mothership:', message.event || message.type, JSON.stringify(message).substring(0, 200)) + } catch (error: any) { + console.error('Error handling GraphQL request:', error) + + // Send error response if possible + try { + const errorRequest = JSON.parse(data) + // Only send error response for GraphQL requests that have operationId + if (errorRequest.operationId && (errorRequest.event === 'remote_query' || errorRequest.event === 'route_message')) { + const operationId = errorRequest.operationId || `error-${Date.now()}` + this.sendResponse({ + operationId, + event: 'query_response', + type: 'error', + payload: { + errors: [ + { + message: error?.message || 'Unknown error', + extensions: { code: 'EXECUTION_ERROR' }, + }, + ], + }, + }) + } + } catch (e) { + console.error('Failed to send error response:', e) + } + } + } + + private sendResponse(response: GraphQLResponse) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(response)) + } + } + + private sendPong() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + // Send plaintext pong response + this.ws.send('pong') + } + } + + private setupPingInterval() { + this.clearPingInterval() + // Send ping every 30 seconds to keep connection alive + this.pingInterval = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + // Send plaintext ping + this.ws.send('ping') + } + }, 30000) + } + + private clearPingInterval() { + if (this.pingInterval) { + clearInterval(this.pingInterval) + this.pingInterval = null + } + } + + private scheduleReconnect() { + if (!this.shouldReconnect) { + console.log('Reconnection disabled, not scheduling reconnect') + return + } + + this.reconnectAttempts++ + + // Calculate exponential backoff delay: 1s, 2s, 4s, 8s, 16s, 32s, etc. + // Cap at maxReconnectDelay (30 minutes) + const exponentialDelay = this.initialReconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + const delay = Math.min(exponentialDelay, this.maxReconnectDelay) + + console.log( + `Scheduling reconnection attempt ${this.reconnectAttempts} in ${delay / 1000}s (${Math.floor(delay / 60000)}m ${Math.floor((delay % 60000) / 1000)}s)` + ) + + // Clear any existing reconnect timeout + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + } + + this.reconnectTimeout = setTimeout( + () => { + if (!this.shouldReconnect) { + console.log('Reconnection disabled, skipping attempt') + return + } + + console.log(`Reconnection attempt ${this.reconnectAttempts}`) + this.connect().catch((error) => { + console.error('Reconnection failed:', error) + // Schedule next reconnection attempt + this.scheduleReconnect() + }) + }, + delay + ) + } + + private async handleNewFormatGraphQLRequest(message: any) { + if (!message.payload || !message.payload.query) { + console.warn('Invalid GraphQL request - missing payload or query:', message) + return + } + + const operationId = message.operationId || `auto-${Date.now()}` + const messageId = message.messageId || `msg_${operationId}_${Date.now()}` + + // Handle subscription stop + if (message.event === 'subscription_stop') { + if (this.executor.stopSubscription) { + await this.executor.stopSubscription(operationId) + } + this.sendResponse({ + operationId, + messageId, + event: 'query_response', + type: 'complete', + payload: { data: null }, + }) + return + } + + // Execute GraphQL operation for remote_query and subscription_start events + if (message.event === 'remote_query' || message.event === 'subscription_start') { + try { + const operationType = message.event === 'subscription_start' ? 'subscription' : 'query' + const result = await this.executor.execute({ + query: message.payload.query, + variables: message.payload.variables, + operationName: message.payload.operationName, + operationType, + }) + + // Send response back to mothership + const response: GraphQLResponse = { + operationId, + messageId: `msg_response_${Date.now()}`, + event: 'query_response', + type: result.errors ? 'error' : 'data', + payload: result, + } + + this.sendResponse(response) + } catch (error: any) { + this.sendResponse({ + operationId, + messageId: `msg_error_${Date.now()}`, + event: 'query_response', + type: 'error', + payload: { + errors: [ + { + message: error?.message || 'Unknown error', + extensions: { code: 'EXECUTION_ERROR' }, + }, + ], + }, + }) + } + } + } + + private async handleRouteMessage(message: any) { + if (!message.payload || !message.payload.query) { + console.warn('Invalid route message - missing payload or query:', message) + return + } + + const operationId = message.operationId || `auto-${Date.now()}` + + try { + const result = await this.executor.execute({ + query: message.payload.query, + variables: message.payload.variables, + operationName: message.payload.operationName, + operationType: 'query', + }) + + // Send response back to mothership + const response: GraphQLResponse = { + operationId, + messageId: `msg_response_${Date.now()}`, + event: 'query_response', + type: result.errors ? 'error' : 'data', + payload: result, + } + + this.sendResponse(response) + } catch (error: any) { + this.sendResponse({ + operationId, + messageId: `msg_error_${Date.now()}`, + event: 'query_response', + type: 'error', + payload: { + errors: [ + { + message: error?.message || 'Unknown error', + extensions: { code: 'EXECUTION_ERROR' }, + }, + ], + }, + }) + } + } + + private async handleRequestMessage(message: any) { + if (!message.payload || !message.payload.query) { + console.warn('Invalid request message - missing payload or query:', message) + return + } + + const operationId = message.operationId || `auto-${Date.now()}` + + try { + const result = await this.executor.execute({ + query: message.payload.query, + variables: message.payload.variables, + operationName: message.payload.operationName, + operationType: 'query', + }) + + // Send response back to mothership + const response: GraphQLResponse = { + operationId, + messageId: `msg_response_${Date.now()}`, + event: 'query_response', + type: result.errors ? 'error' : 'data', + payload: result, + } + + this.sendResponse(response) + } catch (error: any) { + this.sendResponse({ + operationId, + messageId: `msg_error_${Date.now()}`, + event: 'query_response', + type: 'error', + payload: { + errors: [ + { + message: error?.message || 'Unknown error', + extensions: { code: 'EXECUTION_ERROR' }, + }, + ], + }, + }) + } + } + + disconnect() { + this.shouldReconnect = false + this.clearPingInterval() + + // Clear any pending reconnection attempts + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + + if (this.ws) { + this.ws.close() + this.ws = null + } + + console.log('Disconnected from mothership (reconnection disabled)') + } +} + +@Injectable() +export class UnraidServerClientService implements OnModuleInit, OnModuleDestroy { + private logger = new Logger(UnraidServerClientService.name); + private client: UnraidServerClient | null = null; + + constructor( + private readonly configService: ConfigService, + private readonly connectionService: MothershipConnectionService, + private readonly localExecutor: LocalGraphQLExecutor + ) {} + + async onModuleInit(): Promise { + // Initialize the client when the module starts + await this.initializeClient(); + } + + async onModuleDestroy(): Promise { + if (this.client) { + this.client.disconnect(); + this.client = null; + } + } + + private async initializeClient(): Promise { + try { + const mothershipUrl = this.configService.getOrThrow('MOTHERSHIP_GRAPHQL_LINK'); + const identityState = this.connectionService.getIdentityState(); + + if (!identityState.isLoaded || !identityState.state.apiKey) { + this.logger.warn('No API key available, cannot initialize UnraidServerClient'); + return; + } + + // Use the injected LocalGraphQLExecutor + const executor = this.localExecutor; + + this.client = new UnraidServerClient( + mothershipUrl, + identityState.state.apiKey, + executor + ); + + await this.client.connect(); + this.logger.log('UnraidServerClient connected successfully'); + } catch (error) { + this.logger.error('Failed to initialize UnraidServerClient:', error); + } + } + + getClient(): UnraidServerClient | null { + return this.client; + } + + async reconnect(): Promise { + if (this.client) { + this.client.disconnect(); + } + await this.initializeClient(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8db98eb3a4..72dd53ebbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -612,6 +612,9 @@ importers: nest-authz: specifier: 2.17.0 version: 2.17.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2) + pify: + specifier: ^6.1.0 + version: 6.1.0 prettier: specifier: 3.6.2 version: 3.6.2 @@ -12434,8 +12437,8 @@ packages: vue-component-type-helpers@3.0.6: resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} - vue-component-type-helpers@3.1.3: - resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==} + vue-component-type-helpers@3.1.5: + resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -16500,7 +16503,7 @@ snapshots: storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) type-fest: 2.19.0 vue: 3.5.20(typescript@5.9.2) - vue-component-type-helpers: 3.1.3 + vue-component-type-helpers: 3.1.5 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -17358,7 +17361,7 @@ snapshots: '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 - magic-string: 0.30.17 + magic-string: 0.30.19 pathe: 2.0.3 '@vitest/spy@3.2.4': @@ -25280,13 +25283,13 @@ snapshots: chai: 5.2.0 debug: 4.4.1(supports-color@5.5.0) expect-type: 1.2.1 - magic-string: 0.30.17 + magic-string: 0.30.19 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) @@ -25339,7 +25342,7 @@ snapshots: vue-component-type-helpers@3.0.6: {} - vue-component-type-helpers@3.1.3: {} + vue-component-type-helpers@3.1.5: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: