Compare commits

...

7 Commits

Author SHA1 Message Date
Eli Bosley
92e30e2c7d refactor: consolidate unraid-api-plugin-connect package and update dependencies
- Renamed the unraid-api-plugin-connect-2 package back to unraid-api-plugin-connect for consistency.
- Updated pnpm-lock.yaml to reflect the new package structure and dependencies.
- Modified environment variables to standardize Mothership API integration.
- Removed deprecated GraphQL code generation and related types, streamlining the API.
- Enhanced connection status handling and introduced new services for WebSocket communication with Mothership.
2025-12-01 12:43:13 -05:00
Eli Bosley
81cc9ff960 refactor: update unraid-api-plugin-connect to version 2 and enhance GraphQL integration
- Renamed the unraid-api-plugin-connect package to unraid-api-plugin-connect-2 for improved clarity.
- Updated dependencies in pnpm-lock.yaml to reflect the new package structure.
- Modified environment variables for Mothership API integration to use a new GraphQL link.
- Refactored services to utilize the updated package and improved internal client handling.
- Removed deprecated UPS-related types from the GraphQL schema to streamline the API.
2025-11-29 22:16:17 -05:00
Eli Bosley
90ed837237 refactor: update Mothership API integration to use new GraphQL link
- Removed the deprecated internal API key JSON file.
- Renamed environment variable from MOTHERSHIP_BASE_URL to MOTHERSHIP_GRAPHQL_LINK for clarity.
- Updated CloudService and UnraidServerClientService to utilize the new MOTHERSHIP_GRAPHQL_LINK for API calls, ensuring consistent access to the Mothership API.
2025-11-29 15:46:50 -05:00
Eli Bosley
67944ebf26 feat: introduce unraid-api-plugin-connect-2 with enhanced GraphQL support
- Added a new package for the Unraid API plugin, featuring a modular structure for connection management and remote access.
- Implemented GraphQL resolvers and services for cloud connection status, dynamic remote access, and network management.
- Updated code generation configuration to support new GraphQL types and queries.
- Refactored existing services to utilize the new GraphQL client for improved performance and maintainability.
- Included comprehensive tests for new functionalities to ensure reliability and stability.
2025-11-29 15:42:11 -05:00
Eli Bosley
fcf74d347b feat: add remote access and connection management to GraphQL API
- Introduced new types and enums for managing remote access configurations, including AccessUrl, RemoteAccess, and Connect settings.
- Added mutations for updating API settings and managing remote access.
- Updated the API configuration to include the new connect plugin.
- Enhanced the pnpm lock file with the addition of the pify package.
- Implemented logic to skip file modifications in development mode.
2025-11-29 15:34:56 -05:00
Eli Bosley
59595499b3 feat: mothership working e2e 2025-11-29 15:33:32 -05:00
Eli Bosley
ff941602b6 Refactor Mothership integration to use WebSocket-based UnraidServerClient
- Updated package.json scripts to remove MOTHERSHIP_GRAPHQL_LINK environment variable.
- Changed MOTHERSHIP_GRAPHQL_LINK to MOTHERSHIP_BASE_URL in environment.ts.
- Removed GraphQL code generation for Mothership types in codegen.ts.
- Updated connection status services to use MOTHERSHIP_BASE_URL.
- Refactored MothershipSubscriptionHandler to utilize UnraidServerClient instead of GraphQL client.
- Implemented UnraidServerClient for WebSocket communication with Mothership.
- Enhanced MothershipController to manage UnraidServerClient lifecycle.
- Added reconnection logic and ping/pong handling in UnraidServerClient.
- Simplified GraphQL execution logic in UnraidServerClient.
2025-11-29 15:33:13 -05:00
18 changed files with 806 additions and 210 deletions

View File

@@ -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

View File

@@ -1,5 +1,5 @@
{
"version": "4.25.3",
"version": "4.27.2",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -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",

View File

@@ -0,0 +1,7 @@
{
"connectionStatus": "PRE_INIT",
"error": null,
"lastPing": null,
"allowedOrigins": "",
"timestamp": 1764601989840
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
},
};

View File

@@ -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",

View File

@@ -204,7 +204,7 @@ export class CloudService {
}
private async hardCheckDns() {
const mothershipGqlUri = this.configService.getOrThrow<string>('MOTHERSHIP_GRAPHQL_LINK');
const mothershipGqlUri = this.configService.getOrThrow<string>('MOTHERSHIP_BASE_URL');
const hostname = new URL(mothershipGqlUri).host;
const lookup = promisify(lookupDNS);
const resolve = promisify(resolveDNS);

View File

@@ -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) {

View File

@@ -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!) {

View File

@@ -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<string, any>
operationName?: string
operationType?: 'query' | 'mutation' | 'subscription'
}): Promise<any>
stopSubscription?(operationId: string): Promise<void>
}
/**
* 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<string, any>
operationName?: string
operationType?: 'query' | 'mutation' | 'subscription'
}): Promise<any> {
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<void> {
this.logger.debug(`Stopping subscription: ${operationId}`);
// Subscription cleanup logic would go here
}
}

View File

@@ -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<string, ActiveSubscription> = new Map();
private mothershipSubscription: Subscription | null = null;
private readonly activeSubscriptions = new Map<string, SubscriptionInfo>();
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
};
}
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,
});
}
}
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;
}
this.activeSubscriptions.set(sha256, subscription);
this.logger.debug(`Added subscription ${sha256} ${operationId ? `with operationId: ${operationId}` : ''}`);
}
stopMothershipSubscription() {
this.mothershipSubscription?.unsubscribe();
this.mothershipSubscription = null;
this.logger.verbose('Stopping mothership subscription (not implemented yet)');
}
async subscribeToMothershipEvents(client = this.mothershipClient.getClient()) {
if (!client) {
this.logger.error('Mothership client unavailable. State might not be loaded.');
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');
}
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;
}
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,
}
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);
}
}
},
});
};
} 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' },
},
],
};
}
}
}

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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<string, any>
operationName?: string
operationType?: 'query' | 'mutation' | 'subscription'
}): Promise<any>
stopSubscription?(operationId: string): Promise<void>
}
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<void> {
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<void> {
// Initialize the client when the module starts
await this.initializeClient();
}
async onModuleDestroy(): Promise<void> {
if (this.client) {
this.client.disconnect();
this.client = null;
}
}
private async initializeClient(): Promise<void> {
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<void> {
if (this.client) {
this.client.disconnect();
}
await this.initializeClient();
}
}

17
pnpm-lock.yaml generated
View File

@@ -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: