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.
This commit is contained in:
Eli Bosley
2025-12-01 12:43:13 -05:00
parent 81cc9ff960
commit 92e30e2c7d
73 changed files with 133 additions and 7597 deletions

View File

@@ -3,5 +3,5 @@
"error": null,
"lastPing": null,
"allowedOrigins": "",
"timestamp": 1764472463288
"timestamp": 1764601989840
}

View File

@@ -19,7 +19,7 @@ Add your workspace package to the vendoring configuration in `api/scripts/build.
```typescript
const WORKSPACE_PACKAGES_TO_VENDOR = {
'@unraid/shared': 'packages/unraid-shared',
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect-2',
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect',
'your-plugin-name': 'packages/your-plugin-path', // Add your plugin here
} as const;
```
@@ -31,7 +31,7 @@ Add your workspace package to the Vite configuration in `api/vite.config.ts`:
```typescript
const workspaceDependencies = {
'@unraid/shared': 'packages/unraid-shared',
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect-2',
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect',
'your-plugin-name': 'packages/your-plugin-path', // Add your plugin here
};
```

View File

@@ -21,7 +21,7 @@ type ApiPackageJson = PackageJson & {
*/
const WORKSPACE_PACKAGES_TO_VENDOR = {
'@unraid/shared': 'packages/unraid-shared',
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect-2',
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect',
} as const;
/**

View File

@@ -23,7 +23,7 @@ import { defineConfig } from 'vitest/config';
*/
const workspaceDependencies = {
'@unraid/shared': 'packages/unraid-shared',
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect-2',
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect',
};
export default defineConfig(({ mode }): ViteUserConfig => {

View File

@@ -1,38 +0,0 @@
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
module.exports = {
trailingComma: 'es5',
tabWidth: 4,
semi: true,
singleQuote: true,
printWidth: 105,
plugins: ['@ianvs/prettier-plugin-sort-imports'],
// decorators-legacy lets the import sorter transform files with decorators
importOrderParserPlugins: ['typescript', 'decorators-legacy'],
importOrder: [
/**----------------------
* Nest.js & node.js imports
*------------------------**/
'<TYPES>^@nestjs(/.*)?$',
'^@nestjs(/.*)?$', // matches imports starting with @nestjs
'<TYPES>^(node:)',
'<BUILTIN_MODULES>', // Node.js built-in modules
'',
/**----------------------
* Third party packages
*------------------------**/
'<TYPES>',
'<THIRD_PARTY_MODULES>', // Imports not matched by other special words or groups.
'',
/**----------------------
* Application Code
*------------------------**/
'<TYPES>^@app(/.*)?$', // matches type imports starting with @app
'^@app(/.*)?$',
'',
'<TYPES>^[.]',
'^[.]', // relative imports
],
};

View File

@@ -1,36 +0,0 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
overwrite: true,
emitLegacyCommonJSImports: false,
verbose: true,
config: {
namingConvention: {
enumValues: 'change-case-all#upperCase',
transformUnderscore: true,
useTypeImports: true,
},
scalars: {
DateTime: 'string',
Long: 'number',
JSON: 'Record<string, any>',
URL: 'URL',
Port: 'number',
UUID: 'string',
BigInt: 'number',
},
scalarSchemas: {
URL: 'z.instanceof(URL)',
Long: 'z.number()',
JSON: 'z.record(z.string(), z.any())',
Port: 'z.number()',
UUID: 'z.string()',
BigInt: 'z.number()',
},
},
generates: {
// No longer generating mothership GraphQL types since we switched to WebSocket-based UnraidServerClient
},
};
export default config;

View File

@@ -1,39 +0,0 @@
# Justfile for unraid-api-plugin-connect
# Default recipe to run when just is called without arguments
default:
@just --list
# Watch for changes in src files and run clean + build
watch:
watchexec -r -e ts,tsx -w src -- pnpm build
# Count TypeScript lines in src directory, excluding test and generated files
count-lines:
#!/usr/bin/env bash
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}Counting TypeScript lines in src/ (excluding test/ and graphql/generated/)...${NC}"
echo
echo -e "${GREEN}Lines by directory:${NC}"
cd src
# First pass to get total lines
total=$(find . -type f -name "*.ts" -not -path "*/test/*" -not -path "*/graphql/generated/*" | xargs wc -l | tail -n 1 | awk '{print $1}')
# Second pass to show directory breakdown with percentages
for dir in $(find . -type d -not -path "*/test/*" -not -path "*/graphql/generated/*" -not -path "." -not -path "./test" | sort); do
lines=$(find "$dir" -type f -name "*.ts" -not -path "*/graphql/generated/*" | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}')
if [ ! -z "$lines" ]; then
percentage=$(echo "scale=1; $lines * 100 / $total" | bc)
printf "%-30s %6d lines (%5.1f%%)\n" "$dir" "$lines" "$percentage"
fi
done
echo
echo -e "${GREEN}Top 10 largest files:${NC}"
find . -type f -name "*.ts" -not -path "*/test/*" -not -path "*/graphql/generated/*" | xargs wc -l | sort -nr | head -n 11
echo
echo -e "${GREEN}Total TypeScript lines:${NC} $total"

View File

@@ -1,106 +0,0 @@
{
"name": "unraid-api-plugin-connect",
"version": "4.25.3",
"main": "dist/index.js",
"type": "module",
"files": [
"dist",
"readme.md"
],
"scripts": {
"test": "vitest",
"clean": "rimraf dist",
"build": "tsc",
"prepare": "npm run build",
"format": "prettier --write \"src/**/*.{ts,js,json}\"",
"codegen": "graphql-codegen --config codegen.ts"
},
"keywords": [
"unraid",
"connect",
"unraid plugin"
],
"author": "Lime Technology, Inc. <unraid.net>",
"license": "GPL-2.0-or-later",
"description": "Unraid Connect plugin for Unraid API",
"devDependencies": {
"@apollo/client": "3.14.0",
"@faker-js/faker": "10.0.0",
"@graphql-codegen/cli": "6.0.0",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
"@jsonforms/core": "3.6.0",
"@nestjs/apollo": "13.1.0",
"@nestjs/common": "11.1.6",
"@nestjs/config": "4.0.2",
"@nestjs/core": "11.1.6",
"@nestjs/event-emitter": "3.0.1",
"@nestjs/graphql": "13.1.0",
"@nestjs/schedule": "6.0.0",
"@runonflux/nat-upnp": "1.0.2",
"@types/ini": "4.1.1",
"@types/ip": "1.1.3",
"@types/lodash-es": "4.17.12",
"@types/node": "22.18.0",
"@types/ws": "8.18.1",
"camelcase-keys": "10.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",
"execa": "9.6.0",
"fast-check": "4.2.0",
"got": "14.4.7",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
"graphql-subscriptions": "3.0.0",
"graphql-ws": "6.0.6",
"ini": "5.0.0",
"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",
"type-fest": "5.0.0",
"typescript": "5.9.2",
"undici": "7.15.0",
"vitest": "3.2.4",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
},
"dependencies": {
"@unraid/shared": "workspace:*",
"ip": "2.0.1",
"node-cache": "5.1.2"
},
"peerDependencies": {
"@apollo/client": "3.14.0",
"@graphql-typed-document-node/core": "3.2.0",
"@jsonforms/core": "3.6.0",
"@nestjs/apollo": "13.1.0",
"@nestjs/common": "11.1.6",
"@nestjs/config": "4.0.2",
"@nestjs/core": "11.1.6",
"@nestjs/event-emitter": "3.0.1",
"@nestjs/graphql": "13.1.0",
"@nestjs/schedule": "6.0.0",
"@runonflux/nat-upnp": "1.0.2",
"camelcase-keys": "10.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",
"execa": "9.6.0",
"got": "14.4.7",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
"graphql-subscriptions": "3.0.0",
"graphql-ws": "6.0.6",
"ini": "5.0.0",
"jose": "6.0.13",
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"rxjs": "7.8.2",
"undici": "7.15.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
}
}

View File

@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { CloudService } from '../connection-status/cloud.service.js';
const MOTHERSHIP_GRAPHQL_LINK = 'https://mothership.unraid.net/ws';
const API_VERSION = 'TEST_VERSION';
const BAD_API_KEY = 'BAD_API_KEY';
const BAD = 'BAD';
describe('CloudService.hardCheckCloud (integration)', () => {
let service: CloudService;
let configService: any;
let mothership: any;
let connectConfig: any;
beforeEach(() => {
configService = {
getOrThrow: (key: string) => {
if (key === 'MOTHERSHIP_GRAPHQL_LINK') return MOTHERSHIP_GRAPHQL_LINK;
if (key === 'API_VERSION') return API_VERSION;
throw new Error('Unknown key');
},
};
mothership = {
getConnectionState: () => null,
};
connectConfig = {
getConfig: () => ({ apikey: BAD_API_KEY }),
};
service = new CloudService(configService, mothership, connectConfig);
});
it('fails to authenticate with mothership with no credentials', async () => {
try {
await expect(service['hardCheckCloud'](API_VERSION, BAD)).resolves.toMatchObject({
status: 'error',
});
await expect(service['hardCheckCloud'](API_VERSION, BAD_API_KEY)).resolves.toMatchObject({
status: 'error',
});
} catch (error) {
if (error instanceof Error && error.message.includes('Timeout')) {
// Test succeeds on timeout
return;
}
throw error;
}
}, { timeout: 10000 });
});

View File

@@ -1,518 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { faker } from '@faker-js/faker';
import * as fc from 'fast-check';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConnectConfigPersister } from '../config/config.persistence.js';
import { ConfigType, DynamicRemoteAccessType } from '../config/connect.config.js';
describe('ConnectConfigPersister', () => {
let service: ConnectConfigPersister;
let configService: ConfigService<ConfigType, true>;
beforeEach(() => {
configService = {
getOrThrow: vi.fn(),
get: vi.fn(),
set: vi.fn(),
changes$: {
pipe: vi.fn(() => ({
subscribe: vi.fn(),
})),
},
} as any;
service = new ConnectConfigPersister(configService as any);
});
describe('parseLegacyConfig', () => {
it('should parse INI format legacy config correctly', () => {
const iniContent = `
[api]
version="4.8.0+9485809"
extraOrigins="https://example1.com,https://example2.com"
[local]
sandbox="no"
[remote]
wanaccess="yes"
wanport="3333"
upnpEnabled="no"
apikey="unraid_test_key"
localApiKey="test_local_key"
email="test@example.com"
username="testuser"
avatar=""
regWizTime=""
accesstoken=""
idtoken=""
refreshtoken=""
dynamicRemoteAccessType="DISABLED"
ssoSubIds="user1,user2"
`.trim();
const result = service.parseLegacyConfig(iniContent);
expect(result.api.version).toBe('4.8.0+9485809');
expect(result.api.extraOrigins).toBe('https://example1.com,https://example2.com');
expect(result.local.sandbox).toBe('no');
expect(result.remote.wanaccess).toBe('yes');
expect(result.remote.wanport).toBe('3333');
expect(result.remote.upnpEnabled).toBe('no');
expect(result.remote.ssoSubIds).toBe('user1,user2');
});
it('should parse various INI configs with different boolean values using fast-check', () => {
fc.assert(
fc.property(
fc.boolean(),
fc.boolean(),
fc.constantFrom('yes', 'no'),
fc.integer({ min: 1000, max: 9999 }),
fc.constant(null).map(() => faker.internet.email()),
fc.constant(null).map(() => faker.internet.username()),
(wanaccess, upnpEnabled, sandbox, port, email, username) => {
const iniContent = `
[api]
version="6.12.0"
extraOrigins=""
[local]
sandbox="${sandbox}"
[remote]
wanaccess="${wanaccess ? 'yes' : 'no'}"
wanport="${port}"
upnpEnabled="${upnpEnabled ? 'yes' : 'no'}"
apikey="unraid_test_key"
localApiKey="test_local_key"
email="${email}"
username="${username}"
avatar=""
regWizTime=""
accesstoken=""
idtoken=""
refreshtoken=""
dynamicRemoteAccessType="DISABLED"
ssoSubIds=""
`.trim();
const result = service.parseLegacyConfig(iniContent);
expect(result.api.version).toBe('6.12.0');
expect(result.local.sandbox).toBe(sandbox);
expect(result.remote.wanaccess).toBe(wanaccess ? 'yes' : 'no');
expect(result.remote.wanport).toBe(port.toString());
expect(result.remote.upnpEnabled).toBe(upnpEnabled ? 'yes' : 'no');
expect(result.remote.email).toBe(email);
expect(result.remote.username).toBe(username);
}
),
{ numRuns: 25 }
);
});
it('should handle empty sections gracefully', () => {
const iniContent = `
[api]
version="6.12.0"
[local]
[remote]
wanaccess="no"
wanport="0"
upnpEnabled="no"
apikey="test"
localApiKey="test"
email="test@example.com"
username="test"
avatar=""
regWizTime=""
dynamicRemoteAccessType="DISABLED"
`.trim();
const result = service.parseLegacyConfig(iniContent);
expect(result.api.version).toBe('6.12.0');
expect(result.local).toBeDefined();
expect(result.remote).toBeDefined();
expect(result.remote.wanaccess).toBe('no');
});
});
describe('convertLegacyConfig', () => {
it('should migrate wanaccess from string "yes" to boolean true', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'yes',
wanport: '3333',
upnpEnabled: 'no',
apikey: 'unraid_test_key',
localApiKey: 'test_local_key',
email: 'test@example.com',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
expect(result.wanaccess).toBe(true);
});
it('should migrate wanaccess from string "no" to boolean false', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'no',
wanport: '3333',
upnpEnabled: 'no',
apikey: 'unraid_test_key',
localApiKey: 'test_local_key',
email: 'test@example.com',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
expect(result.wanaccess).toBe(false);
});
it('should migrate wanport from string to number', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'yes',
wanport: '8080',
upnpEnabled: 'no',
apikey: 'unraid_test_key',
localApiKey: 'test_local_key',
email: 'test@example.com',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
expect(result.wanport).toBe(8080);
expect(typeof result.wanport).toBe('number');
});
it('should migrate upnpEnabled from string "yes" to boolean true', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'yes',
wanport: '3333',
upnpEnabled: 'yes',
apikey: 'unraid_test_key',
localApiKey: 'test_local_key',
email: 'test@example.com',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
expect(result.upnpEnabled).toBe(true);
});
it('should migrate upnpEnabled from string "no" to boolean false', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'yes',
wanport: '3333',
upnpEnabled: 'no',
apikey: 'unraid_test_key',
localApiKey: 'test_local_key',
email: 'test@example.com',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
expect(result.upnpEnabled).toBe(false);
});
it('should migrate signed in user information correctly', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'yes',
wanport: '3333',
upnpEnabled: 'no',
apikey: 'unraid_sfHboeSNzTzx24816QBssqi0A3nIT0f4Xg4c9Ht49WQfQKLMojU81Sb3f',
localApiKey: '101d204832d24fc7e5d387f6fce47067ba230f8aa0ac3bcc6c12a415aa27dbd9',
email: 'pujitm2009@gmail.com',
username: 'pujitm2009@gmail.com',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
expect(result.apikey).toBe(
'unraid_sfHboeSNzTzx24816QBssqi0A3nIT0f4Xg4c9Ht49WQfQKLMojU81Sb3f'
);
expect(result.localApiKey).toBe(
'101d204832d24fc7e5d387f6fce47067ba230f8aa0ac3bcc6c12a415aa27dbd9'
);
expect(result.email).toBe('pujitm2009@gmail.com');
expect(result.username).toBe('pujitm2009@gmail.com');
expect(result.avatar).toBe('');
});
it('should merge all sections (api, local, remote) into single config object', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: 'https://example.com' },
local: { sandbox: 'yes' },
remote: {
wanaccess: 'yes',
wanport: '8080',
upnpEnabled: 'yes',
apikey: 'test_api_key',
localApiKey: 'test_local_key',
email: 'user@test.com',
username: 'testuser',
avatar: 'https://avatar.url',
regWizTime: '2023-01-01T00:00:00Z',
accesstoken: 'access_token_value',
idtoken: 'id_token_value',
refreshtoken: 'refresh_token_value',
dynamicRemoteAccessType: 'UPNP',
ssoSubIds: 'sub1,sub2',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
expect(result.wanaccess).toBe(true);
expect(result.wanport).toBe(8080);
expect(result.upnpEnabled).toBe(true);
expect(result.apikey).toBe('test_api_key');
expect(result.localApiKey).toBe('test_local_key');
expect(result.email).toBe('user@test.com');
expect(result.username).toBe('testuser');
expect(result.avatar).toBe('https://avatar.url');
expect(result.regWizTime).toBe('2023-01-01T00:00:00Z');
expect(result.dynamicRemoteAccessType).toBe('UPNP');
});
it('should handle integration of parsing and conversion together', async () => {
const iniContent = `
[api]
version="4.8.0+9485809"
extraOrigins="https://example.com"
[local]
sandbox="yes"
[remote]
wanaccess="yes"
wanport="8080"
upnpEnabled="yes"
apikey="test_api_key"
localApiKey="test_local_key"
email="user@test.com"
username="testuser"
avatar="https://avatar.url"
regWizTime="2023-01-01T00:00:00Z"
accesstoken="access_token_value"
idtoken="id_token_value"
refreshtoken="refresh_token_value"
dynamicRemoteAccessType="UPNP"
ssoSubIds="sub1,sub2"
`.trim();
// Parse the INI content
const legacyConfig = service.parseLegacyConfig(iniContent);
// Convert to new format
const result = await service.convertLegacyConfig(legacyConfig);
// Verify the end-to-end conversion
expect(result.wanaccess).toBe(true);
expect(result.wanport).toBe(8080);
expect(result.upnpEnabled).toBe(true);
});
it('should handle various boolean migrations consistently using property-based testing', () => {
fc.assert(
fc.asyncProperty(
fc.boolean(),
fc.boolean(),
fc.integer({ min: 1000, max: 65535 }),
fc.constant(null).map(() => faker.internet.email()),
fc.constant(null).map(() => faker.internet.username()),
fc.constant(null).map(() => faker.string.alphanumeric({ length: 32 })),
async (wanaccess, upnpEnabled, port, email, username, apikey) => {
const legacyConfig = {
api: { version: faker.system.semver(), extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: wanaccess ? 'yes' : 'no',
wanport: port.toString(),
upnpEnabled: upnpEnabled ? 'yes' : 'no',
apikey: `unraid_${apikey}`,
localApiKey: faker.string.alphanumeric({ length: 64 }),
email,
username,
avatar: faker.image.avatarGitHub(),
regWizTime: faker.date.past().toISOString(),
accesstoken: faker.string.alphanumeric({ length: 64 }),
idtoken: faker.string.alphanumeric({ length: 64 }),
refreshtoken: faker.string.alphanumeric({ length: 64 }),
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
// Test migration logic, not validation
expect(result.wanaccess).toBe(wanaccess);
expect(result.upnpEnabled).toBe(upnpEnabled);
expect(result.wanport).toBe(port);
expect(typeof result.wanport).toBe('number');
expect(result.email).toBe(email);
expect(result.username).toBe(username);
expect(result.apikey).toBe(`unraid_${apikey}`);
}
),
{ numRuns: 20 }
);
});
it('should handle edge cases in port conversion', () => {
fc.assert(
fc.asyncProperty(fc.integer({ min: 0, max: 65535 }), async (port) => {
const legacyConfig = {
api: { version: '6.12.0', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'no',
wanport: port.toString(),
upnpEnabled: 'no',
apikey: 'unraid_test',
localApiKey: 'test_local',
email: 'test@example.com',
username: faker.internet.username(),
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
// Test port conversion logic
expect(result.wanport).toBe(port);
expect(typeof result.wanport).toBe('number');
}),
{ numRuns: 15 }
);
});
it('should handle empty port values', async () => {
const legacyConfig = {
api: { version: '6.12.0', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'no',
wanport: '',
upnpEnabled: 'no',
apikey: 'unraid_test',
localApiKey: 'test_local',
email: 'test@example.com',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
const result = await service.convertLegacyConfig(legacyConfig);
expect(result.wanport).toBe(0);
expect(typeof result.wanport).toBe('number');
});
it('should reject invalid configurations during migration', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'yes',
wanport: '3333',
upnpEnabled: 'no',
apikey: 'unraid_test_key',
localApiKey: 'test_local_key',
email: 'invalid-email',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;
await expect(service.convertLegacyConfig(legacyConfig)).rejects.toThrow();
});
});
});

View File

@@ -1,304 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { faker } from '@faker-js/faker';
import * as fc from 'fast-check';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConnectConfigPersister } from '../config/config.persistence.js';
import { DynamicRemoteAccessType, MyServersConfig } from '../config/connect.config.js';
describe('MyServersConfig Validation', () => {
let persister: ConnectConfigPersister;
let validConfig: Partial<MyServersConfig>;
beforeEach(() => {
const configService = {
getOrThrow: vi.fn().mockReturnValue('/mock/path'),
get: vi.fn(),
set: vi.fn(),
changes$: {
pipe: vi.fn(() => ({
subscribe: vi.fn(),
})),
},
} as any;
persister = new ConnectConfigPersister(configService as any);
validConfig = {
wanaccess: false,
wanport: 0,
upnpEnabled: false,
apikey: 'test-api-key',
localApiKey: 'test-local-key',
email: 'test@example.com',
username: 'testuser',
avatar: 'https://example.com/avatar.jpg',
regWizTime: '2024-01-01T00:00:00Z',
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
upnpStatus: null,
};
});
describe('Email validation', () => {
it('should accept valid email addresses', async () => {
const config = { ...validConfig, email: 'user@example.com' };
const result = await persister.validate(config);
expect(result.email).toBe('user@example.com');
});
it('should accept empty string for email', async () => {
const config = { ...validConfig, email: '' };
const result = await persister.validate(config);
expect(result.email).toBe('');
});
it('should accept null for email', async () => {
const config = { ...validConfig, email: null };
const result = await persister.validate(config);
expect(result.email).toBeNull();
});
it('should reject invalid email addresses', async () => {
const config = { ...validConfig, email: 'invalid-email' };
await expect(persister.validate(config)).rejects.toThrow();
});
it('should reject malformed email addresses', async () => {
const config = { ...validConfig, email: '@example.com' };
await expect(persister.validate(config)).rejects.toThrow();
});
});
describe('Boolean field validation', () => {
it('should accept boolean values for wanaccess', async () => {
const config = { ...validConfig, wanaccess: true };
const result = await persister.validate(config);
expect(result.wanaccess).toBe(true);
});
it('should accept boolean values for upnpEnabled', async () => {
const config = { ...validConfig, upnpEnabled: true };
const result = await persister.validate(config);
expect(result.upnpEnabled).toBe(true);
});
it('should reject non-boolean values for wanaccess', async () => {
const config = { ...validConfig, wanaccess: 'yes' as any };
await expect(persister.validate(config)).rejects.toThrow();
});
it('should reject non-boolean values for upnpEnabled', async () => {
const config = { ...validConfig, upnpEnabled: 'no' as any };
await expect(persister.validate(config)).rejects.toThrow();
});
});
describe('Number field validation', () => {
it('should accept number values for wanport', async () => {
const config = { ...validConfig, wanport: 8080 };
const result = await persister.validate(config);
expect(result.wanport).toBe(8080);
});
it('should accept null for optional number fields', async () => {
const config = { ...validConfig, wanport: null };
const result = await persister.validate(config);
expect(result.wanport).toBeNull();
});
it('should reject non-number values for wanport', async () => {
const config = { ...validConfig, wanport: '8080' as any };
await expect(persister.validate(config)).rejects.toThrow();
});
});
describe('String field validation', () => {
it('should accept string values for required string fields', async () => {
const config = { ...validConfig };
const result = await persister.validate(config);
expect(result.apikey).toBe(validConfig.apikey);
expect(result.localApiKey).toBe(validConfig.localApiKey);
expect(result.username).toBe(validConfig.username);
});
it('should reject non-string values for required string fields', async () => {
const config = { ...validConfig, apikey: 123 as any };
await expect(persister.validate(config)).rejects.toThrow();
});
});
describe('Enum validation', () => {
it('should accept valid enum values for dynamicRemoteAccessType', async () => {
const config = { ...validConfig, dynamicRemoteAccessType: DynamicRemoteAccessType.STATIC };
const result = await persister.validate(config);
expect(result.dynamicRemoteAccessType).toBe(DynamicRemoteAccessType.STATIC);
});
it('should reject invalid enum values for dynamicRemoteAccessType', async () => {
const config = { ...validConfig, dynamicRemoteAccessType: 'INVALID' as any };
await expect(persister.validate(config)).rejects.toThrow();
});
});
describe('Property-based validation testing', () => {
it('should accept valid email addresses generated by faker', () => {
fc.assert(
fc.asyncProperty(
fc.constant(null).map(() => faker.internet.email()),
async (email) => {
const config = { ...validConfig, email };
const result = await persister.validate(config);
expect(result.email).toBe(email);
}
),
{ numRuns: 20 }
);
});
it('should handle various boolean combinations', () => {
fc.assert(
fc.asyncProperty(fc.boolean(), fc.boolean(), async (wanaccess, upnpEnabled) => {
const config = { ...validConfig, wanaccess, upnpEnabled };
const result = await persister.validate(config);
expect(result.wanaccess).toBe(wanaccess);
expect(result.upnpEnabled).toBe(upnpEnabled);
}),
{ numRuns: 10 }
);
});
it('should handle valid port numbers', () => {
fc.assert(
fc.asyncProperty(fc.integer({ min: 0, max: 65535 }), async (port) => {
const config = { ...validConfig, wanport: port };
const result = await persister.validate(config);
expect(result.wanport).toBe(port);
expect(typeof result.wanport).toBe('number');
}),
{ numRuns: 20 }
);
});
it('should handle various usernames and API keys', () => {
fc.assert(
fc.asyncProperty(
fc.constant(null).map(() => faker.internet.username()),
fc.constant(null).map(() => `unraid_${faker.string.alphanumeric({ length: 32 })}`),
fc.constant(null).map(() => faker.string.alphanumeric({ length: 64 })),
async (username, apikey, localApiKey) => {
const config = { ...validConfig, username, apikey, localApiKey };
const result = await persister.validate(config);
expect(result.username).toBe(username);
expect(result.apikey).toBe(apikey);
expect(result.localApiKey).toBe(localApiKey);
}
),
{ numRuns: 15 }
);
});
it('should handle various enum values for dynamicRemoteAccessType', () => {
fc.assert(
fc.asyncProperty(
fc.constantFrom(
DynamicRemoteAccessType.DISABLED,
DynamicRemoteAccessType.STATIC,
DynamicRemoteAccessType.UPNP
),
async (dynamicRemoteAccessType) => {
const config = { ...validConfig, dynamicRemoteAccessType };
const result = await persister.validate(config);
expect(result.dynamicRemoteAccessType).toBe(dynamicRemoteAccessType);
}
),
{ numRuns: 10 }
);
});
it('should reject invalid enum values', () => {
fc.assert(
fc.asyncProperty(
fc
.string({ minLength: 1 })
.filter((s) => !Object.values(DynamicRemoteAccessType).includes(s as any)),
async (invalidEnumValue) => {
const config = { ...validConfig, dynamicRemoteAccessType: invalidEnumValue };
await expect(persister.validate(config)).rejects.toThrow();
}
),
{ numRuns: 10 }
);
});
it('should reject invalid email formats using fuzzing', () => {
fc.assert(
fc.asyncProperty(
fc
.string({ minLength: 1 })
.filter((s) => !s.includes('@') || s.startsWith('@') || s.endsWith('@')),
async (invalidEmail) => {
const config = { ...validConfig, email: invalidEmail };
await expect(persister.validate(config)).rejects.toThrow();
}
),
{ numRuns: 15 }
);
});
it('should accept any number values for wanport (range validation is done at form level)', () => {
fc.assert(
fc.asyncProperty(fc.integer({ min: -100000, max: 100000 }), async (port) => {
const config = { ...validConfig, wanport: port };
const result = await persister.validate(config);
expect(result.wanport).toBe(port);
expect(typeof result.wanport).toBe('number');
}),
{ numRuns: 10 }
);
});
});
describe('Complete config validation', () => {
it('should validate a complete valid config', async () => {
const result = await persister.validate(validConfig);
expect(result).toBeDefined();
expect(result.email).toBe(validConfig.email);
expect(result.username).toBe(validConfig.username);
expect(result.wanaccess).toBe(validConfig.wanaccess);
expect(result.upnpEnabled).toBe(validConfig.upnpEnabled);
});
it('should validate config with minimal required fields using faker data', () => {
fc.assert(
fc.asyncProperty(
fc.constant(null).map(() => ({
email: faker.internet.email(),
username: faker.internet.username(),
apikey: `unraid_${faker.string.alphanumeric({ length: 32 })}`,
localApiKey: faker.string.alphanumeric({ length: 64 }),
avatar: faker.image.avatarGitHub(),
regWizTime: faker.date.past().toISOString(),
})),
async (fakerData) => {
const minimalConfig = {
wanaccess: false,
upnpEnabled: false,
wanport: 0,
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
upnpStatus: null,
...fakerData,
};
const result = await persister.validate(minimalConfig);
expect(result.email).toBe(fakerData.email);
expect(result.username).toBe(fakerData.username);
expect(result.apikey).toBe(fakerData.apikey);
expect(result.localApiKey).toBe(fakerData.localApiKey);
}
),
{ numRuns: 10 }
);
});
});
});

View File

@@ -1,161 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MinigraphStatus } from '../config/connect.config.js';
import { MothershipGraphqlClientService } from '../mothership-proxy/graphql.client.js';
// Mock only the WebSocket client creation, not the Apollo Client error handling
vi.mock('graphql-ws', () => ({
createClient: vi.fn(),
}));
// Mock WebSocket to avoid actual network connections
vi.mock('ws', () => ({
WebSocket: vi.fn().mockImplementation(() => ({})),
}));
describe('MothershipGraphqlClientService', () => {
let service: MothershipGraphqlClientService;
let mockConfigService: any;
let mockConnectionService: any;
let mockEventEmitter: any;
let mockWsClient: any;
beforeEach(async () => {
vi.clearAllMocks();
mockConfigService = {
getOrThrow: vi.fn((key: string) => {
switch (key) {
case 'API_VERSION':
return '4.8.0+test';
case 'MOTHERSHIP_GRAPHQL_LINK':
return 'https://mothership.unraid.net/ws';
default:
throw new Error(`Unknown config key: ${key}`);
}
}),
set: vi.fn(),
};
mockConnectionService = {
getIdentityState: vi.fn().mockReturnValue({ isLoaded: true }),
getWebsocketConnectionParams: vi.fn().mockReturnValue({}),
getMothershipWebsocketHeaders: vi.fn().mockReturnValue({}),
getConnectionState: vi.fn().mockReturnValue({ status: MinigraphStatus.CONNECTED }),
setConnectionStatus: vi.fn(),
receivePing: vi.fn(),
};
mockEventEmitter = {
emit: vi.fn(),
};
mockWsClient = {
on: vi.fn().mockReturnValue(() => {}),
terminate: vi.fn(),
dispose: vi.fn().mockResolvedValue(undefined),
};
// Mock the createClient function
const { createClient } = await import('graphql-ws');
vi.mocked(createClient).mockReturnValue(mockWsClient as any);
service = new MothershipGraphqlClientService(
mockConfigService as any,
mockConnectionService as any,
mockEventEmitter as any
);
});
describe('isInvalidApiKeyError', () => {
it.each([
{
description: 'standard API key error',
error: { message: 'API Key Invalid with error No user found' },
expected: true,
},
{
description: 'simple API key error',
error: { message: 'API Key Invalid' },
expected: true,
},
{
description: 'API key error within other text',
error: { message: 'Something else API Key Invalid something' },
expected: true,
},
{
description: 'malformed GraphQL error with API key message',
error: {
message:
'"error" message expects the \'payload\' property to be an array of GraphQL errors, but got "API Key Invalid with error No user found"',
},
expected: true,
},
{
description: 'non-API key error',
error: { message: 'Network connection failed' },
expected: false,
},
{
description: 'null error',
error: null,
expected: false,
},
{
description: 'empty error object',
error: {},
expected: false,
},
])('should identify $description correctly', ({ error, expected }) => {
const isInvalidApiKeyError = (service as any).isInvalidApiKeyError.bind(service);
expect(isInvalidApiKeyError(error)).toBe(expected);
});
});
describe('client lifecycle', () => {
it('should return null client when identity state is not valid', () => {
mockConnectionService.getIdentityState.mockReturnValue({ isLoaded: false });
const client = service.getClient();
expect(client).toBeNull();
});
it('should return client when identity state is valid', () => {
mockConnectionService.getIdentityState.mockReturnValue({ isLoaded: true });
// Since we're not mocking Apollo Client, this will create a real client
// We just want to verify the state check works
const client = service.getClient();
// The client should either be null (if not created yet) or an Apollo client instance
// The key is that it doesn't throw an error when state is valid
expect(() => service.getClient()).not.toThrow();
});
});
describe('sendQueryResponse', () => {
it('should handle null client gracefully', async () => {
// Make identity state invalid so getClient returns null
mockConnectionService.getIdentityState.mockReturnValue({ isLoaded: false });
const result = await service.sendQueryResponse('test-sha256', {
data: { test: 'data' },
});
// Should not throw and should return undefined when client is null
expect(result).toBeUndefined();
});
});
describe('configuration', () => {
it('should get API version from config', () => {
expect(service.apiVersion).toBe('4.8.0+test');
});
it('should get mothership GraphQL link from config', () => {
expect(service.mothershipGraphqlLink).toBe('https://mothership.unraid.net/ws');
});
});
});

View File

@@ -1,269 +0,0 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PubSub } from 'graphql-subscriptions';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MinigraphStatus } from '../config/connect.config.js';
import { EVENTS, GRAPHQL_PUBSUB_CHANNEL } from '../helper/nest-tokens.js';
import { MothershipConnectionService } from '../mothership-proxy/connection.service.js';
import { MothershipController } from '../mothership-proxy/mothership.controller.js';
import { MothershipHandler } from '../mothership-proxy/mothership.events.js';
describe('MothershipHandler - Behavioral Tests', () => {
let handler: MothershipHandler;
let connectionService: MothershipConnectionService;
let mothershipController: MothershipController;
let pubSub: PubSub;
let eventEmitter: EventEmitter2;
// Track actual state changes and effects
let connectionAttempts: Array<{ timestamp: number; reason: string }> = [];
let publishedMessages: Array<{ channel: string; data: any }> = [];
let controllerStops: Array<{ timestamp: number; reason?: string }> = [];
beforeEach(() => {
// Reset tracking arrays
connectionAttempts = [];
publishedMessages = [];
controllerStops = [];
// Create real event emitter for integration testing
eventEmitter = new EventEmitter2();
// Mock connection service with realistic behavior
connectionService = {
getIdentityState: vi.fn(),
getConnectionState: vi.fn(),
} as any;
// Mock controller that tracks behavior instead of just method calls
mothershipController = {
initOrRestart: vi.fn().mockImplementation(() => {
connectionAttempts.push({
timestamp: Date.now(),
reason: 'initOrRestart called',
});
return Promise.resolve();
}),
stop: vi.fn().mockImplementation(() => {
controllerStops.push({
timestamp: Date.now(),
});
return Promise.resolve();
}),
} as any;
// Mock PubSub that tracks published messages
pubSub = {
publish: vi.fn().mockImplementation((channel: string, data: any) => {
publishedMessages.push({ channel, data });
return Promise.resolve();
}),
} as any;
handler = new MothershipHandler(connectionService, mothershipController, pubSub);
});
describe('Connection Recovery Behavior', () => {
it('should attempt reconnection when ping fails', async () => {
// Given: Connection is in ping failure state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping timeout after 3 minutes',
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should attempt to recover the connection
expect(connectionAttempts).toHaveLength(1);
expect(connectionAttempts[0].reason).toBe('initOrRestart called');
});
it('should NOT interfere with exponential backoff during error retry state', async () => {
// Given: Connection is in error retry state (GraphQL client managing backoff)
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.ERROR_RETRYING,
error: 'Network error',
timeout: 20000,
timeoutStart: Date.now(),
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should NOT interfere with ongoing retry logic
expect(connectionAttempts).toHaveLength(0);
});
it('should remain stable during normal connection states', async () => {
const stableStates = [MinigraphStatus.CONNECTED, MinigraphStatus.CONNECTING];
for (const status of stableStates) {
// Reset for each test
connectionAttempts.length = 0;
// Given: Connection is in a stable state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status,
error: null,
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should not trigger unnecessary reconnection attempts
expect(connectionAttempts).toHaveLength(0);
}
});
});
describe('Identity-Based Connection Behavior', () => {
it('should establish connection when valid API key becomes available', async () => {
// Given: Valid API key is present
vi.mocked(connectionService.getIdentityState).mockReturnValue({
state: {
apiKey: 'valid-unraid-key-12345',
unraidVersion: '6.12.0',
flashGuid: 'test-flash-guid',
apiVersion: '1.0.0',
},
isLoaded: true,
});
// When: Identity changes
await handler.onIdentityChanged();
// Then: System should establish mothership connection
expect(connectionAttempts).toHaveLength(1);
});
it('should not attempt connection without valid credentials', async () => {
const invalidCredentials = [{ apiKey: undefined }, { apiKey: '' }];
for (const credentials of invalidCredentials) {
// Reset for each test
connectionAttempts.length = 0;
// Given: Invalid or missing API key
vi.mocked(connectionService.getIdentityState).mockReturnValue({
state: credentials,
isLoaded: false,
});
// When: Identity changes
await handler.onIdentityChanged();
// Then: System should not attempt connection
expect(connectionAttempts).toHaveLength(0);
}
});
});
describe('Logout Behavior', () => {
it('should properly clean up connections and notify subscribers on logout', async () => {
// When: User logs out
await handler.logout({ reason: 'User initiated logout' });
// Then: System should clean up connections
expect(controllerStops).toHaveLength(1);
// And: Subscribers should be notified of empty state
expect(publishedMessages).toHaveLength(2);
const serversMessage = publishedMessages.find(
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.SERVERS
);
const ownerMessage = publishedMessages.find(
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.OWNER
);
expect(serversMessage?.data).toEqual({ servers: [] });
expect(ownerMessage?.data).toEqual({
owner: { username: 'root', url: '', avatar: '' },
});
});
it('should handle logout gracefully even without explicit reason', async () => {
// When: System logout occurs without reason
await handler.logout({});
// Then: Cleanup should still occur properly
expect(controllerStops).toHaveLength(1);
expect(publishedMessages).toHaveLength(2);
});
});
describe('DDoS Prevention Behavior', () => {
it('should demonstrate exponential backoff is respected during network errors', async () => {
// Given: Multiple rapid network errors occur
const errorStates = [
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 1' },
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 2' },
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 3' },
];
// When: Rapid error retry states occur
for (const state of errorStates) {
vi.mocked(connectionService.getConnectionState).mockReturnValue(state);
await handler.onMothershipConnectionStatusChanged();
}
// Then: No linear retry attempts should be made (respecting exponential backoff)
expect(connectionAttempts).toHaveLength(0);
});
it('should differentiate between network errors and ping failures', async () => {
// Given: Network error followed by ping failure
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.ERROR_RETRYING,
error: 'Network error',
});
// When: Network error occurs
await handler.onMothershipConnectionStatusChanged();
// Then: No immediate reconnection attempt
expect(connectionAttempts).toHaveLength(0);
// Given: Ping failure occurs (different issue)
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping timeout',
});
// When: Ping failure occurs
await handler.onMothershipConnectionStatusChanged();
// Then: Immediate reconnection attempt should occur
expect(connectionAttempts).toHaveLength(1);
});
});
describe('Edge Cases and Error Handling', () => {
it('should handle missing connection state gracefully', async () => {
// Given: Connection service returns undefined
vi.mocked(connectionService.getConnectionState).mockReturnValue(undefined);
// When: Connection status change occurs
await handler.onMothershipConnectionStatusChanged();
// Then: No errors should occur, no reconnection attempts
expect(connectionAttempts).toHaveLength(0);
});
it('should handle malformed connection state', async () => {
// Given: Malformed connection state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: 'UNKNOWN_STATUS' as any,
error: 'Malformed state',
});
// When: Connection status change occurs
await handler.onMothershipConnectionStatusChanged();
// Then: Should not trigger reconnection for unknown states
expect(connectionAttempts).toHaveLength(0);
});
});
});

View File

@@ -1,426 +0,0 @@
import { ConfigService } from '@nestjs/config';
import type { Mock } from 'vitest';
import { URL_TYPE } from '@unraid/shared/network.model.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { UrlResolverService } from '../network/url-resolver.service.js';
interface PortTestParams {
httpPort: number;
httpsPort: number;
}
describe('UrlResolverService', () => {
let service: UrlResolverService;
let mockConfigService: ConfigService<ConfigType, true>;
beforeEach(() => {
mockConfigService = {
get: vi.fn(),
getOrThrow: vi.fn(),
} as unknown as ConfigService<ConfigType, true>;
service = new UrlResolverService(mockConfigService);
});
describe('getServerIps', () => {
it('should return empty arrays when store is not loaded', () => {
(mockConfigService.get as Mock).mockReturnValue(null);
const result = service.getServerIps();
expect(result).toEqual({
urls: [],
errors: [new Error('Store not loaded')],
});
});
it('should return empty arrays when nginx is not loaded', () => {
(mockConfigService.get as Mock).mockReturnValue({
emhttp: {},
});
const result = service.getServerIps();
expect(result).toEqual({
urls: [],
errors: [new Error('Nginx Not Loaded')],
});
});
it.each<PortTestParams>([
{ httpPort: 80, httpsPort: 443 },
{ httpPort: 123, httpsPort: 443 },
{ httpPort: 80, httpsPort: 12_345 },
{ httpPort: 212, httpsPort: 3_233 },
])('should handle different port combinations: %j', (params: PortTestParams) => {
const { httpPort, httpsPort } = params;
const mockStore = {
emhttp: {
nginx: {
defaultUrl: 'https://default.unraid.net',
lanIp: '192.168.1.1',
lanIp6: '2001:db8::1',
lanName: 'unraid.local',
lanMdns: 'unraid.local',
sslEnabled: true,
sslMode: 'yes',
httpPort,
httpsPort,
fqdnUrls: [],
},
},
};
(mockConfigService.get as Mock).mockReturnValue(mockStore);
const result = service.getServerIps();
const lanUrl = result.urls.find(
(url) => url.type === URL_TYPE.LAN && url.name === 'LAN IPv4'
);
expect(lanUrl).toBeDefined();
if (httpsPort === 443) {
expect(lanUrl?.ipv4?.toString()).toBe('https://192.168.1.1/');
} else {
expect(lanUrl?.ipv4?.toString()).toBe(`https://192.168.1.1:${httpsPort}/`);
}
});
it('should handle broken URLs gracefully', () => {
const mockStore = {
emhttp: {
nginx: {
defaultUrl: 'https://BROKEN_URL',
lanIp: '192.168.1.1',
lanIp6: '2001:db8::1',
lanName: 'unraid.local',
lanMdns: 'unraid.local',
sslEnabled: true,
sslMode: 'yes',
httpPort: 80,
httpsPort: 443,
fqdnUrls: [],
},
},
};
(mockConfigService.get as Mock).mockReturnValue(mockStore);
const result = service.getServerIps();
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors.some((error) => error.message.includes('Failed to parse URL'))).toBe(
true
);
});
it('should handle SSL mode variations', () => {
const testCases = [
{
sslEnabled: false,
sslMode: 'no',
expectedProtocol: 'http',
expectedPort: 80,
},
{
sslEnabled: true,
sslMode: 'yes',
expectedProtocol: 'https',
expectedPort: 443,
},
{
sslEnabled: true,
sslMode: 'auto',
shouldError: true,
},
];
testCases.forEach((testCase) => {
const mockStore = {
emhttp: {
nginx: {
defaultUrl: 'https://default.unraid.net',
lanIp: '192.168.1.1',
lanIp6: 'ipv6.unraid.local',
lanName: 'unraid.local',
lanMdns: 'unraid.local',
sslEnabled: testCase.sslEnabled,
sslMode: testCase.sslMode,
httpPort: 80,
httpsPort: 443,
fqdnUrls: [],
},
},
};
(mockConfigService.get as Mock).mockReturnValue(mockStore);
const result = service.getServerIps();
if (testCase.shouldError) {
expect(result.errors.some((error) => error.message.includes('SSL mode auto'))).toBe(
true
);
} else {
const lanUrl = result.urls.find(
(url) => url.type === URL_TYPE.LAN && url.name === 'LAN IPv4'
);
expect(lanUrl).toBeDefined();
expect(lanUrl?.ipv4?.toString()).toBe(`${testCase.expectedProtocol}://192.168.1.1/`);
}
});
});
it('should resolve URLs for all network interfaces', () => {
const mockStore = {
emhttp: {
nginx: {
defaultUrl: 'https://default.unraid.net',
lanIp: '192.168.1.1',
lanIp6: 'ipv6.unraid.local',
lanName: 'unraid.local',
lanMdns: 'unraid.local',
sslEnabled: true,
sslMode: 'yes',
httpPort: 80,
httpsPort: 443,
fqdnUrls: [
{
interface: 'LAN',
id: null,
fqdn: 'lan.unraid.net',
isIpv6: false,
},
{
interface: 'WAN',
id: null,
fqdn: 'wan.unraid.net',
isIpv6: false,
},
],
},
},
};
(mockConfigService.get as Mock)
.mockReturnValueOnce(mockStore)
.mockReturnValueOnce(443);
const result = service.getServerIps();
expect(result.urls).toHaveLength(7); // Default + LAN IPv4 + LAN IPv6 + LAN Name + LAN MDNS + 2 FQDN
expect(result.errors).toHaveLength(0);
// Verify default URL
const defaultUrl = result.urls.find((url) => url.type === URL_TYPE.DEFAULT);
expect(defaultUrl).toBeDefined();
expect(defaultUrl?.ipv4?.toString()).toBe('https://default.unraid.net/');
// Verify LAN IPv4 URL
const lanIp4Url = result.urls.find(
(url) => url.type === URL_TYPE.LAN && url.name === 'LAN IPv4'
);
expect(lanIp4Url).toBeDefined();
expect(lanIp4Url?.ipv4?.toString()).toBe('https://192.168.1.1/');
// Verify LAN IPv6 URL
const lanIp6Url = result.urls.find(
(url) => url.type === URL_TYPE.LAN && url.name === 'LAN IPv6'
);
expect(lanIp6Url).toBeDefined();
expect(lanIp6Url?.ipv6?.toString()).toBe('https://ipv6.unraid.local/');
// Verify LAN Name URL
const lanNameUrl = result.urls.find(
(url) => url.type === URL_TYPE.MDNS && url.name === 'LAN Name'
);
expect(lanNameUrl).toBeDefined();
expect(lanNameUrl?.ipv4?.toString()).toBe('https://unraid.local/');
// Verify LAN MDNS URL
const lanMdnsUrl = result.urls.find(
(url) => url.type === URL_TYPE.MDNS && url.name === 'LAN MDNS'
);
expect(lanMdnsUrl).toBeDefined();
expect(lanMdnsUrl?.ipv4?.toString()).toBe('https://unraid.local/');
// Verify FQDN URLs
const lanFqdnUrl = result.urls.find(
(url) => url.type === URL_TYPE.LAN && url.name === 'FQDN LAN'
);
expect(lanFqdnUrl).toBeDefined();
expect(lanFqdnUrl?.ipv4?.toString()).toBe('https://lan.unraid.net/');
const wanFqdnUrl = result.urls.find(
(url) => url.type === URL_TYPE.WAN && url.name === 'FQDN WAN'
);
expect(wanFqdnUrl).toBeDefined();
expect(wanFqdnUrl?.ipv4?.toString()).toBe('https://wan.unraid.net/');
});
it('should handle invalid WAN port values gracefully', () => {
const testCases = [
{ port: null, description: 'null port' },
{ port: undefined, description: 'undefined port' },
{ port: '', description: 'empty string port' },
{ port: 'invalid', description: 'non-numeric port' },
{ port: 0, description: 'zero port' },
{ port: -1, description: 'negative port' },
{ port: 65536, description: 'port above valid range' },
{ port: 1.5, description: 'non-integer port' },
];
testCases.forEach(({ port, description }) => {
const mockStore = {
emhttp: {
nginx: {
defaultUrl: 'https://default.unraid.net',
lanIp: '192.168.1.1',
lanIp6: 'ipv6.unraid.local',
lanName: 'unraid.local',
lanMdns: 'unraid.local',
sslEnabled: true,
sslMode: 'yes',
httpPort: 80,
httpsPort: 443,
fqdnUrls: [
{
interface: 'WAN',
id: null,
fqdn: 'wan.unraid.net',
isIpv6: false,
},
],
},
},
};
(mockConfigService.get as Mock)
.mockReturnValueOnce(mockStore)
.mockReturnValueOnce(port);
const result = service.getServerIps();
// Should fallback to nginx.httpsPort (443) for WAN FQDN URLs
const wanFqdnUrl = result.urls.find(
(url) => url.type === URL_TYPE.WAN && url.name === 'FQDN WAN'
);
expect(wanFqdnUrl).toBeDefined();
expect(wanFqdnUrl?.ipv4?.toString()).toBe('https://wan.unraid.net/');
expect(result.errors).toHaveLength(0);
});
});
it('should use valid WAN port when provided', () => {
const testCases = [
{ port: 1, expected: 'https://wan.unraid.net:1/' },
{ port: 8080, expected: 'https://wan.unraid.net:8080/' },
{ port: 65535, expected: 'https://wan.unraid.net:65535/' },
{ port: '3000', expected: 'https://wan.unraid.net:3000/' }, // string that parses to valid number
];
testCases.forEach(({ port, expected }) => {
const mockStore = {
emhttp: {
nginx: {
defaultUrl: 'https://default.unraid.net',
lanIp: '192.168.1.1',
lanIp6: 'ipv6.unraid.local',
lanName: 'unraid.local',
lanMdns: 'unraid.local',
sslEnabled: true,
sslMode: 'yes',
httpPort: 80,
httpsPort: 443,
fqdnUrls: [
{
interface: 'WAN',
id: null,
fqdn: 'wan.unraid.net',
isIpv6: false,
},
],
},
},
};
(mockConfigService.get as Mock)
.mockReturnValueOnce(mockStore)
.mockReturnValueOnce(port);
const result = service.getServerIps();
const wanFqdnUrl = result.urls.find(
(url) => url.type === URL_TYPE.WAN && url.name === 'FQDN WAN'
);
expect(wanFqdnUrl).toBeDefined();
expect(wanFqdnUrl?.ipv4?.toString()).toBe(expected);
expect(result.errors).toHaveLength(0);
});
});
});
describe('getRemoteAccessUrl', () => {
it('should return WAN URL when available', () => {
const mockStore = {
emhttp: {
nginx: {
defaultUrl: 'https://default.unraid.net',
lanIp: '192.168.1.1',
lanIp6: '2001:db8::1',
lanName: 'unraid.local',
lanMdns: 'unraid.local',
sslEnabled: true,
sslMode: 'yes',
httpPort: 80,
httpsPort: 443,
fqdnUrls: [
{
interface: 'WAN',
id: null,
fqdn: 'wan.unraid.net',
isIpv6: false,
},
],
},
},
};
(mockConfigService.get as Mock)
.mockReturnValueOnce(mockStore)
.mockReturnValueOnce(443);
const result = service.getRemoteAccessUrl();
expect(result).toBeDefined();
expect(result?.type).toBe(URL_TYPE.WAN);
expect(result?.ipv4?.toString()).toBe('https://wan.unraid.net/');
});
it('should return null when no WAN URL is available', () => {
const mockStore = {
emhttp: {
nginx: {
defaultUrl: 'https://default.unraid.net',
lanIp: '192.168.1.1',
lanIp6: '2001:db8::1',
lanName: 'unraid.local',
lanMdns: 'unraid.local',
sslEnabled: true,
sslMode: 'yes',
httpPort: 80,
httpsPort: 443,
fqdnUrls: [],
},
},
};
(mockConfigService.get as Mock).mockReturnValue(mockStore);
const result = service.getRemoteAccessUrl();
expect(result).toBeNull();
});
});
});

View File

@@ -1,36 +0,0 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PubSub } from 'graphql-subscriptions';
import { EVENTS, GRAPHQL_PUBSUB_CHANNEL, GRAPHQL_PUBSUB_TOKEN } from '../helper/nest-tokens.js';
@Injectable()
export class ConnectLoginHandler {
private readonly logger = new Logger(ConnectLoginHandler.name);
constructor(
@Inject(GRAPHQL_PUBSUB_TOKEN)
private readonly legacyPubSub: PubSub
) {}
@OnEvent(EVENTS.LOGIN, { async: true })
async onLogin(userInfo: {
username: string;
avatar: string;
email: string;
apikey: string;
localApiKey: string;
}) {
this.logger.log('Logging in user: %s', userInfo.username);
// Publish to the owner channel
await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.OWNER, {
owner: {
username: userInfo.username,
avatar: userInfo.avatar,
url: '',
},
});
}
}

View File

@@ -1,131 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { existsSync, readFileSync } from 'fs';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { parse as parseIni } from 'ini';
import type { MyServersConfig as LegacyConfig } from './my-servers.config.js';
import { emptyMyServersConfig, MyServersConfig } from './connect.config.js';
@Injectable()
export class ConnectConfigPersister extends ConfigFilePersister<MyServersConfig> {
constructor(configService: ConfigService) {
super(configService);
}
/**
* @override
* @returns The name of the config file.
*/
fileName(): string {
return 'connect.json';
}
/**
* @override
* @returns The key of the config in the config service.
*/
configKey(): string {
return 'connect.config';
}
/**
* @override
* @returns The default config object.
*/
defaultConfig(): MyServersConfig {
return emptyMyServersConfig();
}
/**
* Validate the config object.
* @override
* @param config - The config object to validate.
* @returns The validated config instance.
*/
public async validate(config: object) {
let instance: MyServersConfig;
if (config instanceof MyServersConfig) {
instance = config;
} else {
instance = plainToInstance(MyServersConfig, config, {
enableImplicitConversion: true,
});
}
await validateOrReject(instance, { whitelist: true });
return instance;
}
/**
* @override
* @returns The migrated config object.
*/
async migrateConfig(): Promise<MyServersConfig> {
return await this.migrateLegacyConfig();
}
/**-----------------------------------------------------
* Helpers for migrating myservers.cfg to connect.json
*------------------------------------------------------**/
/**
* Migrate the legacy config file to the new config format.
* Loads into memory, but does not persist.
*
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async migrateLegacyConfig(filePath?: string) {
const myServersCfgFile = await this.readLegacyConfig(filePath);
const legacyConfig = this.parseLegacyConfig(myServersCfgFile);
return await this.convertLegacyConfig(legacyConfig);
}
/**
* Transform the legacy config object to the new config format.
* @param filePath - The path to the legacy config file.
* @returns A new config object.
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
public async convertLegacyConfig(config: LegacyConfig): Promise<MyServersConfig> {
return this.validate({
...config.api,
...config.local,
...config.remote,
// Convert string yes/no to boolean
wanaccess: config.remote.wanaccess === 'yes',
upnpEnabled: config.remote.upnpEnabled === 'yes',
// Convert string port to number
wanport: config.remote.wanport ? parseInt(config.remote.wanport, 10) : 0,
});
}
/**
* Get the legacy config from the filesystem.
* @param filePath - The path to the legacy config file.
* @returns The legacy config object.
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async readLegacyConfig(filePath?: string) {
filePath ??= this.configService.get(
'PATHS_MY_SERVERS_CONFIG',
'/boot/config/plugins/dynamix.my.servers/myservers.cfg'
);
if (!filePath) {
throw new Error('No legacy config file path provided');
}
if (!existsSync(filePath)) {
throw new Error(`Legacy config file does not exist: ${filePath}`);
}
return readFileSync(filePath, 'utf8');
}
public parseLegacyConfig(iniFileContent: string): LegacyConfig {
return parseIni(iniFileContent) as LegacyConfig;
}
}

View File

@@ -1,57 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import { EVENTS } from '../helper/nest-tokens.js';
import { ConfigType, emptyMyServersConfig, MyServersConfig } from './connect.config.js';
@Injectable()
export class ConnectConfigService {
public readonly configKey = 'connect.config';
private readonly logger = new Logger(ConnectConfigService.name);
constructor(private readonly configService: ConfigService<ConfigType>) {}
getConfig(): MyServersConfig {
return this.configService.getOrThrow<MyServersConfig>(this.configKey);
}
getExtraOrigins(): string[] {
const extraOrigins = this.configService.get<string>('store.config.api.extraOrigins');
if (extraOrigins) {
return extraOrigins
.replaceAll(' ', '')
.split(',')
.filter((origin) => origin.startsWith('http://') || origin.startsWith('https://'));
}
return [];
}
getSandboxOrigins(): string[] {
const introspectionFlag = this.configService.get<boolean>('GRAPHQL_INTROSPECTION');
if (introspectionFlag) {
return ['https://studio.apollographql.com'];
}
return [];
}
/**
* Clear the user's identity from the config.
*
* This is used when the user logs out.
* It retains the existing config, but resets identity-related fields.
*/
resetUser() {
// overwrite identity fields, but retain destructured fields
const { wanaccess, wanport, upnpEnabled, ...identity } = emptyMyServersConfig();
this.configService.set(this.configKey, {
...this.getConfig(),
...identity,
});
this.logger.verbose('Reset Connect user identity');
}
@OnEvent(EVENTS.LOGOUT, { async: true })
async onLogout() {
this.resetUser();
}
}

View File

@@ -1,210 +0,0 @@
import { UsePipes, ValidationPipe } from '@nestjs/common';
import { registerAs } from '@nestjs/config';
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { URL_TYPE } from '@unraid/shared/network.model.js';
import { plainToInstance } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEmail,
IsEnum,
IsNumber,
IsOptional,
IsString,
Matches,
ValidateIf,
} from 'class-validator';
export enum MinigraphStatus {
PRE_INIT = 'PRE_INIT',
CONNECTING = 'CONNECTING',
CONNECTED = 'CONNECTED',
PING_FAILURE = 'PING_FAILURE',
ERROR_RETRYING = 'ERROR_RETRYING',
}
export enum DynamicRemoteAccessType {
STATIC = 'STATIC',
UPNP = 'UPNP',
DISABLED = 'DISABLED',
}
@ObjectType()
@UsePipes(new ValidationPipe({ transform: true }))
@InputType('MyServersConfigInput')
export class MyServersConfig {
// Remote Access Configurationx
@Field(() => Boolean)
@IsBoolean()
wanaccess!: boolean;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
wanport?: number | null;
@Field(() => Boolean)
@IsBoolean()
upnpEnabled!: boolean;
@Field(() => String)
@IsString()
apikey!: string;
@Field(() => String)
@IsString()
localApiKey!: string;
// User Information
@Field(() => String, { nullable: true })
@IsOptional()
@ValidateIf((o) => o.email !== undefined && o.email !== null && o.email !== '')
@IsEmail()
email?: string | null;
@Field(() => String)
@IsString()
username!: string;
@Field(() => String)
@IsString()
avatar!: string;
@Field(() => String)
@IsString()
regWizTime!: string;
// Remote Access Settings
@Field(() => DynamicRemoteAccessType)
@IsEnum(DynamicRemoteAccessType)
dynamicRemoteAccessType!: DynamicRemoteAccessType;
// Connection Status
// @Field(() => MinigraphStatus)
// @IsEnum(MinigraphStatus)
// minigraph!: MinigraphStatus;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
upnpStatus?: string | null;
}
@ObjectType()
@UsePipes(new ValidationPipe({ transform: true }))
export class ConnectionMetadata {
@Field(() => MinigraphStatus)
@IsEnum(MinigraphStatus)
status!: MinigraphStatus;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
error?: string | null;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
lastPing?: number | null;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
selfDisconnectedSince?: number | null;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
timeout?: number | null;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
timeoutStart?: number | null;
}
@ObjectType()
@InputType('AccessUrlObjectInput')
export class AccessUrlObject {
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
ipv4!: string | null | undefined;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
ipv6!: string | null | undefined;
@Field(() => URL_TYPE)
@IsEnum(URL_TYPE)
type!: URL_TYPE;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
name!: string | null | undefined;
}
@ObjectType()
@UsePipes(new ValidationPipe({ transform: true }))
@InputType('DynamicRemoteAccessStateInput')
export class DynamicRemoteAccessState {
@Field(() => DynamicRemoteAccessType)
@IsEnum(DynamicRemoteAccessType)
runningType!: DynamicRemoteAccessType;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
error!: string | null;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
lastPing!: number | null;
@Field(() => AccessUrlObject, { nullable: true })
@IsOptional()
allowedUrl!: AccessUrlObject | null;
}
export const makeDisabledDynamicRemoteAccessState = (): DynamicRemoteAccessState =>
plainToInstance(DynamicRemoteAccessState, {
runningType: DynamicRemoteAccessType.DISABLED,
error: null,
lastPing: null,
allowedUrl: null,
});
export type ConnectConfig = {
mothership: ConnectionMetadata;
dynamicRemoteAccess: DynamicRemoteAccessState;
config: MyServersConfig;
};
export type ConfigType = ConnectConfig & {
connect: ConnectConfig;
store: any;
} & Record<string, string>;
export const emptyMyServersConfig = (): MyServersConfig => ({
wanaccess: false,
wanport: 0,
upnpEnabled: false,
apikey: '',
localApiKey: '',
username: '',
avatar: '',
regWizTime: '',
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
});
export const configFeature = registerAs<ConnectConfig>('connect', () => ({
mothership: plainToInstance(ConnectionMetadata, {
status: MinigraphStatus.PRE_INIT,
}),
dynamicRemoteAccess: makeDisabledDynamicRemoteAccessState(),
config: plainToInstance(MyServersConfig, emptyMyServersConfig()),
}));

View File

@@ -1,56 +0,0 @@
// Schema for the legacy myservers.cfg configuration file.
import { registerEnumType } from '@nestjs/graphql';
export enum MinigraphStatus {
PRE_INIT = 'PRE_INIT',
CONNECTING = 'CONNECTING',
CONNECTED = 'CONNECTED',
PING_FAILURE = 'PING_FAILURE',
ERROR_RETRYING = 'ERROR_RETRYING',
}
export enum DynamicRemoteAccessType {
STATIC = 'STATIC',
UPNP = 'UPNP',
DISABLED = 'DISABLED',
}
registerEnumType(MinigraphStatus, {
name: 'MinigraphStatus',
description: 'The status of the minigraph',
});
export type MyServersConfig = {
api: {
version: string;
extraOrigins: string;
};
local: {
sandbox: 'yes' | 'no';
};
remote: {
wanaccess: string;
wanport: string;
upnpEnabled: string;
apikey: string;
localApiKey: string;
email: string;
username: string;
avatar: string;
regWizTime: string;
accesstoken: string;
idtoken: string;
refreshtoken: string;
dynamicRemoteAccessType: DynamicRemoteAccessType;
ssoSubIds: string;
};
};
/** In-Memory representation of the legacy myservers.cfg configuration file */
export type MyServersConfigMemory = MyServersConfig & {
connectionStatus: {
minigraph: MinigraphStatus;
upnpStatus?: string | null;
};
};

View File

@@ -1,69 +0,0 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { MinigraphStatus } from '../config/my-servers.config.js';
@ObjectType()
export class ApiKeyResponse {
@Field(() => Boolean)
valid!: boolean;
@Field(() => String, { nullable: true })
error?: string;
}
@ObjectType()
export class MinigraphqlResponse {
@Field(() => MinigraphStatus)
status!: MinigraphStatus;
@Field(() => Int, { nullable: true })
timeout?: number | null;
@Field(() => String, { nullable: true })
error?: string | null;
}
@ObjectType()
export class CloudResponse {
@Field(() => String)
status!: string;
@Field(() => String, { nullable: true })
ip?: string;
@Field(() => String, { nullable: true })
error?: string | null;
}
@ObjectType()
export class RelayResponse {
@Field(() => String)
status!: string;
@Field(() => String, { nullable: true })
timeout?: string;
@Field(() => String, { nullable: true })
error?: string;
}
@ObjectType()
export class Cloud {
@Field(() => String, { nullable: true })
error?: string;
@Field(() => ApiKeyResponse)
apiKey!: ApiKeyResponse;
@Field(() => RelayResponse, { nullable: true })
relay?: RelayResponse;
@Field(() => MinigraphqlResponse)
minigraphql!: MinigraphqlResponse;
@Field(() => CloudResponse)
cloud!: CloudResponse;
@Field(() => [String])
allowedOrigins!: string[];
}

View File

@@ -1,52 +0,0 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import {
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { NetworkService } from '../network/network.service.js';
import { Cloud } from './cloud.model.js';
import { CloudService } from './cloud.service.js';
/**
* Exposes details about the connection to the Unraid Connect cloud.
*/
@Resolver(() => Cloud)
export class CloudResolver {
constructor(
private readonly cloudService: CloudService,
private readonly networkService: NetworkService
) {}
@Query(() => Cloud)
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.CLOUD,
})
public async cloud(): Promise<Cloud> {
const minigraphql = this.cloudService.checkMothershipClient();
const cloud = await this.cloudService.checkCloudConnection();
const cloudError = cloud.error ? `NETWORK: ${cloud.error}` : '';
const miniGraphError = minigraphql.error ? `CLOUD: ${minigraphql.error}` : '';
let error = cloudError || miniGraphError || undefined;
if (cloudError && miniGraphError) {
error = `${cloudError}\n${miniGraphError}`;
}
return {
relay: {
// Left in for UPC backwards compat.
error: undefined,
status: 'connected',
timeout: undefined,
},
apiKey: { valid: true },
minigraphql,
cloud,
allowedOrigins: this.networkService.getAllowedOrigins(),
error,
};
}
}

View File

@@ -1,249 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { lookup as lookupDNS, resolve as resolveDNS } from 'node:dns';
import { promisify } from 'node:util';
import { got, HTTPError, TimeoutError } from 'got';
import ip from 'ip';
import NodeCache from 'node-cache';
import { ConfigType, MinigraphStatus } from '../config/connect.config.js';
import { ConnectConfigService } from '../config/connect.config.service.js';
import { ONE_HOUR_SECS, ONE_MINUTE_SECS } from '../helper/generic-consts.js';
import { MothershipConnectionService } from '../mothership-proxy/connection.service.js';
import { CloudResponse, MinigraphqlResponse } from './cloud.model.js';
interface CacheSchema {
cloudIp: string;
dnsError: Error;
cloudCheck: CloudResponse;
}
/** Type-helper that keeps all NodeCache methods except get/set signatures */
type TypedCache<S> = Omit<NodeCache, 'set' | 'get'> & {
set<K extends keyof S>(key: K, value: S[K], ttl?: number): boolean;
get<K extends keyof S>(key: K): S[K] | undefined;
};
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,
},
});
const isHttpError = (error: unknown): error is HTTPError => error instanceof HTTPError;
/**
* Cloud connection service.
*
* Checks connection status to the cloud infrastructure supporting Unraid Connect.
*/
@Injectable()
export class CloudService {
static cache = new NodeCache() as TypedCache<CacheSchema>;
private readonly logger = new Logger(CloudService.name);
constructor(
private readonly configService: ConfigService<ConfigType>,
private readonly mothership: MothershipConnectionService,
private readonly connectConfig: ConnectConfigService
) {}
checkMothershipClient(): MinigraphqlResponse {
this.logger.verbose('checking mini-graphql');
const connection = this.mothership.getConnectionState();
if (!connection) {
return { status: MinigraphStatus.PING_FAILURE, error: 'No connection to mothership' };
}
let timeoutRemaining: number | null = null;
const { status, error, timeout, timeoutStart } = connection;
if (timeout && timeoutStart) {
const elapsed = Date.now() - timeoutStart;
timeoutRemaining = timeout - elapsed;
}
return { status, error, timeout: timeoutRemaining };
}
async checkCloudConnection() {
this.logger.verbose('checking cloud connection');
const gqlClientStatus = this.mothership.getConnectionState()?.status;
if (gqlClientStatus === MinigraphStatus.CONNECTED) {
return await this.fastCheckCloud();
}
const apiKey = this.connectConfig.getConfig().apikey;
const cachedCloudCheck = CloudService.cache.get('cloudCheck');
if (cachedCloudCheck) {
// this.logger.verbose('Cache hit for cloud check %O', cachedCloudCheck);
return cachedCloudCheck;
}
this.logger.verbose('Cache miss for cloud check');
const apiVersion = this.configService.getOrThrow<string>('API_VERSION');
const cloudCheck = await this.hardCheckCloud(apiVersion, apiKey);
const ttl = cloudCheck.error ? 15 * ONE_MINUTE_SECS : 4 * ONE_HOUR_SECS; // 15 minutes for a failure, 4 hours for a success
CloudService.cache.set('cloudCheck', cloudCheck, ttl);
return cloudCheck;
}
private async hardCheckCloud(apiVersion: string, apiKey: string): Promise<CloudResponse> {
try {
const mothershipGqlUri = this.configService.getOrThrow<string>('MOTHERSHIP_GRAPHQL_LINK');
const ip = await this.checkDns();
const { canReach, baseUrl } = await this.canReachMothership(
mothershipGqlUri,
apiVersion,
apiKey
);
if (!canReach) {
return { status: 'error', error: `Unable to connect to mothership at ${baseUrl}` };
}
await this.checkMothershipAuthentication(mothershipGqlUri, apiVersion, apiKey);
return { status: 'ok', error: null, ip };
} catch (error) {
return { status: 'error', error: error instanceof Error ? error.message : 'Unknown Error' };
}
}
private async canReachMothership(mothershipGqlUri: string, apiVersion: string, apiKey: string) {
const mothershipBaseUrl = new URL(mothershipGqlUri).origin;
/**
* 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 canReach = await got
.head(mothershipBaseUrl, createGotOptions(apiVersion, apiKey))
.then(() => true)
.catch(() => false);
return { canReach, baseUrl: mothershipBaseUrl };
}
private async checkMothershipAuthentication(
mothershipGqlUri: string,
apiVersion: string,
apiKey: string
) {
const msURL = new URL(mothershipGqlUri);
const url = `https://${msURL.hostname}${msURL.pathname}`;
try {
const options = createGotOptions(apiVersion, apiKey);
// This will throw if there is a non 2XX/3XX code
await got.head(url, options);
} catch (error: unknown) {
// HTTP errors
if (isHttpError(error)) {
switch (error.response.statusCode) {
case 429: {
const retryAfter = error.response.headers['retry-after'];
throw new Error(
retryAfter
? `${url} is rate limited for another ${retryAfter} seconds`
: `${url} is rate limited`
);
}
case 401:
throw new Error('Invalid credentials');
default:
throw new Error(
`Failed to connect to ${url} with a "${error.response.statusCode}" HTTP error.`
);
}
}
if (error instanceof TimeoutError) throw new Error(`Timed-out while connecting to "${url}"`);
this.logger.debug('Unknown Error', error);
// @TODO: Add in the cause when we move to a newer node version
// throw new Error('Unknown Error', { cause: error as Error });
throw new Error('Unknown Error');
}
}
private async fastCheckCloud(): Promise<CloudResponse> {
let ip = 'FAST_CHECK_NO_IP_FOUND';
try {
ip = await this.checkDns();
} catch (error) {
this.logger.warn(error, 'Failed to fetch DNS, but Minigraph is connected - continuing');
ip = `ERROR: ${error instanceof Error ? error.message : 'Unknown Error'}`;
// Clear error since we're actually connected to the cloud.
// Do not populate the ip cache since we're in a weird state (this is a change from the previous behavior).
CloudService.cache.del('dnsError');
}
return { status: 'ok', error: null, ip };
}
private async checkDns(): Promise<string> {
const cache = CloudService.cache;
const cloudIp = cache.get('cloudIp');
if (cloudIp) return cloudIp;
const dnsError = cache.get('dnsError');
if (dnsError) throw dnsError;
try {
const { local, network } = await this.hardCheckDns();
const validIp = local ?? network ?? '';
if (typeof validIp !== 'string') {
return '';
}
cache.set('cloudIp', validIp, 12 * ONE_HOUR_SECS); // 12 hours ttl
return validIp;
} catch (error) {
cache.set('dnsError', error as Error, 15 * ONE_MINUTE_SECS); // 15 minutes ttl
cache.del('cloudIp');
throw error;
}
}
private async hardCheckDns() {
const mothershipGqlUri = this.configService.getOrThrow<string>('MOTHERSHIP_BASE_URL');
const hostname = new URL(mothershipGqlUri).host;
const lookup = promisify(lookupDNS);
const resolve = promisify(resolveDNS);
const [local, network] = await Promise.all([
lookup(hostname).then(({ address }) => address),
resolve(hostname).then(([address]) => address),
]);
/**
* If either resolver returns a private IP we still treat this as a fatal
* mis-configuration because the host will be unreachable from the public
* Internet.
*
* The user likely has a PI-hole or something similar running that rewrites
* the record to a private address.
*/
if (ip.isPrivate(local) || ip.isPrivate(network)) {
throw new Error(
`"${hostname}" is being resolved to a private IP. [local="${local ?? 'NOT FOUND'}"] [network="${
network ?? 'NOT FOUND'
}"]`
);
}
/**
* Different public IPs are expected when Cloudflare (or anycast) load-balancing
* is in place. Log the mismatch for debugging purposes but do **not** treat it
* as an error.
*
* It does not affect whether the server can connect to Mothership.
*/
if (local !== network) {
this.logger.debug(
`Local and network resolvers returned different IPs for "${hostname}". [local="${local ?? 'NOT FOUND'}"] [network="${
network ?? 'NOT FOUND'
}"]`
);
}
return { local, network };
}
}

View File

@@ -1,158 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { access, constants, mkdir, readFile, rm } from 'fs/promises';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
describe('ConnectStatusWriterService Config Behavior', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
const testDir = '/tmp/connect-status-config-test';
const testFilePath = join(testDir, 'connectStatus.json');
// Simulate config changes
let configStore: any = {};
beforeEach(async () => {
vi.clearAllMocks();
// Reset config store
configStore = {};
// Create test directory
await mkdir(testDir, { recursive: true });
// Create a ConfigService mock that behaves like the real one
configService = {
get: vi.fn().mockImplementation((key: string) => {
console.log(`ConfigService.get('${key}') called, returning:`, configStore[key]);
return configStore[key];
}),
set: vi.fn().mockImplementation((key: string, value: any) => {
console.log(`ConfigService.set('${key}', ${JSON.stringify(value)}) called`);
configStore[key] = value;
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
// Override the status file path to use our test location
Object.defineProperty(service, 'statusFilePath', {
get: () => testFilePath,
});
});
afterEach(async () => {
await service.onModuleDestroy();
await rm(testDir, { recursive: true, force: true });
});
it('should write status when config is updated directly', async () => {
// Initialize service - should write PRE_INIT
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
let content = await readFile(testFilePath, 'utf-8');
let data = JSON.parse(content);
console.log('Initial status:', data);
expect(data.connectionStatus).toBe('PRE_INIT');
// Update config directly (simulating what ConnectionService does)
console.log('\n=== Updating config to CONNECTED ===');
configService.set('connect.mothership', {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
});
// Call the writeStatus method directly (since @OnEvent handles the event)
await service['writeStatus']();
content = await readFile(testFilePath, 'utf-8');
data = JSON.parse(content);
console.log('Status after config update:', data);
expect(data.connectionStatus).toBe('CONNECTED');
});
it('should test the actual flow with multiple status updates', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
const statusUpdates = [
{ status: 'CONNECTING', error: null, lastPing: null },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
{ status: 'DISCONNECTED', error: 'Lost connection', lastPing: Date.now() - 10000 },
{ status: 'RECONNECTING', error: null, lastPing: Date.now() - 10000 },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
];
for (const update of statusUpdates) {
console.log(`\n=== Updating to ${update.status} ===`);
// Update config
configService.set('connect.mothership', update);
// Call writeStatus directly
await service['writeStatus']();
const content = await readFile(testFilePath, 'utf-8');
const data = JSON.parse(content);
console.log(`Status file shows: ${data.connectionStatus}`);
expect(data.connectionStatus).toBe(update.status);
}
});
it('should handle case where config is not set before event', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Delete the config
delete configStore['connect.mothership'];
// Call writeStatus without config
console.log('\n=== Calling writeStatus with no config ===');
await service['writeStatus']();
const content = await readFile(testFilePath, 'utf-8');
const data = JSON.parse(content);
console.log('Status with no config:', data);
expect(data.connectionStatus).toBe('PRE_INIT');
// Now set config and call writeStatus again
console.log('\n=== Setting config and calling writeStatus ===');
configService.set('connect.mothership', {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
});
await service['writeStatus']();
const content2 = await readFile(testFilePath, 'utf-8');
const data2 = JSON.parse(content2);
console.log('Status after setting config:', data2);
expect(data2.connectionStatus).toBe('CONNECTED');
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Verify file exists
await expect(access(testFilePath, constants.F_OK)).resolves.not.toThrow();
// Cleanup
await service.onModuleDestroy();
// Verify file is deleted
await expect(access(testFilePath, constants.F_OK)).rejects.toThrow();
});
it('should handle cleanup when file does not exist', async () => {
// Don't bootstrap (so no file is written)
await expect(service.onModuleDestroy()).resolves.not.toThrow();
});
});
});

View File

@@ -1,167 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { access, constants, mkdir, readFile, rm } from 'fs/promises';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
describe('ConnectStatusWriterService Integration', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
const testDir = '/tmp/connect-status-test';
const testFilePath = join(testDir, 'connectStatus.json');
beforeEach(async () => {
vi.clearAllMocks();
// Create test directory
await mkdir(testDir, { recursive: true });
configService = {
get: vi.fn().mockImplementation((key: string) => {
console.log(`ConfigService.get called with key: ${key}`);
return {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
};
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
// Override the status file path to use our test location
Object.defineProperty(service, 'statusFilePath', {
get: () => testFilePath,
});
});
afterEach(async () => {
await service.onModuleDestroy();
await rm(testDir, { recursive: true, force: true });
});
it('should write initial PRE_INIT status, then update on event', async () => {
// First, mock the config to return undefined (no connection metadata)
vi.mocked(configService.get).mockReturnValue(undefined);
console.log('=== Starting onApplicationBootstrap ===');
await service.onApplicationBootstrap();
// Wait a bit for the initial write to complete
await new Promise(resolve => setTimeout(resolve, 50));
// Read initial status
const initialContent = await readFile(testFilePath, 'utf-8');
const initialData = JSON.parse(initialContent);
console.log('Initial status written:', initialData);
expect(initialData.connectionStatus).toBe('PRE_INIT');
expect(initialData.error).toBeNull();
expect(initialData.lastPing).toBeNull();
// Now update the mock to return CONNECTED status
vi.mocked(configService.get).mockReturnValue({
status: 'CONNECTED',
error: null,
lastPing: 1234567890,
});
console.log('=== Calling writeStatus directly ===');
await service['writeStatus']();
// Read updated status
const updatedContent = await readFile(testFilePath, 'utf-8');
const updatedData = JSON.parse(updatedContent);
console.log('Updated status after writeStatus:', updatedData);
expect(updatedData.connectionStatus).toBe('CONNECTED');
expect(updatedData.lastPing).toBe(1234567890);
});
it('should handle rapid status changes correctly', async () => {
const statusChanges = [
{ status: 'PRE_INIT', error: null, lastPing: null },
{ status: 'CONNECTING', error: null, lastPing: null },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
{ status: 'DISCONNECTED', error: 'Connection lost', lastPing: Date.now() - 5000 },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
];
let changeIndex = 0;
vi.mocked(configService.get).mockImplementation(() => {
const change = statusChanges[changeIndex];
console.log(`Returning status ${changeIndex}: ${change.status}`);
return change;
});
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Simulate the final status change
changeIndex = statusChanges.length - 1;
console.log(`=== Calling writeStatus for final status: ${statusChanges[changeIndex].status} ===`);
await service['writeStatus']();
// Read final status
const finalContent = await readFile(testFilePath, 'utf-8');
const finalData = JSON.parse(finalContent);
console.log('Final status after status change:', finalData);
// Should have the last status
expect(finalData.connectionStatus).toBe('CONNECTED');
expect(finalData.error).toBeNull();
});
it('should handle multiple write calls correctly', async () => {
const writes: number[] = [];
const originalWriteStatus = service['writeStatus'].bind(service);
service['writeStatus'] = async function() {
const timestamp = Date.now();
writes.push(timestamp);
console.log(`writeStatus called at ${timestamp}`);
return originalWriteStatus();
};
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
const initialWrites = writes.length;
console.log(`Initial writes: ${initialWrites}`);
// Make multiple write calls
for (let i = 0; i < 3; i++) {
console.log(`Calling writeStatus ${i}`);
await service['writeStatus']();
}
console.log(`Total writes: ${writes.length}`);
console.log('Write timestamps:', writes);
// Should have initial write + 3 additional writes
expect(writes.length).toBe(initialWrites + 3);
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Verify file exists
await expect(access(testFilePath, constants.F_OK)).resolves.not.toThrow();
// Cleanup
await service.onModuleDestroy();
// Verify file is deleted
await expect(access(testFilePath, constants.F_OK)).rejects.toThrow();
});
it('should handle cleanup gracefully when file does not exist', async () => {
// Don't bootstrap (so no file is created)
await expect(service.onModuleDestroy()).resolves.not.toThrow();
});
});
});

View File

@@ -1,140 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { unlink, writeFile } from 'fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
unlink: vi.fn(),
}));
describe('ConnectStatusWriterService', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
let writeFileMock: ReturnType<typeof vi.fn>;
let unlinkMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();
writeFileMock = vi.mocked(writeFile);
unlinkMock = vi.mocked(unlink);
configService = {
get: vi.fn().mockReturnValue({
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
});
afterEach(async () => {
vi.useRealTimers();
});
describe('onApplicationBootstrap', () => {
it('should write initial status on bootstrap', async () => {
await service.onApplicationBootstrap();
expect(writeFileMock).toHaveBeenCalledTimes(1);
expect(writeFileMock).toHaveBeenCalledWith(
'/var/local/emhttp/connectStatus.json',
expect.stringContaining('CONNECTED')
);
});
it('should handle event-driven status changes', async () => {
await service.onApplicationBootstrap();
writeFileMock.mockClear();
// The service uses @OnEvent decorator, so we need to call the method directly
await service['writeStatus']();
expect(writeFileMock).toHaveBeenCalledTimes(1);
});
});
describe('write content', () => {
it('should write correct JSON structure with all fields', async () => {
const mockMetadata = {
status: 'CONNECTED',
error: 'Some error',
lastPing: 1234567890,
};
vi.mocked(configService.get).mockReturnValue(mockMetadata);
await service.onApplicationBootstrap();
const writeCall = writeFileMock.mock.calls[0];
const writtenData = JSON.parse(writeCall[1] as string);
expect(writtenData).toMatchObject({
connectionStatus: 'CONNECTED',
error: 'Some error',
lastPing: 1234567890,
allowedOrigins: '',
});
expect(writtenData.timestamp).toBeDefined();
expect(typeof writtenData.timestamp).toBe('number');
});
it('should handle missing connection metadata', async () => {
vi.mocked(configService.get).mockReturnValue(undefined);
await service.onApplicationBootstrap();
const writeCall = writeFileMock.mock.calls[0];
const writtenData = JSON.parse(writeCall[1] as string);
expect(writtenData).toMatchObject({
connectionStatus: 'PRE_INIT',
error: null,
lastPing: null,
allowedOrigins: '',
});
});
});
describe('error handling', () => {
it('should handle write errors gracefully', async () => {
writeFileMock.mockRejectedValue(new Error('Write failed'));
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
// Test direct write error handling
await expect(service['writeStatus']()).resolves.not.toThrow();
});
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onModuleDestroy();
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith('/var/local/emhttp/connectStatus.json');
});
it('should handle file deletion errors gracefully', async () => {
unlinkMock.mockRejectedValue(new Error('File not found'));
await expect(service.onModuleDestroy()).resolves.not.toThrow();
expect(unlinkMock).toHaveBeenCalledTimes(1);
});
it('should ensure file is deleted even if it was never written', async () => {
// Don't bootstrap (so no file is written)
await service.onModuleDestroy();
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith('/var/local/emhttp/connectStatus.json');
});
});
});

View File

@@ -1,73 +0,0 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
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';
@Injectable()
export class ConnectStatusWriterService implements OnApplicationBootstrap, OnModuleDestroy {
constructor(private readonly configService: ConfigService<ConfigType, true>) {}
private logger = new Logger(ConnectStatusWriterService.name);
get statusFilePath() {
// 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() {
this.logger.verbose(`Status file path: ${this.statusFilePath}`);
// Write initial status
await this.writeStatus();
}
async onModuleDestroy() {
try {
await unlink(this.statusFilePath);
this.logger.verbose(`Status file deleted: ${this.statusFilePath}`);
} catch (error) {
this.logger.debug(`Could not delete status file: ${error}`);
}
}
@OnEvent(EVENTS.MOTHERSHIP_CONNECTION_STATUS_CHANGED, { async: true })
private async writeStatus() {
try {
const connectionMetadata = this.configService.get<ConnectionMetadata>('connect.mothership');
// Try to get allowed origins from the store
let allowedOrigins = '';
try {
// We can't import from @app here, so we'll skip allowed origins for now
// This can be added later if needed
allowedOrigins = '';
} catch (error) {
this.logger.debug('Could not get allowed origins:', error);
}
const statusData = {
connectionStatus: connectionMetadata?.status || 'PRE_INIT',
error: connectionMetadata?.error || null,
lastPing: connectionMetadata?.lastPing || null,
allowedOrigins: allowedOrigins,
timestamp: Date.now(),
};
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) {
this.logger.error(error, `Error writing status to '${this.statusFilePath}'`);
}
}
}

View File

@@ -1,79 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { isDefined } from 'class-validator';
import { MinigraphStatus } from '../config/connect.config.js';
import { ONE_MINUTE_MS, THREE_MINUTES_MS } from '../helper/generic-consts.js';
import { MothershipConnectionService } from '../mothership-proxy/connection.service.js';
import { MothershipSubscriptionHandler } from '../mothership-proxy/mothership-subscription.handler.js';
import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js';
@Injectable()
export class TimeoutCheckerJob {
constructor(
private readonly connectionService: MothershipConnectionService,
private readonly subscriptionHandler: MothershipSubscriptionHandler,
private schedulerRegistry: SchedulerRegistry,
private readonly dynamicRemoteAccess: DynamicRemoteAccessService
) {}
public jobName = 'connect-timeout-checker';
private readonly logger = new Logger(TimeoutCheckerJob.name);
private hasMothershipClientTimedOut() {
const { lastPing, status } = this.connectionService.getConnectionState() ?? {};
return (
status === MinigraphStatus.CONNECTED && lastPing && Date.now() - lastPing > THREE_MINUTES_MS
);
}
private checkMothershipClientTimeout() {
if (this.hasMothershipClientTimedOut()) {
const minutes = this.msToMinutes(THREE_MINUTES_MS);
this.logger.warn(`NO PINGS RECEIVED IN ${minutes} MINUTES, SOCKET MUST BE RECONNECTED`);
this.connectionService.setConnectionStatus({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping Receive Exceeded Timeout',
});
}
}
private msToMinutes(ms: number) {
return ms / 1000 / 60;
}
async checkForTimeouts() {
this.subscriptionHandler.clearStaleSubscriptions({ maxAgeMs: THREE_MINUTES_MS });
this.checkMothershipClientTimeout();
await this.dynamicRemoteAccess.checkForTimeout();
}
start() {
this.stop();
const callback = () => this.checkForTimeouts();
const interval = setInterval(callback, ONE_MINUTE_MS);
this.schedulerRegistry.addInterval(this.jobName, interval);
}
stop() {
if (!this.isJobRegistered()) {
this.logger.debug('Stop called before TimeoutCheckerJob was registered. Ignoring.');
return;
}
const interval = this.schedulerRegistry.getInterval(this.jobName);
if (isDefined(interval)) {
clearInterval(interval);
this.schedulerRegistry.deleteInterval(this.jobName);
}
}
isJobRunning() {
return this.isJobRegistered() && isDefined(this.schedulerRegistry.getInterval(this.jobName));
}
isJobRegistered() {
this.logger.verbose('isJobRegistered?');
return this.schedulerRegistry.doesExist('interval', this.jobName);
}
}

View File

@@ -1,36 +0,0 @@
import { graphql } from './generated/client/gql.js';
export const RemoteGraphQL_Fragment = graphql(/* GraphQL */ `
fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {
remoteGraphQLEventData: data {
type
body
sha256
}
}
`);
export const EVENTS_SUBSCRIPTION = graphql(/* GraphQL */ `
subscription events {
events {
__typename
... on ClientConnectedEvent {
connectedData: data {
type
version
apiKey
}
connectedEvent: type
}
... on ClientDisconnectedEvent {
disconnectedData: data {
type
version
apiKey
}
disconnectedEvent: type
}
...RemoteGraphQLEventFragment
}
}
`);

View File

@@ -1,87 +0,0 @@
/* eslint-disable */
import type { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
import type { FragmentDefinitionNode } from 'graphql';
import type { Incremental } from './graphql.js';
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<
infer TType,
any
>
? [TType] extends [{ ' $fragmentName'?: infer TKey }]
? TKey extends string
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
: never
: never
: never;
// return non-nullable if `fragmentType` is non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
): TType;
// return nullable if `fragmentType` is undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined
): TType | undefined;
// return nullable if `fragmentType` is nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null
): TType | null;
// return nullable if `fragmentType` is nullable or undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined
): TType | null | undefined;
// return array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>
): Array<TType>;
// return array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): Array<TType> | null | undefined;
// return readonly array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
): ReadonlyArray<TType>;
// return readonly array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): ReadonlyArray<TType> | null | undefined;
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | Array<FragmentType<DocumentTypeDecoration<TType, any>>> | ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
return fragmentType as any;
}
export function makeFragmentData<
F extends DocumentTypeDecoration<any, any>,
FT extends ResultOf<F>
>(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>;
}
export function isFragmentReady<TQuery, TFrag>(
queryNode: DocumentTypeDecoration<TQuery, any>,
fragmentNode: TypedDocumentNode<TFrag>,
data: FragmentType<TypedDocumentNode<Incremental<TFrag>, any>> | null | undefined
): data is FragmentType<typeof fragmentNode> {
const deferredFields = (queryNode as { __meta__?: { deferredFields: Record<string, (keyof TFrag)[]> } }).__meta__
?.deferredFields;
if (!deferredFields) return true;
const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
const fragName = fragDef?.name?.value;
const fields = (fragName && deferredFields[fragName]) || [];
return fields.length > 0 && fields.every(field => data && field in data);
}

View File

@@ -1,58 +0,0 @@
/* eslint-disable */
import * as types from './graphql.js';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
"\n fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {\n remoteGraphQLEventData: data {\n type\n body\n sha256\n }\n }\n": typeof types.RemoteGraphQlEventFragmentFragmentDoc,
"\n subscription events {\n events {\n __typename\n ... on ClientConnectedEvent {\n connectedData: data {\n type\n version\n apiKey\n }\n connectedEvent: type\n }\n ... on ClientDisconnectedEvent {\n disconnectedData: data {\n type\n version\n apiKey\n }\n disconnectedEvent: type\n }\n ...RemoteGraphQLEventFragment\n }\n }\n": typeof types.EventsDocument,
"\n mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {\n remoteGraphQLResponse(input: $input)\n }\n": typeof types.SendRemoteGraphQlResponseDocument,
};
const documents: Documents = {
"\n fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {\n remoteGraphQLEventData: data {\n type\n body\n sha256\n }\n }\n": types.RemoteGraphQlEventFragmentFragmentDoc,
"\n subscription events {\n events {\n __typename\n ... on ClientConnectedEvent {\n connectedData: data {\n type\n version\n apiKey\n }\n connectedEvent: type\n }\n ... on ClientDisconnectedEvent {\n disconnectedData: data {\n type\n version\n apiKey\n }\n disconnectedEvent: type\n }\n ...RemoteGraphQLEventFragment\n }\n }\n": types.EventsDocument,
"\n mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {\n remoteGraphQLResponse(input: $input)\n }\n": types.SendRemoteGraphQlResponseDocument,
};
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*
*
* @example
* ```ts
* const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
* ```
*
* The query argument is unknown!
* Please regenerate the types.
*/
export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {\n remoteGraphQLEventData: data {\n type\n body\n sha256\n }\n }\n"): (typeof documents)["\n fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {\n remoteGraphQLEventData: data {\n type\n body\n sha256\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription events {\n events {\n __typename\n ... on ClientConnectedEvent {\n connectedData: data {\n type\n version\n apiKey\n }\n connectedEvent: type\n }\n ... on ClientDisconnectedEvent {\n disconnectedData: data {\n type\n version\n apiKey\n }\n disconnectedEvent: type\n }\n ...RemoteGraphQLEventFragment\n }\n }\n"): (typeof documents)["\n subscription events {\n events {\n __typename\n ... on ClientConnectedEvent {\n connectedData: data {\n type\n version\n apiKey\n }\n connectedEvent: type\n }\n ... on ClientDisconnectedEvent {\n disconnectedData: data {\n type\n version\n apiKey\n }\n disconnectedEvent: type\n }\n ...RemoteGraphQLEventFragment\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {\n remoteGraphQLResponse(input: $input)\n }\n"): (typeof documents)["\n mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {\n remoteGraphQLResponse(input: $input)\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
}
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;

View File

@@ -1,755 +0,0 @@
/* eslint-disable */
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string; }
String: { input: string; output: string; }
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
/** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */
DateTime: { input: string; output: string; }
/** A field whose value is a IPv4 address: https://en.wikipedia.org/wiki/IPv4. */
IPv4: { input: any; output: any; }
/** A field whose value is a IPv6 address: https://en.wikipedia.org/wiki/IPv6. */
IPv6: { input: any; output: any; }
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
JSON: { input: Record<string, any>; output: Record<string, any>; }
/** The `Long` scalar type represents 52-bit integers */
Long: { input: number; output: number; }
/** A field whose value is a valid TCP port within the range of 0 to 65535: https://en.wikipedia.org/wiki/Transmission_Control_Protocol#TCP_ports */
Port: { input: number; output: number; }
/** A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. */
URL: { input: URL; output: URL; }
};
export type AccessUrl = {
__typename?: 'AccessUrl';
ipv4?: Maybe<Scalars['URL']['output']>;
ipv6?: Maybe<Scalars['URL']['output']>;
name?: Maybe<Scalars['String']['output']>;
type: UrlType;
};
export type AccessUrlInput = {
ipv4?: InputMaybe<Scalars['URL']['input']>;
ipv6?: InputMaybe<Scalars['URL']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
type: UrlType;
};
export type ArrayCapacity = {
__typename?: 'ArrayCapacity';
bytes?: Maybe<ArrayCapacityBytes>;
};
export type ArrayCapacityBytes = {
__typename?: 'ArrayCapacityBytes';
free?: Maybe<Scalars['Long']['output']>;
total?: Maybe<Scalars['Long']['output']>;
used?: Maybe<Scalars['Long']['output']>;
};
export type ArrayCapacityBytesInput = {
free?: InputMaybe<Scalars['Long']['input']>;
total?: InputMaybe<Scalars['Long']['input']>;
used?: InputMaybe<Scalars['Long']['input']>;
};
export type ArrayCapacityInput = {
bytes?: InputMaybe<ArrayCapacityBytesInput>;
};
export type ClientConnectedEvent = {
__typename?: 'ClientConnectedEvent';
data: ClientConnectionEventData;
type: EventType;
};
export type ClientConnectionEventData = {
__typename?: 'ClientConnectionEventData';
apiKey: Scalars['String']['output'];
type: ClientType;
version: Scalars['String']['output'];
};
export type ClientDisconnectedEvent = {
__typename?: 'ClientDisconnectedEvent';
data: ClientConnectionEventData;
type: EventType;
};
export type ClientPingEvent = {
__typename?: 'ClientPingEvent';
data: PingEventData;
type: EventType;
};
export enum ClientType {
API = 'API',
DASHBOARD = 'DASHBOARD'
}
export type Config = {
__typename?: 'Config';
error?: Maybe<ConfigErrorState>;
valid?: Maybe<Scalars['Boolean']['output']>;
};
export enum ConfigErrorState {
INVALID = 'INVALID',
NO_KEY_SERVER = 'NO_KEY_SERVER',
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
WITHDRAWN = 'WITHDRAWN'
}
export type Dashboard = {
__typename?: 'Dashboard';
apps?: Maybe<DashboardApps>;
array?: Maybe<DashboardArray>;
config?: Maybe<DashboardConfig>;
display?: Maybe<DashboardDisplay>;
id: Scalars['ID']['output'];
lastPublish?: Maybe<Scalars['DateTime']['output']>;
network?: Maybe<Network>;
online?: Maybe<Scalars['Boolean']['output']>;
os?: Maybe<DashboardOs>;
services?: Maybe<Array<Maybe<DashboardService>>>;
twoFactor?: Maybe<DashboardTwoFactor>;
vars?: Maybe<DashboardVars>;
versions?: Maybe<DashboardVersions>;
vms?: Maybe<DashboardVms>;
};
export type DashboardApps = {
__typename?: 'DashboardApps';
installed?: Maybe<Scalars['Int']['output']>;
started?: Maybe<Scalars['Int']['output']>;
};
export type DashboardAppsInput = {
installed: Scalars['Int']['input'];
started: Scalars['Int']['input'];
};
export type DashboardArray = {
__typename?: 'DashboardArray';
/** Current array capacity */
capacity?: Maybe<ArrayCapacity>;
/** Current array state */
state?: Maybe<Scalars['String']['output']>;
};
export type DashboardArrayInput = {
/** Current array capacity */
capacity: ArrayCapacityInput;
/** Current array state */
state: Scalars['String']['input'];
};
export type DashboardCase = {
__typename?: 'DashboardCase';
base64?: Maybe<Scalars['String']['output']>;
error?: Maybe<Scalars['String']['output']>;
icon?: Maybe<Scalars['String']['output']>;
url?: Maybe<Scalars['String']['output']>;
};
export type DashboardCaseInput = {
base64: Scalars['String']['input'];
error?: InputMaybe<Scalars['String']['input']>;
icon: Scalars['String']['input'];
url: Scalars['String']['input'];
};
export type DashboardConfig = {
__typename?: 'DashboardConfig';
error?: Maybe<Scalars['String']['output']>;
valid?: Maybe<Scalars['Boolean']['output']>;
};
export type DashboardConfigInput = {
error?: InputMaybe<Scalars['String']['input']>;
valid: Scalars['Boolean']['input'];
};
export type DashboardDisplay = {
__typename?: 'DashboardDisplay';
case?: Maybe<DashboardCase>;
};
export type DashboardDisplayInput = {
case: DashboardCaseInput;
};
export type DashboardInput = {
apps: DashboardAppsInput;
array: DashboardArrayInput;
config: DashboardConfigInput;
display: DashboardDisplayInput;
os: DashboardOsInput;
services: Array<DashboardServiceInput>;
twoFactor?: InputMaybe<DashboardTwoFactorInput>;
vars: DashboardVarsInput;
versions: DashboardVersionsInput;
vms: DashboardVmsInput;
};
export type DashboardOs = {
__typename?: 'DashboardOs';
hostname?: Maybe<Scalars['String']['output']>;
uptime?: Maybe<Scalars['DateTime']['output']>;
};
export type DashboardOsInput = {
hostname: Scalars['String']['input'];
uptime: Scalars['DateTime']['input'];
};
export type DashboardService = {
__typename?: 'DashboardService';
name?: Maybe<Scalars['String']['output']>;
online?: Maybe<Scalars['Boolean']['output']>;
uptime?: Maybe<DashboardServiceUptime>;
version?: Maybe<Scalars['String']['output']>;
};
export type DashboardServiceInput = {
name: Scalars['String']['input'];
online: Scalars['Boolean']['input'];
uptime?: InputMaybe<DashboardServiceUptimeInput>;
version: Scalars['String']['input'];
};
export type DashboardServiceUptime = {
__typename?: 'DashboardServiceUptime';
timestamp?: Maybe<Scalars['DateTime']['output']>;
};
export type DashboardServiceUptimeInput = {
timestamp: Scalars['DateTime']['input'];
};
export type DashboardTwoFactor = {
__typename?: 'DashboardTwoFactor';
local?: Maybe<DashboardTwoFactorLocal>;
remote?: Maybe<DashboardTwoFactorRemote>;
};
export type DashboardTwoFactorInput = {
local: DashboardTwoFactorLocalInput;
remote: DashboardTwoFactorRemoteInput;
};
export type DashboardTwoFactorLocal = {
__typename?: 'DashboardTwoFactorLocal';
enabled?: Maybe<Scalars['Boolean']['output']>;
};
export type DashboardTwoFactorLocalInput = {
enabled: Scalars['Boolean']['input'];
};
export type DashboardTwoFactorRemote = {
__typename?: 'DashboardTwoFactorRemote';
enabled?: Maybe<Scalars['Boolean']['output']>;
};
export type DashboardTwoFactorRemoteInput = {
enabled: Scalars['Boolean']['input'];
};
export type DashboardVars = {
__typename?: 'DashboardVars';
flashGuid?: Maybe<Scalars['String']['output']>;
regState?: Maybe<Scalars['String']['output']>;
regTy?: Maybe<Scalars['String']['output']>;
serverDescription?: Maybe<Scalars['String']['output']>;
serverName?: Maybe<Scalars['String']['output']>;
};
export type DashboardVarsInput = {
flashGuid: Scalars['String']['input'];
regState: Scalars['String']['input'];
regTy: Scalars['String']['input'];
/** Server description */
serverDescription?: InputMaybe<Scalars['String']['input']>;
/** Name of the server */
serverName?: InputMaybe<Scalars['String']['input']>;
};
export type DashboardVersions = {
__typename?: 'DashboardVersions';
unraid?: Maybe<Scalars['String']['output']>;
};
export type DashboardVersionsInput = {
unraid: Scalars['String']['input'];
};
export type DashboardVms = {
__typename?: 'DashboardVms';
installed?: Maybe<Scalars['Int']['output']>;
started?: Maybe<Scalars['Int']['output']>;
};
export type DashboardVmsInput = {
installed: Scalars['Int']['input'];
started: Scalars['Int']['input'];
};
export type Event = ClientConnectedEvent | ClientDisconnectedEvent | ClientPingEvent | RemoteAccessEvent | RemoteGraphQlEvent | UpdateEvent;
export enum EventType {
CLIENT_CONNECTED_EVENT = 'CLIENT_CONNECTED_EVENT',
CLIENT_DISCONNECTED_EVENT = 'CLIENT_DISCONNECTED_EVENT',
CLIENT_PING_EVENT = 'CLIENT_PING_EVENT',
REMOTE_ACCESS_EVENT = 'REMOTE_ACCESS_EVENT',
REMOTE_GRAPHQL_EVENT = 'REMOTE_GRAPHQL_EVENT',
UPDATE_EVENT = 'UPDATE_EVENT'
}
export type FullServerDetails = {
__typename?: 'FullServerDetails';
apiConnectedCount?: Maybe<Scalars['Int']['output']>;
apiVersion?: Maybe<Scalars['String']['output']>;
connectionTimestamp?: Maybe<Scalars['String']['output']>;
dashboard?: Maybe<Dashboard>;
lastPublish?: Maybe<Scalars['String']['output']>;
network?: Maybe<Network>;
online?: Maybe<Scalars['Boolean']['output']>;
};
export enum Importance {
ALERT = 'ALERT',
INFO = 'INFO',
WARNING = 'WARNING'
}
export type KsServerDetails = {
__typename?: 'KsServerDetails';
accessLabel: Scalars['String']['output'];
accessUrl: Scalars['String']['output'];
apiKey?: Maybe<Scalars['String']['output']>;
description: Scalars['String']['output'];
dnsHash: Scalars['String']['output'];
flashBackupDate?: Maybe<Scalars['Int']['output']>;
flashBackupUrl: Scalars['String']['output'];
flashProduct: Scalars['String']['output'];
flashVendor: Scalars['String']['output'];
guid: Scalars['String']['output'];
ipsId?: Maybe<Scalars['String']['output']>;
keyType?: Maybe<Scalars['String']['output']>;
licenseKey: Scalars['String']['output'];
name: Scalars['String']['output'];
plgVersion?: Maybe<Scalars['String']['output']>;
signedIn: Scalars['Boolean']['output'];
};
export type LegacyService = {
__typename?: 'LegacyService';
name?: Maybe<Scalars['String']['output']>;
online?: Maybe<Scalars['Boolean']['output']>;
uptime?: Maybe<Scalars['Int']['output']>;
version?: Maybe<Scalars['String']['output']>;
};
export type Mutation = {
__typename?: 'Mutation';
remoteGraphQLResponse: Scalars['Boolean']['output'];
remoteMutation: Scalars['String']['output'];
remoteSession?: Maybe<Scalars['Boolean']['output']>;
sendNotification?: Maybe<Notification>;
sendPing?: Maybe<Scalars['Boolean']['output']>;
updateDashboard: Dashboard;
updateNetwork: Network;
};
export type MutationRemoteGraphQlResponseArgs = {
input: RemoteGraphQlServerInput;
};
export type MutationRemoteMutationArgs = {
input: RemoteGraphQlClientInput;
};
export type MutationRemoteSessionArgs = {
remoteAccess: RemoteAccessInput;
};
export type MutationSendNotificationArgs = {
notification: NotificationInput;
};
export type MutationUpdateDashboardArgs = {
data: DashboardInput;
};
export type MutationUpdateNetworkArgs = {
data: NetworkInput;
};
export type Network = {
__typename?: 'Network';
accessUrls?: Maybe<Array<AccessUrl>>;
};
export type NetworkInput = {
accessUrls: Array<AccessUrlInput>;
};
export type Notification = {
__typename?: 'Notification';
description?: Maybe<Scalars['String']['output']>;
importance?: Maybe<Importance>;
link?: Maybe<Scalars['String']['output']>;
status: NotificationStatus;
subject?: Maybe<Scalars['String']['output']>;
title?: Maybe<Scalars['String']['output']>;
};
export type NotificationInput = {
description?: InputMaybe<Scalars['String']['input']>;
importance: Importance;
link?: InputMaybe<Scalars['String']['input']>;
subject?: InputMaybe<Scalars['String']['input']>;
title?: InputMaybe<Scalars['String']['input']>;
};
export enum NotificationStatus {
FAILED_TO_SEND = 'FAILED_TO_SEND',
NOT_FOUND = 'NOT_FOUND',
PENDING = 'PENDING',
SENT = 'SENT'
}
export type PingEvent = {
__typename?: 'PingEvent';
data?: Maybe<Scalars['String']['output']>;
type: EventType;
};
export type PingEventData = {
__typename?: 'PingEventData';
source: PingEventSource;
};
export enum PingEventSource {
API = 'API',
MOTHERSHIP = 'MOTHERSHIP'
}
export type ProfileModel = {
__typename?: 'ProfileModel';
avatar?: Maybe<Scalars['String']['output']>;
cognito_id?: Maybe<Scalars['String']['output']>;
url?: Maybe<Scalars['String']['output']>;
userId?: Maybe<Scalars['ID']['output']>;
username?: Maybe<Scalars['String']['output']>;
};
export type Query = {
__typename?: 'Query';
apiVersion?: Maybe<Scalars['String']['output']>;
dashboard?: Maybe<Dashboard>;
ksServers: Array<KsServerDetails>;
online?: Maybe<Scalars['Boolean']['output']>;
remoteQuery: Scalars['String']['output'];
serverStatus: ServerStatusResponse;
servers: Array<Maybe<Server>>;
status?: Maybe<ServerStatus>;
};
export type QueryDashboardArgs = {
id: Scalars['String']['input'];
};
export type QueryRemoteQueryArgs = {
input: RemoteGraphQlClientInput;
};
export type QueryServerStatusArgs = {
apiKey: Scalars['String']['input'];
};
export enum RegistrationState {
/** Basic */
BASIC = 'BASIC',
/** BLACKLISTED */
EBLACKLISTED = 'EBLACKLISTED',
/** BLACKLISTED */
EBLACKLISTED1 = 'EBLACKLISTED1',
/** BLACKLISTED */
EBLACKLISTED2 = 'EBLACKLISTED2',
/** Trial Expired */
EEXPIRED = 'EEXPIRED',
/** GUID Error */
EGUID = 'EGUID',
/** Multiple License Keys Present */
EGUID1 = 'EGUID1',
/** Trial Requires Internet Connection */
ENOCONN = 'ENOCONN',
/** No Flash */
ENOFLASH = 'ENOFLASH',
ENOFLASH1 = 'ENOFLASH1',
ENOFLASH2 = 'ENOFLASH2',
ENOFLASH3 = 'ENOFLASH3',
ENOFLASH4 = 'ENOFLASH4',
ENOFLASH5 = 'ENOFLASH5',
ENOFLASH6 = 'ENOFLASH6',
ENOFLASH7 = 'ENOFLASH7',
/** No Keyfile */
ENOKEYFILE = 'ENOKEYFILE',
/** No Keyfile */
ENOKEYFILE1 = 'ENOKEYFILE1',
/** Missing key file */
ENOKEYFILE2 = 'ENOKEYFILE2',
/** Invalid installation */
ETRIAL = 'ETRIAL',
/** Plus */
PLUS = 'PLUS',
/** Pro */
PRO = 'PRO',
/** Trial */
TRIAL = 'TRIAL'
}
export type RemoteAccessEvent = {
__typename?: 'RemoteAccessEvent';
data: RemoteAccessEventData;
type: EventType;
};
/** Defines whether remote access event is the initiation (from connect) or the response (from the server) */
export enum RemoteAccessEventActionType {
ACK = 'ACK',
END = 'END',
INIT = 'INIT',
PING = 'PING'
}
export type RemoteAccessEventData = {
__typename?: 'RemoteAccessEventData';
apiKey: Scalars['String']['output'];
type: RemoteAccessEventActionType;
url?: Maybe<AccessUrl>;
};
export type RemoteAccessInput = {
apiKey: Scalars['String']['input'];
type: RemoteAccessEventActionType;
url?: InputMaybe<AccessUrlInput>;
};
export type RemoteGraphQlClientInput = {
apiKey: Scalars['String']['input'];
body: Scalars['String']['input'];
/** Time in milliseconds to wait for a response from the remote server (defaults to 15000) */
timeout?: InputMaybe<Scalars['Int']['input']>;
/** How long mothership should cache the result of this query in seconds, only valid on queries */
ttl?: InputMaybe<Scalars['Int']['input']>;
};
export type RemoteGraphQlEvent = {
__typename?: 'RemoteGraphQLEvent';
data: RemoteGraphQlEventData;
type: EventType;
};
export type RemoteGraphQlEventData = {
__typename?: 'RemoteGraphQLEventData';
/** Contains mutation / subscription / query data in the form of body: JSON, variables: JSON */
body: Scalars['String']['output'];
/** sha256 hash of the body */
sha256: Scalars['String']['output'];
type: RemoteGraphQlEventType;
};
export enum RemoteGraphQlEventType {
REMOTE_MUTATION_EVENT = 'REMOTE_MUTATION_EVENT',
REMOTE_QUERY_EVENT = 'REMOTE_QUERY_EVENT',
REMOTE_SUBSCRIPTION_EVENT = 'REMOTE_SUBSCRIPTION_EVENT',
REMOTE_SUBSCRIPTION_EVENT_PING = 'REMOTE_SUBSCRIPTION_EVENT_PING'
}
export type RemoteGraphQlServerInput = {
/** Body - contains an object containing data: (GQL response data) or errors: (GQL Errors) */
body: Scalars['String']['input'];
/** sha256 hash of the body */
sha256: Scalars['String']['input'];
type: RemoteGraphQlEventType;
};
export type Server = {
__typename?: 'Server';
apikey?: Maybe<Scalars['String']['output']>;
guid?: Maybe<Scalars['String']['output']>;
lanip?: Maybe<Scalars['String']['output']>;
localurl?: Maybe<Scalars['String']['output']>;
name?: Maybe<Scalars['String']['output']>;
owner?: Maybe<ProfileModel>;
remoteurl?: Maybe<Scalars['String']['output']>;
status?: Maybe<ServerStatus>;
wanip?: Maybe<Scalars['String']['output']>;
};
/** Defines server fields that have a TTL on them, for example last ping */
export type ServerFieldsWithTtl = {
__typename?: 'ServerFieldsWithTtl';
lastPing?: Maybe<Scalars['String']['output']>;
};
export type ServerModel = {
apikey: Scalars['String']['output'];
guid: Scalars['String']['output'];
lanip: Scalars['String']['output'];
localurl: Scalars['String']['output'];
name: Scalars['String']['output'];
remoteurl: Scalars['String']['output'];
wanip: Scalars['String']['output'];
};
export enum ServerStatus {
NEVER_CONNECTED = 'never_connected',
OFFLINE = 'offline',
ONLINE = 'online'
}
export type ServerStatusResponse = {
__typename?: 'ServerStatusResponse';
id: Scalars['ID']['output'];
lastPublish?: Maybe<Scalars['String']['output']>;
online: Scalars['Boolean']['output'];
};
export type Service = {
__typename?: 'Service';
name?: Maybe<Scalars['String']['output']>;
online?: Maybe<Scalars['Boolean']['output']>;
uptime?: Maybe<Uptime>;
version?: Maybe<Scalars['String']['output']>;
};
export type Subscription = {
__typename?: 'Subscription';
events?: Maybe<Array<Event>>;
remoteSubscription: Scalars['String']['output'];
servers: Array<Server>;
};
export type SubscriptionRemoteSubscriptionArgs = {
input: RemoteGraphQlClientInput;
};
export type TwoFactorLocal = {
__typename?: 'TwoFactorLocal';
enabled?: Maybe<Scalars['Boolean']['output']>;
};
export type TwoFactorRemote = {
__typename?: 'TwoFactorRemote';
enabled?: Maybe<Scalars['Boolean']['output']>;
};
export type TwoFactorWithToken = {
__typename?: 'TwoFactorWithToken';
local?: Maybe<TwoFactorLocal>;
remote?: Maybe<TwoFactorRemote>;
token?: Maybe<Scalars['String']['output']>;
};
export type TwoFactorWithoutToken = {
__typename?: 'TwoFactorWithoutToken';
local?: Maybe<TwoFactorLocal>;
remote?: Maybe<TwoFactorRemote>;
};
export enum UrlType {
DEFAULT = 'DEFAULT',
LAN = 'LAN',
MDNS = 'MDNS',
WAN = 'WAN',
WIREGUARD = 'WIREGUARD'
}
export type UpdateEvent = {
__typename?: 'UpdateEvent';
data: UpdateEventData;
type: EventType;
};
export type UpdateEventData = {
__typename?: 'UpdateEventData';
apiKey: Scalars['String']['output'];
type: UpdateType;
};
export enum UpdateType {
DASHBOARD = 'DASHBOARD',
NETWORK = 'NETWORK'
}
export type Uptime = {
__typename?: 'Uptime';
timestamp?: Maybe<Scalars['String']['output']>;
};
export type UserProfileModelWithServers = {
__typename?: 'UserProfileModelWithServers';
profile: ProfileModel;
servers: Array<Server>;
};
export type Vars = {
__typename?: 'Vars';
expireTime?: Maybe<Scalars['DateTime']['output']>;
flashGuid?: Maybe<Scalars['String']['output']>;
regState?: Maybe<RegistrationState>;
regTm2?: Maybe<Scalars['String']['output']>;
regTy?: Maybe<Scalars['String']['output']>;
};
export type RemoteGraphQlEventFragmentFragment = { __typename?: 'RemoteGraphQLEvent', remoteGraphQLEventData: { __typename?: 'RemoteGraphQLEventData', type: RemoteGraphQlEventType, body: string, sha256: string } } & { ' $fragmentName'?: 'RemoteGraphQlEventFragmentFragment' };
export type EventsSubscriptionVariables = Exact<{ [key: string]: never; }>;
export type EventsSubscription = { __typename?: 'Subscription', events?: Array<
| { __typename: 'ClientConnectedEvent', connectedEvent: EventType, connectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } }
| { __typename: 'ClientDisconnectedEvent', disconnectedEvent: EventType, disconnectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } }
| { __typename: 'ClientPingEvent' }
| { __typename: 'RemoteAccessEvent' }
| (
{ __typename: 'RemoteGraphQLEvent' }
& { ' $fragmentRefs'?: { 'RemoteGraphQlEventFragmentFragment': RemoteGraphQlEventFragmentFragment } }
)
| { __typename: 'UpdateEvent' }
> | null };
export type SendRemoteGraphQlResponseMutationVariables = Exact<{
input: RemoteGraphQlServerInput;
}>;
export type SendRemoteGraphQlResponseMutation = { __typename?: 'Mutation', remoteGraphQLResponse: boolean };
export const RemoteGraphQlEventFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RemoteGraphQLEventFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteGraphQLEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"remoteGraphQLEventData"},"name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"sha256"}}]}}]}}]} as unknown as DocumentNode<RemoteGraphQlEventFragmentFragment, unknown>;
export const EventsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ClientConnectedEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"connectedData"},"name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"connectedEvent"},"name":{"kind":"Name","value":"type"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ClientDisconnectedEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"disconnectedData"},"name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"disconnectedEvent"},"name":{"kind":"Name","value":"type"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"RemoteGraphQLEventFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RemoteGraphQLEventFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteGraphQLEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"remoteGraphQLEventData"},"name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"sha256"}}]}}]}}]} as unknown as DocumentNode<EventsSubscription, EventsSubscriptionVariables>;
export const SendRemoteGraphQlResponseDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"sendRemoteGraphQLResponse"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteGraphQLServerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"remoteGraphQLResponse"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<SendRemoteGraphQlResponseMutation, SendRemoteGraphQlResponseMutationVariables>;

View File

@@ -1,2 +0,0 @@
export * from "./fragment-masking.js";
export * from "./gql.js";

View File

@@ -1,8 +0,0 @@
// Import from the generated directory
import { graphql } from './generated/client/gql.js';
export const SEND_REMOTE_QUERY_RESPONSE = graphql(/* GraphQL */ `
mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {
remoteGraphQLResponse(input: $input)
}
`);

View File

@@ -1,22 +0,0 @@
import { type DelayFunctionOptions } from '@apollo/client/link/retry/delayFunction.js';
export function buildDelayFunction(delayOptions?: DelayFunctionOptions): (count: number) => number {
const { initial = 10_000, jitter = true, max = Infinity } = delayOptions ?? {};
// If we're jittering, baseDelay is half of the maximum delay for that
// attempt (and is, on average, the delay we will encounter).
// If we're not jittering, adjust baseDelay so that the first attempt
// lines up with initialDelay, for everyone's sanity.
const baseDelay = jitter ? initial : initial / 2;
return (count: number) => {
let delay = Math.min(max, baseDelay * 2 ** count);
if (jitter) {
// We opt for a full jitter approach for a mostly uniform distribution,
// but bound it within initialDelay and delay for everyone's sanity.
delay = Math.random() * delay;
}
return Math.round(delay);
};
}

View File

@@ -1,8 +0,0 @@
// Names for magic numbers & constants, that are not domain specific.
export const ONE_MINUTE_MS = 60 * 1000;
export const THREE_MINUTES_MS = 3 * ONE_MINUTE_MS;
export const ONE_MINUTE_SECS = 60;
export const ONE_HOUR_SECS = 60 * 60;
export const ONE_DAY_SECS = 24 * ONE_HOUR_SECS;
export const FIVE_DAYS_SECS = 5 * ONE_DAY_SECS;

View File

@@ -1,15 +0,0 @@
// NestJS tokens.
// Strings & Symbols used to identify jobs, services, events, etc.
export const UPNP_RENEWAL_JOB_TOKEN = 'upnp-renewal';
export { GRAPHQL_PUBSUB_TOKEN, GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js';
export enum EVENTS {
LOGIN = 'connect.login',
LOGOUT = 'connect.logout',
IDENTITY_CHANGED = 'connect.identity.changed',
MOTHERSHIP_CONNECTION_STATUS_CHANGED = 'connect.mothership.changed',
ENABLE_WAN_ACCESS = 'connect.wanAccess.enable',
DISABLE_WAN_ACCESS = 'connect.wanAccess.disable',
}

View File

@@ -1,21 +0,0 @@
import { gql, QueryOptions } from '@apollo/client/core/index.js';
interface ParsedQuery {
query?: string;
variables?: Record<string, string>;
}
export const parseGraphQLQuery = (body: string): QueryOptions => {
try {
const parsedBody: ParsedQuery = JSON.parse(body);
if (parsedBody.query && parsedBody.variables && typeof parsedBody.variables === 'object') {
return {
query: gql(parsedBody.query),
variables: parsedBody.variables,
};
}
throw new Error('Invalid Body');
} catch (error) {
throw new Error('Invalid Body Provided');
}
};

View File

@@ -1,30 +0,0 @@
import { Inject, Logger, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConnectConfigPersister } from './config/config.persistence.js';
import { configFeature } from './config/connect.config.js';
import { MothershipModule } from './mothership-proxy/mothership.module.js';
import { ConnectModule } from './unraid-connect/connect.module.js';
export const adapter = 'nestjs';
/**
* When the plugin is installed we expose the full Nest module graph.
* Configuration and proxy submodules only bootstrap in this branch.
*/
@Module({
imports: [ConfigModule.forFeature(configFeature), ConnectModule, MothershipModule],
providers: [ConnectConfigPersister],
exports: [],
})
class ConnectPluginModule {
logger = new Logger(ConnectPluginModule.name);
constructor(@Inject(ConfigService) private readonly configService: ConfigService) {}
onModuleInit() {
this.logger.log('Connect plugin initialized with %o', this.configService.get('connect'));
}
}
export const ApiModule = ConnectPluginModule;

View File

@@ -1,241 +0,0 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { OutgoingHttpHeaders } from 'node:http2';
import { isEqual } from 'lodash-es';
import { Subscription } from 'rxjs';
import { debounceTime, filter } from 'rxjs/operators';
import { ConnectionMetadata, MinigraphStatus } from '../config/connect.config.js';
import { EVENTS } from '../helper/nest-tokens.js';
interface MothershipWebsocketHeaders extends OutgoingHttpHeaders {
'x-api-key': string;
'x-flash-guid': string;
'x-unraid-api-version': string;
'x-unraid-server-version': string;
'User-Agent': string;
}
enum ClientType {
API = 'API',
DASHBOARD = 'DASHBOARD',
}
interface MothershipConnectionParams extends Record<string, unknown> {
clientType: ClientType;
apiKey: string;
flashGuid: string;
apiVersion: string;
unraidVersion: string;
}
interface IdentityState {
unraidVersion: string;
flashGuid: string;
apiKey: string;
apiVersion: string;
}
type ConnectionStatus =
| {
status: MinigraphStatus.CONNECTED | MinigraphStatus.CONNECTING | MinigraphStatus.PRE_INIT;
error: null;
}
| {
status: MinigraphStatus.ERROR_RETRYING | MinigraphStatus.PING_FAILURE;
error: string;
};
@Injectable()
export class MothershipConnectionService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MothershipConnectionService.name);
private readonly configKeys = {
unraidVersion: 'store.emhttp.var.version',
flashGuid: 'store.emhttp.var.flashGuid',
apiVersion: 'API_VERSION',
apiKey: 'connect.config.apikey',
};
private identitySubscription: Subscription | null = null;
private lastIdentity: Partial<IdentityState> | null = null;
private metadataChangedSubscription: Subscription | null = null;
constructor(
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2
) {}
private updateMetadata(data: Partial<ConnectionMetadata>) {
this.configService.set('connect.mothership', {
...this.configService.get<ConnectionMetadata>('connect.mothership'),
...data,
});
}
private setMetadata(data: ConnectionMetadata) {
this.configService.set('connect.mothership', data);
}
private setupIdentitySubscription() {
if (this.identitySubscription) {
this.identitySubscription.unsubscribe();
}
this.identitySubscription = this.configService.changes$
.pipe(
filter((change) => Object.values(this.configKeys).includes(change.path)),
// debouncing is necessary here (instead of buffering/batching) to prevent excess emissions
// because the store.* config values will change frequently upon api boot
debounceTime(25)
)
.subscribe({
next: () => {
const { state } = this.getIdentityState();
if (isEqual(state, this.lastIdentity)) {
this.logger.debug('Identity unchanged; skipping event emission');
return;
}
this.lastIdentity = structuredClone(state);
const success = this.eventEmitter.emit(EVENTS.IDENTITY_CHANGED);
if (success) {
this.logger.debug('Emitted IDENTITY_CHANGED event');
} else {
this.logger.warn('Failed to emit IDENTITY_CHANGED event');
}
},
error: (err) => {
this.logger.error('Error in identity state subscription: %o', err);
},
});
}
private setupMetadataChangedEvent() {
if (this.metadataChangedSubscription) {
this.metadataChangedSubscription.unsubscribe();
}
this.metadataChangedSubscription = this.configService.changes$
.pipe(filter((change) => change.path.startsWith('connect.mothership')))
.subscribe({
next: () => {
const success = this.eventEmitter.emit(EVENTS.MOTHERSHIP_CONNECTION_STATUS_CHANGED);
if (!success) {
this.logger.warn('Failed to emit METADATA_CHANGED event');
}
},
error: (err) => {
this.logger.error('Error in metadata changed subscription: %o', err);
},
});
}
async onModuleInit() {
// Warn on startup if these config values are not set initially
const { unraidVersion, flashGuid, apiVersion } = this.configKeys;
const warnings: string[] = [];
[unraidVersion, flashGuid, apiVersion].forEach((key) => {
try {
this.configService.getOrThrow(key);
} catch (error) {
warnings.push(`${key} is not set`);
}
});
if (warnings.length > 0) {
this.logger.warn('Missing config values: %s', warnings.join(', '));
}
// Setup IDENTITY_CHANGED & METADATA_CHANGED events
this.setupIdentitySubscription();
this.setupMetadataChangedEvent();
}
async onModuleDestroy() {
if (this.identitySubscription) {
this.identitySubscription.unsubscribe();
this.identitySubscription = null;
}
if (this.metadataChangedSubscription) {
this.metadataChangedSubscription.unsubscribe();
this.metadataChangedSubscription = null;
}
}
getApiKey() {
return this.configService.get<string>(this.configKeys.apiKey);
}
/**
* Fetches the current identity state directly from ConfigService.
*/
getIdentityState():
| { state: IdentityState; isLoaded: true }
| { state: Partial<IdentityState>; isLoaded: false } {
const state = {
unraidVersion: this.configService.get<string>(this.configKeys.unraidVersion),
flashGuid: this.configService.get<string>(this.configKeys.flashGuid),
apiVersion: this.configService.get<string>(this.configKeys.apiVersion),
apiKey: this.configService.get<string>(this.configKeys.apiKey),
};
const isLoaded = Object.values(state).every(Boolean);
return isLoaded ? { state: state as IdentityState, isLoaded: true } : { state, isLoaded: false };
}
getMothershipWebsocketHeaders(): OutgoingHttpHeaders | MothershipWebsocketHeaders {
const { isLoaded, state } = this.getIdentityState();
if (!isLoaded) {
this.logger.debug('Incomplete identity state; cannot create websocket headers: %o', state);
return {};
}
return {
'x-api-key': state.apiKey,
'x-flash-guid': state.flashGuid,
'x-unraid-api-version': state.apiVersion,
'x-unraid-server-version': state.unraidVersion,
'User-Agent': `unraid-api/${state.apiVersion}`,
} satisfies MothershipWebsocketHeaders;
}
getWebsocketConnectionParams(): MothershipConnectionParams | Record<string, unknown> {
const { isLoaded, state } = this.getIdentityState();
if (!isLoaded) {
this.logger.debug(
'Incomplete identity state; cannot create websocket connection params: %o',
state
);
return {};
}
return {
clientType: ClientType.API,
...state,
} satisfies MothershipConnectionParams;
}
getConnectionState() {
const state = this.configService.get<ConnectionMetadata>('connect.mothership');
if (!state) {
this.logger.error(
'connect.mothership config is not present! Preventing fatal crash; mothership is in Error state.'
);
}
return state;
}
setConnectionStatus({ status, error }: ConnectionStatus) {
this.updateMetadata({ status, error });
}
resetMetadata() {
this.setMetadata({ status: MinigraphStatus.PRE_INIT });
}
receivePing() {
this.updateMetadata({ lastPing: Date.now() });
}
clearDisconnectedTimestamp() {
return this.updateMetadata({ selfDisconnectedSince: null });
}
setDisconnectedTimestamp() {
return this.updateMetadata({ selfDisconnectedSince: Date.now() });
}
}

View File

@@ -1,344 +0,0 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
ApolloClient,
ApolloLink,
InMemoryCache,
NormalizedCacheObject,
Observable,
} from '@apollo/client/core/index.js';
import { ErrorLink } from '@apollo/client/link/error/index.js';
import { RetryLink } from '@apollo/client/link/retry/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { Client, createClient } from 'graphql-ws';
import { WebSocket } from 'ws';
import { MinigraphStatus } from '../config/connect.config.js';
import { RemoteGraphQlEventType } from '../graphql/generated/client/graphql.js';
import { SEND_REMOTE_QUERY_RESPONSE } from '../graphql/remote-response.js';
import { buildDelayFunction } from '../helper/delay-function.js';
import { EVENTS } from '../helper/nest-tokens.js';
import { MothershipConnectionService } from './connection.service.js';
const FIVE_MINUTES_MS = 5 * 60 * 1000;
type Unsubscribe = () => void;
@Injectable()
export class MothershipGraphqlClientService implements OnModuleInit, OnModuleDestroy {
private logger = new Logger(MothershipGraphqlClientService.name);
private apolloClient: ApolloClient<NormalizedCacheObject> | null = null;
private wsClient: Client | null = null;
private delayFn = buildDelayFunction({
jitter: true,
max: FIVE_MINUTES_MS,
initial: 10_000,
});
private isStateValid = () => this.connectionService.getIdentityState().isLoaded;
private disposalQueue: Unsubscribe[] = [];
get apiVersion() {
return this.configService.getOrThrow('API_VERSION');
}
get mothershipGraphqlLink() {
return this.configService.getOrThrow('MOTHERSHIP_GRAPHQL_LINK');
}
constructor(
private readonly configService: ConfigService,
private readonly connectionService: MothershipConnectionService,
private readonly eventEmitter: EventEmitter2
) {}
/**
* Initialize the GraphQL client when the module is created
*/
async onModuleInit(): Promise<void> {
this.configService.getOrThrow('API_VERSION');
this.configService.getOrThrow('MOTHERSHIP_GRAPHQL_LINK');
}
/**
* Clean up resources when the module is destroyed
*/
async onModuleDestroy(): Promise<void> {
await this.clearInstance();
}
async sendQueryResponse(sha256: string, body: { data?: unknown; errors?: unknown }) {
try {
const result = await this.getClient()?.mutate({
mutation: SEND_REMOTE_QUERY_RESPONSE,
variables: {
input: {
sha256,
body: JSON.stringify(body),
type: RemoteGraphQlEventType.REMOTE_QUERY_EVENT,
},
},
});
return result;
} catch (error) {
this.logger.error(
'Failed to send query response to mothership. %s %O\n%O',
sha256,
error,
body
);
}
}
/**
* Get the Apollo client instance (if possible given loaded state)
* @returns ApolloClient instance or null, if state is not valid
*/
getClient(): ApolloClient<NormalizedCacheObject> | null {
if (this.isStateValid()) {
return this.apolloClient;
}
this.logger.debug('Identity state is not valid. Returning null client instance');
return null;
}
/**
* Create a new Apollo client instance if one doesn't exist and state is valid
*/
async createClientInstance(): Promise<ApolloClient<NormalizedCacheObject>> {
return this.getClient() ?? this.createGraphqlClient();
}
/**
* Clear the Apollo client instance and WebSocket client
*/
async clearInstance(): Promise<void> {
if (this.apolloClient) {
try {
await this.apolloClient.clearStore();
// some race condition causes apolloClient to be null here upon api shutdown?
this.apolloClient?.stop();
} catch (error) {
this.logger.warn(error, 'Error clearing apolloClient');
}
this.apolloClient = null;
}
if (this.wsClient) {
this.clearClientEventHandlers();
try {
await this.wsClient.dispose();
} catch (error) {
this.logger.warn(error, 'Error disposing of wsClient');
}
this.wsClient = null;
}
this.logger.verbose('Cleared GraphQl client & instance');
}
/**
* Create a new Apollo client with WebSocket link
*/
private createGraphqlClient(): ApolloClient<NormalizedCacheObject> {
this.logger.verbose('Creating a new Apollo Client Instance');
this.wsClient = createClient({
url: this.mothershipGraphqlLink.replace('http', 'ws'),
webSocketImpl: this.getWebsocketWithMothershipHeaders(),
connectionParams: () => this.connectionService.getWebsocketConnectionParams(),
});
const wsLink = new GraphQLWsLink(this.wsClient);
const { appErrorLink, retryLink, errorLink } = this.createApolloLinks();
const apolloClient = new ApolloClient({
link: ApolloLink.from([appErrorLink, retryLink, errorLink, wsLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'all',
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all',
},
},
});
this.initEventHandlers();
this.apolloClient = apolloClient;
return this.apolloClient;
}
/**
* Create a WebSocket class with Mothership headers
*/
private getWebsocketWithMothershipHeaders() {
const getHeaders = () => this.connectionService.getMothershipWebsocketHeaders();
return class WebsocketWithMothershipHeaders extends WebSocket {
constructor(address: string | URL, protocols?: string | string[]) {
super(address, protocols, {
headers: getHeaders(),
});
}
};
}
/**
* Check if an error is an invalid API key error
*/
private isInvalidApiKeyError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string' &&
error.message.includes('API Key Invalid')
);
}
/**
* Create Apollo links for error handling and retries
*/
private createApolloLinks() {
/** prevents errors from bubbling beyond this link/apollo instance & potentially crashing the api */
const appErrorLink = new ApolloLink((operation, forward) => {
return new Observable((observer) => {
forward(operation).subscribe({
next: (result) => observer.next(result),
error: (error) => {
this.logger.warn('Apollo error, will not retry: %s', error?.message);
observer.complete();
},
complete: () => observer.complete(),
});
});
});
/**
* Max # of times to retry authenticating with mothership.
* Total # of attempts will be retries + 1.
*/
const MAX_AUTH_RETRIES = 3;
const retryLink = new RetryLink({
delay: (count, operation, error) => {
const getDelay = this.delayFn(count);
operation.setContext({ retryCount: count });
// note: unsure where/whether
// store.dispatch(setMothershipTimeout(getDelay));
this.configService.set('connect.mothership.timeout', getDelay);
this.logger.log('Delay currently is: %i', getDelay);
return getDelay;
},
attempts: {
max: Infinity,
retryIf: (error, operation) => {
const { retryCount = 0 } = operation.getContext();
// i.e. retry api key errors up to 3 times (4 attempts total)
return !this.isInvalidApiKeyError(error) || retryCount < MAX_AUTH_RETRIES;
},
},
});
const errorLink = new ErrorLink((handler) => {
const { retryCount = 0 } = handler.operation.getContext();
this.logger.debug(`Operation attempt: #${retryCount}`);
if (handler.graphQLErrors) {
this.logger.log('GQL Error Encountered %o', handler.graphQLErrors);
} else if (handler.networkError) {
/**----------------------------------------------
* Handling of Network Errors
*
* When the handler has a void return,
* the network error will bubble up
* (i.e. left in the `ApolloLink.from` array).
*
* The underlying operation/request
* may be retried per the retry link.
*
* If the error is not retried, it will bubble
* into the appErrorLink and terminate there.
*---------------------------------------------**/
this.logger.error(handler.networkError, 'Network Error');
const error = handler.networkError;
if (error?.message?.includes('to be an array of GraphQL errors, but got')) {
this.logger.warn('detected malformed graphql error in websocket message');
}
if (this.isInvalidApiKeyError(error)) {
if (retryCount >= MAX_AUTH_RETRIES) {
this.eventEmitter.emit(EVENTS.LOGOUT, {
reason: 'Invalid API Key on Mothership',
});
}
} else if (
this.connectionService.getConnectionState()?.status !==
MinigraphStatus.ERROR_RETRYING
) {
this.connectionService.setConnectionStatus({
status: MinigraphStatus.ERROR_RETRYING,
error: handler.networkError.message,
});
}
}
});
return { appErrorLink, retryLink, errorLink } as const;
}
/**
* Initialize event handlers for the GraphQL client WebSocket connection
*/
private initEventHandlers(): void {
if (!this.wsClient) return;
const disposeConnecting = this.wsClient.on('connecting', () => {
this.connectionService.setConnectionStatus({
status: MinigraphStatus.CONNECTING,
error: null,
});
this.logger.log('Connecting to %s', this.mothershipGraphqlLink.replace('http', 'ws'));
});
const disposeError = this.wsClient.on('error', (err) => {
this.logger.error('GraphQL Client Error: %o', err);
});
const disposeConnected = this.wsClient.on('connected', () => {
this.connectionService.setConnectionStatus({
status: MinigraphStatus.CONNECTED,
error: null,
});
this.logger.log('Connected to %s', this.mothershipGraphqlLink.replace('http', 'ws'));
});
const disposePing = this.wsClient.on('ping', () => {
this.logger.verbose('ping');
this.connectionService.receivePing();
});
this.disposalQueue.push(disposeConnecting, disposeConnected, disposePing, disposeError);
}
/**
* Clear event handlers from the GraphQL client WebSocket connection
*/
private clearClientEventHandlers(
events: Array<'connected' | 'connecting' | 'error' | 'ping'> = [
'connected',
'connecting',
'error',
'ping',
]
): void {
if (!this.wsClient) return;
while (this.disposalQueue.length > 0) {
const dispose = this.disposalQueue.shift();
dispose?.();
}
}
}

View File

@@ -1,158 +0,0 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'class-validator';
import { type Subscription } from 'zen-observable-ts';
import { CANONICAL_INTERNAL_CLIENT_TOKEN, type CanonicalInternalClientService } from '@unraid/shared';
import { EVENTS_SUBSCRIPTION, RemoteGraphQL_Fragment } from '../graphql/event.js';
import {
ClientType,
RemoteGraphQlEventFragmentFragment,
RemoteGraphQlEventType,
} from '../graphql/generated/client/graphql.js';
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 { UnraidServerClientService } from './unraid-server-client.service.js';
interface SubscriptionInfo {
sha256: string;
createdAt: number;
lastPing: number;
operationId?: string;
}
@Injectable()
export class MothershipSubscriptionHandler {
constructor(
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
private readonly internalClientService: CanonicalInternalClientService,
private readonly mothershipClient: UnraidServerClientService,
private readonly connectionService: MothershipConnectionService
) {}
private readonly logger = new Logger(MothershipSubscriptionHandler.name);
private readonly activeSubscriptions = new Map<string, SubscriptionInfo>();
removeSubscription(sha256: string) {
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 ${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 }) {
const now = Date.now();
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);
}
} else {
this.logger.verbose(`No stale subscriptions found (${this.activeSubscriptions.size} active)`);
}
}
pingSubscription(sha256: string) {
const subscription = this.activeSubscriptions.get(sha256);
if (subscription) {
subscription.lastPing = Date.now();
this.logger.verbose(`Updated ping for subscription ${sha256}`);
} else {
this.logger.verbose(`Ping for unknown subscription ${sha256}`);
}
}
addSubscription(sha256: string, operationId?: string) {
const now = Date.now();
const subscription: SubscriptionInfo = {
sha256,
createdAt: now,
lastPing: now,
operationId
};
this.activeSubscriptions.set(sha256, subscription);
this.logger.debug(`Added subscription ${sha256} ${operationId ? `with operationId: ${operationId}` : ''}`);
}
stopMothershipSubscription() {
this.logger.verbose('Stopping mothership subscription (not implemented yet)');
}
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');
}
}
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' },
},
],
};
}
}
}

View File

@@ -1,61 +0,0 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js';
import { MothershipConnectionService } from './connection.service.js';
import { UnraidServerClientService } from './unraid-server-client.service.js';
import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js';
/**
* Controller for (starting and stopping) the mothership stack:
* - 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)
*/
@Injectable()
export class MothershipController implements OnModuleDestroy, OnApplicationBootstrap {
private readonly logger = new Logger(MothershipController.name);
constructor(
private readonly clientService: UnraidServerClientService,
private readonly connectionService: MothershipConnectionService,
private readonly subscriptionHandler: MothershipSubscriptionHandler,
private readonly timeoutCheckerJob: TimeoutCheckerJob
) {}
async onModuleDestroy() {
await this.stop();
}
async onApplicationBootstrap() {
await this.initOrRestart();
}
/**
* Stops the mothership stack. Throws on first error.
*/
async stop() {
this.timeoutCheckerJob.stop();
this.subscriptionHandler.stopMothershipSubscription();
if (this.clientService.getClient()) {
this.clientService.getClient()?.disconnect();
}
this.connectionService.resetMetadata();
this.subscriptionHandler.clearAllSubscriptions();
}
/**
* Attempts to stop, then starts the mothership stack. Throws on first error.
*/
async initOrRestart() {
await this.stop();
const identityState = this.connectionService.getIdentityState();
this.logger.verbose('cleared, got identity state');
if (!identityState.isLoaded || !identityState.state.apiKey) {
this.logger.warn('No API key found; cannot setup mothership connection');
return;
}
await this.clientService.reconnect();
await this.subscriptionHandler.subscribeToMothershipEvents();
this.timeoutCheckerJob.start();
}
}

View File

@@ -1,62 +0,0 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PubSub } from 'graphql-subscriptions';
import { MinigraphStatus } from '../config/connect.config.js';
import { EVENTS, GRAPHQL_PUBSUB_CHANNEL, GRAPHQL_PUBSUB_TOKEN } from '../helper/nest-tokens.js';
import { MothershipConnectionService } from './connection.service.js';
import { MothershipController } from './mothership.controller.js';
@Injectable()
export class MothershipHandler {
private readonly logger = new Logger(MothershipHandler.name);
constructor(
private readonly connectionService: MothershipConnectionService,
private readonly mothershipController: MothershipController,
@Inject(GRAPHQL_PUBSUB_TOKEN)
private readonly legacyPubSub: PubSub
) {}
@OnEvent(EVENTS.IDENTITY_CHANGED, { async: true })
async onIdentityChanged() {
const { state } = this.connectionService.getIdentityState();
if (state.apiKey) {
this.logger.verbose('Identity changed; setting up mothership subscription');
await this.mothershipController.initOrRestart();
}
}
@OnEvent(EVENTS.MOTHERSHIP_CONNECTION_STATUS_CHANGED, { async: true })
async onMothershipConnectionStatusChanged() {
const state = this.connectionService.getConnectionState();
if (
state &&
[MinigraphStatus.PING_FAILURE].includes(state.status)
) {
this.logger.verbose(
'Mothership connection status changed to %s; setting up mothership subscription',
state.status
);
await this.mothershipController.initOrRestart();
}
}
/**
* First listener triggered when the user logs out.
*
* It publishes the 'servers' and 'owner' endpoints to the pubsub event bus.
*
* @param reason - The reason for the logout.
*/
@OnEvent(EVENTS.LOGOUT, { async: true, prependListener: true })
async logout({ reason }: { reason?: string }) {
this.logger.log('Logging out user: %s', reason ?? 'No reason provided');
// publish to the 'servers' and 'owner' endpoints
await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.SERVERS, { servers: [] });
await this.legacyPubSub.publish(GRAPHQL_PUBSUB_CHANNEL.OWNER, {
owner: { username: 'root', url: '', avatar: '' },
});
await this.mothershipController.stop();
}
}

View File

@@ -1,30 +0,0 @@
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 { 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,
LocalGraphQLExecutor,
UnraidServerClientService,
MothershipHandler,
MothershipSubscriptionHandler,
TimeoutCheckerJob,
CloudService,
CloudResolver,
MothershipController,
],
exports: [],
})
export class MothershipModule {}

View File

@@ -1,18 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { execa } from 'execa';
@Injectable()
export class DnsService {
private readonly logger = new Logger(DnsService.name);
async update() {
try {
await execa('/usr/bin/php', ['/usr/local/emhttp/plugins/dynamix/include/UpdateDNS.php']);
return true;
} catch (err: unknown) {
this.logger.warn('Failed to call Update DNS with error: ', err);
return false;
}
}
}

View File

@@ -1,30 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConnectConfigService } from '../config/connect.config.service.js';
import { DnsService } from './dns.service.js';
import { NetworkResolver } from './network.resolver.js';
import { NetworkService } from './network.service.js';
import { UpnpService } from './upnp.service.js';
import { UrlResolverService } from './url-resolver.service.js';
@Module({
imports: [ConfigModule],
providers: [
NetworkService,
NetworkResolver,
UpnpService,
UrlResolverService,
DnsService,
ConnectConfigService,
],
exports: [
NetworkService,
NetworkResolver,
UpnpService,
UrlResolverService,
DnsService,
ConnectConfigService,
],
})
export class NetworkModule {}

View File

@@ -1,37 +0,0 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { AccessUrl } from '@unraid/shared/network.model.js';
import {
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { Network } from '../unraid-connect/connect.model.js';
import { UrlResolverService } from './url-resolver.service.js';
@Resolver(() => Network)
export class NetworkResolver {
constructor(private readonly urlResolverService: UrlResolverService) {}
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.NETWORK,
})
@Query(() => Network)
public async network(): Promise<Network> {
return {
id: 'network',
};
}
@ResolveField(() => [AccessUrl])
public async accessUrls(): Promise<AccessUrl[]> {
const ips = this.urlResolverService.getServerIps();
return ips.urls.map((url) => ({
type: url.type,
name: url.name,
ipv4: url.ipv4,
ipv6: url.ipv6,
}));
}
}

View File

@@ -1,49 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { NginxService } from '@unraid/shared/services/nginx.js';
import { NGINX_SERVICE_TOKEN } from '@unraid/shared/tokens.js';
import { ConnectConfigService } from '../config/connect.config.service.js';
import { DnsService } from './dns.service.js';
import { UrlResolverService } from './url-resolver.service.js';
@Injectable()
export class NetworkService {
constructor(
@Inject(NGINX_SERVICE_TOKEN)
private readonly nginxService: NginxService,
private readonly dnsService: DnsService,
private readonly urlResolverService: UrlResolverService,
private readonly connectConfigService: ConnectConfigService
) {}
async reloadNetworkStack() {
await this.nginxService.reload();
await this.dnsService.update();
}
/**
* Returns the set of origins allowed to access the Unraid API
*/
getAllowedOrigins(): string[] {
const sink = [
...this.urlResolverService.getAllowedLocalAccessUrls(),
...this.urlResolverService.getAllowedServerIps(),
...this.connectConfigService.getExtraOrigins(),
...this.connectConfigService.getSandboxOrigins(),
/**----------------------
* Connect Origins
*------------------------**/
'https://connect.myunraid.net',
'https://connect-staging.myunraid.net',
'https://dev-my.myunraid.net:4000',
/**----------------------
* Allowed Sockets
*------------------------**/
'/var/run/unraid-notifications.sock', // Notifier bridge
'/var/run/unraid-php.sock', // Unraid PHP scripts
'/var/run/unraid-cli.sock', // CLI
].map((origin) => (origin.endsWith('/') ? origin.slice(0, -1) : origin));
return [...new Set(sink)];
}
}

View File

@@ -1,199 +0,0 @@
import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
import type { Client, Mapping } from '@runonflux/nat-upnp';
import { UPNP_CLIENT_TOKEN } from '@unraid/shared/tokens.js';
import { isDefined } from 'class-validator';
import { ConfigType } from '../config/connect.config.js';
import { ONE_HOUR_SECS } from '../helper/generic-consts.js';
import { UPNP_RENEWAL_JOB_TOKEN } from '../helper/nest-tokens.js';
@Injectable()
export class UpnpService implements OnModuleDestroy {
private readonly logger = new Logger(UpnpService.name);
#enabled = false;
#wanPort: number | undefined;
#localPort: number | undefined;
constructor(
private readonly configService: ConfigService<ConfigType>,
@Inject(UPNP_CLIENT_TOKEN) private readonly upnpClient: Client,
private readonly scheduleRegistry: SchedulerRegistry
) {}
get enabled() {
return this.#enabled;
}
get wanPort() {
return this.#wanPort;
}
get localPort() {
return this.#localPort;
}
get renewalJob(): ReturnType<typeof this.scheduleRegistry.getCronJob> {
return this.scheduleRegistry.getCronJob(UPNP_RENEWAL_JOB_TOKEN);
}
async onModuleDestroy() {
await this.disableUpnp();
}
private async removeUpnpMapping() {
if (isDefined(this.#wanPort) && isDefined(this.#localPort)) {
const portMap = {
public: this.#wanPort,
private: this.#localPort,
};
try {
const result = await this.upnpClient.removeMapping(portMap);
this.logger.log('UPNP Mapping removed %o', portMap);
this.logger.debug('UPNP Mapping removal result %O', result);
} catch (error) {
this.logger.warn('UPNP Mapping removal failed %O', error);
}
} else {
this.logger.warn('UPNP Mapping removal failed. Missing ports: %o', {
wanPort: this.#wanPort,
localPort: this.#localPort,
});
}
}
/**
* Attempts to create a UPNP lease/mapping using the given ports. Logs result.
* - Modifies `#enabled`, `#wanPort`, and `#localPort` state upon success. Does not modify upon failure.
* @param opts
* @returns true if operation succeeds.
*/
private async createUpnpMapping(opts?: {
publicPort?: number;
privatePort?: number;
serverName?: string;
}) {
const {
publicPort = this.#wanPort,
privatePort = this.#localPort,
serverName = this.configService.get('connect.config.serverName', 'No server name found'),
} = opts ?? {};
if (isDefined(publicPort) && isDefined(privatePort)) {
const upnpOpts = {
public: publicPort,
private: privatePort,
description: `Unraid Remote Access - ${serverName}`,
ttl: ONE_HOUR_SECS,
};
try {
const result = await this.upnpClient.createMapping(upnpOpts);
this.logger.log('UPNP Mapping created %o', upnpOpts);
this.logger.debug('UPNP Mapping creation result %O', result);
this.#wanPort = upnpOpts.public;
this.#localPort = upnpOpts.private;
this.#enabled = true;
return true;
} catch (error) {
this.logger.warn('UPNP Mapping creation failed %O', error);
}
} else {
this.logger.warn('UPNP Mapping creation failed. Missing ports: %o', {
publicPort,
privatePort,
});
}
}
private async getMappings() {
try {
const mappings = await this.upnpClient.getMappings();
return mappings;
} catch (error) {
this.logger.warn('Mapping retrieval failed %O', error);
}
}
private async findAvailableWanPort(args?: {
mappings?: Mapping[];
minPort?: number;
maxPort?: number;
maxAttempts?: number;
}): Promise<number | undefined> {
const {
mappings = await this.getMappings(),
minPort = 35_000,
maxPort = 65_000,
maxAttempts = 50,
} = args ?? {};
const excludedPorts = new Set(mappings?.map((val) => val.public.port) ?? []);
// Generate a random port between minPort and maxPort up to maxAttempts times
for (let i = 0; i < maxAttempts; i++) {
const port = Math.floor(Math.random() * (maxPort - minPort + 1)) + minPort;
if (!excludedPorts.has(port)) {
return port;
}
}
}
private async getWanPortToUse(args?: { wanPort?: number }) {
if (!args) return this.#wanPort;
if (args.wanPort) return args.wanPort;
const newWanPort = await this.findAvailableWanPort();
if (!newWanPort) {
this.logger.warn('Could not find an available WAN port!');
}
return newWanPort;
}
async createOrRenewUpnpLease(args?: { localPort?: number; wanPort?: number }) {
const { localPort, wanPort } = args ?? {};
const newWanOrLocalPort = wanPort !== this.#wanPort || localPort !== this.#localPort;
const upnpWasInitialized = this.#wanPort && this.#localPort;
// remove old mapping when new ports are requested
if (upnpWasInitialized && newWanOrLocalPort) {
await this.removeUpnpMapping();
}
// get new ports to use
const wanPortToUse = await this.getWanPortToUse(args);
const localPortToUse = localPort ?? this.#localPort;
if (!wanPortToUse || !localPortToUse) {
await this.disableUpnp();
this.logger.error('No WAN port found %o. Disabled UPNP.', {
wanPort: wanPortToUse,
localPort: localPortToUse,
});
throw new Error('No WAN port found. Disabled UPNP.');
}
// create new mapping
const mapping = {
publicPort: wanPortToUse,
privatePort: localPortToUse,
};
const success = await this.createUpnpMapping(mapping);
if (success) {
return mapping;
} else {
throw new Error('Failed to create UPNP mapping');
}
}
async disableUpnp() {
await this.removeUpnpMapping();
this.#enabled = false;
this.#wanPort = undefined;
this.#localPort = undefined;
}
@Cron('*/30 * * * *', { name: UPNP_RENEWAL_JOB_TOKEN })
async handleUpnpRenewal() {
if (this.#enabled) {
try {
await this.createOrRenewUpnpLease();
} catch (error) {
this.logger.error('[Job] UPNP Renewal failed %O', error);
}
}
}
}

View File

@@ -1,394 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { URL_TYPE } from '@unraid/shared/network.model.js';
import { makeSafeRunner } from '@unraid/shared/util/processing.js';
import { ConfigType } from '../config/connect.config.js';
/**
* Represents a Fully Qualified Domain Name (FQDN) entry in the nginx configuration.
* These entries are used to map domain names to specific network interfaces.
*/
interface FqdnEntry {
/** The network interface type (e.g., 'LAN', 'WAN', 'WG') */
interface: string;
/** Unique identifier for the interface, null if it's the only interface of its type */
id: number | null;
/** The fully qualified domain name */
fqdn: string;
/** Whether this is an IPv6 FQDN entry */
isIpv6: boolean;
}
/**
* Represents the nginx configuration state from the Unraid system.
* This interface mirrors the structure of the nginx configuration in the Redux store.
*/
interface Nginx {
certificateName: string;
certificatePath: string;
defaultUrl: string;
httpPort: number;
httpsPort: number;
lanIp: string;
lanIp6: string;
lanMdns: string;
lanName: string;
sslEnabled: boolean;
sslMode: 'yes' | 'no' | 'auto';
wanAccessEnabled: boolean;
wanIp: string;
fqdnUrls: FqdnEntry[];
}
/**
* Base interface for URL field input parameters
*/
interface UrlForFieldInput {
url: string;
port?: number;
portSsl?: number;
}
/**
* Input parameters for secure URL fields (using SSL)
*/
interface UrlForFieldInputSecure extends UrlForFieldInput {
url: string;
portSsl: number;
}
/**
* Input parameters for insecure URL fields (using HTTP)
*/
interface UrlForFieldInputInsecure extends UrlForFieldInput {
url: string;
port: number;
}
/**
* Represents a server access URL with its type and protocol information.
* This is the main output type of the URL resolver service.
*/
export interface AccessUrl {
/** The type of access URL (WAN, LAN, etc.) */
type: URL_TYPE;
/** Optional display name for the URL */
name?: string | null;
/** IPv4 URL if available */
ipv4?: URL | null;
/** IPv6 URL if available */
ipv6?: URL | null;
}
/**
* Service responsible for resolving server access URLs from the nginx configuration.
*
* This service handles the conversion of nginx configuration into accessible URLs
* for different network interfaces (WAN, LAN, etc.). It supports both IPv4 and IPv6
* addresses, as well as FQDN entries.
*
* Key Features:
* - Resolves URLs for all network interfaces (WAN, LAN, MDNS)
* - Handles both HTTP and HTTPS protocols
* - Supports FQDN entries with interface-specific configurations
* - Provides error handling and logging for URL resolution failures
*
* Edge Cases and Limitations:
* 1. SSL Mode 'auto': URLs cannot be resolved for fields when SSL mode is set to 'auto'
* 2. Missing Ports: Both HTTP and HTTPS ports must be configured for proper URL resolution
* 3. Store Synchronization: Relies on the store being properly synced via StoreSyncService
* 4. IPv6 Support: While the service handles IPv6 addresses, some features may be limited
* depending on the system's IPv6 configuration
* 5. FQDN Resolution: FQDN entries must have valid interface types (LAN, WAN, WG)
*
* @example
* ```typescript
* // Get all available server URLs
* const { urls, errors } = urlResolverService.getServerIps();
*
* // Find WAN access URL
* const wanUrl = urls.find(url => url.type === URL_TYPE.WAN);
* ```
*/
@Injectable()
export class UrlResolverService {
private readonly logger = new Logger(UrlResolverService.name);
constructor(private readonly configService: ConfigService<ConfigType, true>) {}
/**
* Constructs a URL from the given field parameters.
* Handles both HTTP and HTTPS protocols based on the provided ports.
*
* @param params - URL field parameters including the base URL and port information
* @returns A properly formatted URL object
* @throws Error if no URL is provided or if port configuration is invalid
*/
private getUrlForField({
url,
port,
portSsl,
}: UrlForFieldInputInsecure | UrlForFieldInputSecure): URL {
let portToUse = '';
let httpMode = 'https://';
if (!url || url === '') {
throw new Error('No URL Provided');
}
if (port) {
portToUse = port === 80 ? '' : `:${port}`;
httpMode = 'http://';
} else if (portSsl) {
portToUse = portSsl === 443 ? '' : `:${portSsl}`;
httpMode = 'https://';
} else {
throw new Error(`No ports specified for URL: ${url}`);
}
const urlString = `${httpMode}${url}${portToUse}`;
try {
return new URL(urlString);
} catch (error: unknown) {
throw new Error(`Failed to parse URL: ${urlString}`);
}
}
/**
* Checks if a field name represents an FQDN entry.
*
* @param field - The field name to check
* @returns true if the field is an FQDN entry
*/
private fieldIsFqdn(field: string): boolean {
return field?.toLowerCase().includes('fqdn');
}
/**
* Resolves a URL for a specific nginx field.
* Handles different SSL modes and protocols.
*
* @param nginx - The nginx configuration
* @param field - The field to resolve the URL for
* @returns A URL object for the specified field
* @throws Error if the URL cannot be resolved or if SSL mode is 'auto'
*/
private getUrlForServer(nginx: Nginx, field: keyof Nginx): URL {
if (nginx[field]) {
if (this.fieldIsFqdn(field)) {
return this.getUrlForField({
url: nginx[field] as string,
portSsl: nginx.httpsPort,
});
}
if (!nginx.sslEnabled) {
return this.getUrlForField({ url: nginx[field] as string, port: nginx.httpPort });
}
if (nginx.sslMode === 'yes') {
return this.getUrlForField({
url: nginx[field] as string,
portSsl: nginx.httpsPort,
});
}
// question: what if sslMode is no?
if (nginx.sslMode === 'auto') {
throw new Error(`Cannot get IP Based URL for field: "${field}" SSL mode auto`);
}
}
throw new Error(
`IP URL Resolver: Could not resolve any access URL for field: "${field}", is FQDN?: ${this.fieldIsFqdn(
field
)}`
);
}
/**
* Returns the set of local URLs allowed to access the Unraid API
*/
getAllowedLocalAccessUrls(): string[] {
const { nginx } = this.configService.getOrThrow('store.emhttp');
try {
return [
this.getUrlForField({ url: 'localhost', port: nginx.httpPort }),
this.getUrlForField({ url: 'localhost', portSsl: nginx.httpsPort }),
].map((url) => url.toString());
} catch (error: unknown) {
this.logger.warn('Uncaught error in getLocalAccessUrls: %o', error);
return [];
}
}
/**
* Returns the set of server IPs (both IPv4 and IPv6) allowed to access the Unraid API
*/
getAllowedServerIps(): string[] {
const { urls } = this.getServerIps();
return urls.reduce<string[]>((acc, curr) => {
if ((curr.ipv4 && curr.ipv6) || curr.ipv4) {
acc.push(curr.ipv4.toString());
} else if (curr.ipv6) {
acc.push(curr.ipv6.toString());
}
return acc;
}, []);
}
/**
* Validates and sanitizes a WAN port value.
*
* @param rawPort - The raw port value from configuration
* @returns A valid port number between 1-65535, or undefined if invalid
*/
private validateWanPort(rawPort: unknown): number | undefined {
if (rawPort == null || rawPort === '') {
return undefined;
}
const port = Number(rawPort);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return undefined;
}
return port;
}
/**
* Resolves all available server access URLs from the nginx configuration.
* This is the main method of the service that aggregates all possible access URLs.
*
* The method processes:
* 1. Default URL
* 2. LAN IPv4 and IPv6 URLs
* 3. LAN Name and MDNS URLs
* 4. FQDN URLs for different interfaces
*
* @returns Object containing an array of resolved URLs and any errors encountered
*/
getServerIps(): { urls: AccessUrl[]; errors: Error[] } {
const store = this.configService.get('store');
if (!store) {
return { urls: [], errors: [new Error('Store not loaded')] };
}
const { nginx } = store.emhttp;
const rawWanPort = this.configService.get('connect.config.wanport', { infer: true });
const wanport = this.validateWanPort(rawWanPort);
if (!nginx || Object.keys(nginx).length === 0) {
return { urls: [], errors: [new Error('Nginx Not Loaded')] };
}
const errors: Error[] = [];
const urls: AccessUrl[] = [];
const doSafely = makeSafeRunner((error) => {
if (error instanceof Error) {
errors.push(error);
} else {
this.logger.warn(error, 'Uncaught error in network resolver');
}
});
doSafely(() => {
const defaultUrl = new URL(nginx.defaultUrl);
urls.push({
name: 'Default',
type: URL_TYPE.DEFAULT,
ipv4: defaultUrl,
ipv6: defaultUrl,
});
});
doSafely(() => {
// Lan IP URL
const lanIp4Url = this.getUrlForServer(nginx, 'lanIp');
urls.push({
name: 'LAN IPv4',
type: URL_TYPE.LAN,
ipv4: lanIp4Url,
});
});
doSafely(() => {
// Lan IP6 URL
const lanIp6Url = this.getUrlForServer(nginx, 'lanIp6');
urls.push({
name: 'LAN IPv6',
type: URL_TYPE.LAN,
ipv6: lanIp6Url,
});
});
doSafely(() => {
// Lan Name URL
const lanNameUrl = this.getUrlForServer(nginx, 'lanName');
urls.push({
name: 'LAN Name',
type: URL_TYPE.MDNS,
ipv4: lanNameUrl,
});
});
doSafely(() => {
// Lan MDNS URL
const lanMdnsUrl = this.getUrlForServer(nginx, 'lanMdns');
urls.push({
name: 'LAN MDNS',
type: URL_TYPE.MDNS,
ipv4: lanMdnsUrl,
});
});
// Now Process the FQDN Urls
nginx.fqdnUrls?.forEach((fqdnUrl: FqdnEntry) => {
doSafely(() => {
const urlType = this.getUrlTypeFromFqdn(fqdnUrl.interface);
const portToUse = urlType === URL_TYPE.LAN ? nginx.httpsPort : wanport || nginx.httpsPort;
const fqdnUrlToUse = this.getUrlForField({
url: fqdnUrl.fqdn,
portSsl: Number(portToUse),
});
urls.push({
name: `FQDN ${fqdnUrl.interface}${fqdnUrl.id !== null ? ` ${fqdnUrl.id}` : ''}`,
type: urlType,
ipv4: fqdnUrlToUse,
});
});
});
return { urls, errors };
}
/**
* Maps FQDN interface types to URL types.
*
* @param fqdnType - The FQDN interface type
* @returns The corresponding URL_TYPE
*/
private getUrlTypeFromFqdn(fqdnType: string): URL_TYPE {
switch (fqdnType) {
case 'LAN':
return URL_TYPE.LAN;
case 'WAN':
return URL_TYPE.WAN;
case 'WG':
return URL_TYPE.WIREGUARD;
default:
return URL_TYPE.WIREGUARD;
}
}
getRemoteAccessUrl(): AccessUrl | null {
const { urls } = this.getServerIps();
return urls.find((url) => url.type === URL_TYPE.WAN) ?? null;
}
}

View File

@@ -1,31 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import { ConfigType } from '../config/connect.config.js';
import { EVENTS } from '../helper/nest-tokens.js';
import { NetworkService } from './network.service.js';
@Injectable()
export class WanAccessEventHandler {
private readonly logger = new Logger(WanAccessEventHandler.name);
constructor(
private readonly configService: ConfigService<ConfigType>,
private readonly networkService: NetworkService
) {}
@OnEvent(EVENTS.ENABLE_WAN_ACCESS, { async: true })
async enableWanAccess() {
this.logger.log('Enabling WAN Access');
this.configService.set('connect.config.wanaccess', true);
await this.networkService.reloadNetworkStack();
}
@OnEvent(EVENTS.DISABLE_WAN_ACCESS, { async: true })
async disableWanAccess() {
this.logger.log('Disabling WAN Access');
this.configService.set('connect.config.wanaccess', false);
await this.networkService.reloadNetworkStack();
}
}

View File

@@ -1,49 +0,0 @@
# @unraid-api-plugin-connect/src
This directory contains the core source code for the Unraid Connect API plugin, built as a modular [NestJS](https://nestjs.com/) application. It provides remote access, cloud integration, and configuration management for Unraid servers.
## Structure
- **index.ts**: Main entry, conforming to the `nestjs` API plugin schema.
- **authn/**: Authentication services.
- **config/**: Configuration management, persistence, and settings.
- **connection-status/**: Connection state monitoring and status tracking.
- **graphql/**: GraphQL request definitions and generated client code.
- **helper/**: Utility functions and constants.
- **internal-rpc/**: Internal RPC communication services.
- **mothership-proxy/**: Mothership server proxy and communication.
- **network/**: Network services including UPnP, DNS, URL resolution, and WAN access.
- **remote-access/**: Remote access services (static, dynamic, UPnP).
- **unraid-connect/**: Core Unraid Connect functionality and settings.
- **\_\_test\_\_/**: Vitest-based unit and integration tests.
Each feature directory follows a consistent pattern:
- `*.module.ts`: NestJS module definition
- `*.service.ts`: Business logic implementation
- `*.resolver.ts`: GraphQL resolvers
- `*.model.ts`: TypeScript and GraphQL models, DTOs, and types
- `*.events.ts`: Event handlers for event-driven operations
- `*.config.ts`: Configuration definitions
## Usage
This package is intended to be used as a NestJS plugin/module. Import `ApiModule` from `index.ts` and add it to your NestJS app's module imports.
```
import { ApiModule } from '@unraid-api-plugin-connect/src';
@Module({
imports: [ApiModule],
})
export class AppModule {}
```
## Development
- Install dependencies from the monorepo root: `pnpm install`
- Build: `pnpm run build` (from the package root)
- Codegen (GraphQL): `pnpm run codegen`
- Tests: `vitest` (see `__test__/` for examples)
- Format: `pnpm run format` to format all files in project
## Notes
- Designed for Unraid server environments.
- Relies on other Unraid workspace packages (e.g., `@unraid/shared`).
- For plugin installation and system integration, see the main project documentation.

View File

@@ -1,166 +0,0 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { URL_TYPE } from '@unraid/shared/network.model.js';
import {
AccessUrlObject,
ConfigType,
DynamicRemoteAccessState,
DynamicRemoteAccessType,
makeDisabledDynamicRemoteAccessState,
} from '../config/connect.config.js';
import { ONE_MINUTE_MS } from '../helper/generic-consts.js';
import { StaticRemoteAccessService } from './static-remote-access.service.js';
import { UpnpRemoteAccessService } from './upnp-remote-access.service.js';
@Injectable()
export class DynamicRemoteAccessService implements OnApplicationBootstrap {
private readonly logger = new Logger(DynamicRemoteAccessService.name);
constructor(
private readonly configService: ConfigService<ConfigType, true>,
private readonly staticRemoteAccessService: StaticRemoteAccessService,
private readonly upnpRemoteAccessService: UpnpRemoteAccessService
) {}
async onApplicationBootstrap() {
await this.initRemoteAccess();
}
/**
* Get the current state of dynamic remote access
*/
getState(): DynamicRemoteAccessState {
return this.configService.getOrThrow<DynamicRemoteAccessState>('connect.dynamicRemoteAccess');
}
keepAlive() {
this.receivePing();
}
private receivePing() {
this.configService.set('connect.dynamicRemoteAccess.lastPing', Date.now());
}
private clearPing() {
this.configService.set('connect.dynamicRemoteAccess.lastPing', null);
this.logger.verbose('cleared ping');
}
async checkForTimeout() {
const state = this.getState();
if (state.lastPing && Date.now() - state.lastPing > ONE_MINUTE_MS) {
this.logger.warn('No pings received in 1 minute, disabling dynamic remote access');
await this.stopRemoteAccess();
}
}
setAllowedUrl(url: AccessUrlObject) {
const currentAllowed = this.configService.get('connect.dynamicRemoteAccess.allowedUrl') ?? {};
const newAllowed: AccessUrlObject = {
...currentAllowed,
...url,
type: url.type ?? URL_TYPE.WAN,
};
this.configService.set('connect.dynamicRemoteAccess.allowedUrl', newAllowed);
this.logger.verbose(`setAllowedUrl: ${JSON.stringify(newAllowed, null, 2)}`);
}
private setErrorMessage(error: string) {
this.configService.set('connect.dynamicRemoteAccess.error', error);
}
private clearError() {
this.configService.set('connect.dynamicRemoteAccess.error', null);
}
async enableDynamicRemoteAccess(input: {
allowedUrl: AccessUrlObject;
type: DynamicRemoteAccessType;
}) {
try {
this.logger.verbose(`enableDynamicRemoteAccess ${JSON.stringify(input, null, 2)}`);
await this.stopRemoteAccess();
if (input.allowedUrl) {
this.setAllowedUrl({
ipv4: input.allowedUrl.ipv4?.toString() ?? null,
ipv6: input.allowedUrl.ipv6?.toString() ?? null,
type: input.allowedUrl.type,
name: input.allowedUrl.name,
});
}
await this.setType(input.type);
} catch (error) {
this.logger.error(error);
const message = error instanceof Error ? error.message : 'Unknown Error';
this.setErrorMessage(message);
return error;
}
}
/**
* Set the dynamic remote access type and handle the transition
* @param type The new dynamic remote access type to set
*/
private async setType(type: DynamicRemoteAccessType): Promise<void> {
this.logger.verbose(`setType: ${type}`);
// Update the config first
this.configService.set('connect.config.dynamicRemoteAccessType', type);
if (type === DynamicRemoteAccessType.DISABLED) {
this.logger.log('Disabling Dynamic Remote Access');
await this.stopRemoteAccess();
return;
}
// Update the state to reflect the new type
this.configService.set('connect.dynamicRemoteAccess', {
...makeDisabledDynamicRemoteAccessState(),
runningType: type,
});
// Start the appropriate remote access service
if (type === DynamicRemoteAccessType.STATIC) {
await this.staticRemoteAccessService.beginRemoteAccess();
} else if (type === DynamicRemoteAccessType.UPNP) {
await this.upnpRemoteAccessService.begin();
}
}
/**
* Stop remote access and reset the state
*/
async stopRemoteAccess(): Promise<void> {
const state = this.configService.get<DynamicRemoteAccessState>('connect.dynamicRemoteAccess');
if (state?.runningType === DynamicRemoteAccessType.STATIC) {
await this.staticRemoteAccessService.stopRemoteAccess();
} else if (state?.runningType === DynamicRemoteAccessType.UPNP) {
await this.upnpRemoteAccessService.stop();
}
// Reset the state
this.configService.set('connect.dynamicRemoteAccess', makeDisabledDynamicRemoteAccessState());
this.clearPing();
this.clearError();
}
private async initRemoteAccess() {
this.logger.verbose('Initializing Remote Access');
const { wanaccess, upnpEnabled } = this.configService.get('connect.config', { infer: true });
if (wanaccess && upnpEnabled) {
await this.enableDynamicRemoteAccess({
type: DynamicRemoteAccessType.UPNP,
allowedUrl: {
ipv4: null,
ipv6: null,
type: URL_TYPE.WAN,
name: 'WAN',
},
});
}
// if wanaccess is true and upnpEnabled is false, static remote access is already "enabled".
// if wanaccess is false, remote access is already "disabled".
}
}

View File

@@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { NetworkModule } from '../network/network.module.js';
import { WanAccessEventHandler } from '../network/wan-access.events.js';
import { DynamicRemoteAccessService } from './dynamic-remote-access.service.js';
import { StaticRemoteAccessService } from './static-remote-access.service.js';
import { UpnpRemoteAccessService } from './upnp-remote-access.service.js';
@Module({
imports: [NetworkModule],
providers: [
DynamicRemoteAccessService,
StaticRemoteAccessService,
UpnpRemoteAccessService,
WanAccessEventHandler,
],
exports: [DynamicRemoteAccessService, NetworkModule],
})
export class RemoteAccessModule {}

View File

@@ -1,30 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ConfigType, DynamicRemoteAccessType, MyServersConfig } from '../config/connect.config.js';
import { EVENTS } from '../helper/nest-tokens.js';
import { AccessUrl, UrlResolverService } from '../network/url-resolver.service.js';
@Injectable()
export class StaticRemoteAccessService {
constructor(
private readonly configService: ConfigService<ConfigType>,
private readonly eventEmitter: EventEmitter2,
private readonly urlResolverService: UrlResolverService
) {}
private logger = new Logger(StaticRemoteAccessService.name);
async stopRemoteAccess() {
this.eventEmitter.emit(EVENTS.DISABLE_WAN_ACCESS);
}
async beginRemoteAccess(): Promise<AccessUrl | null> {
this.logger.log('Begin Static Remote Access');
// enabling/disabling static remote access is a config-only change.
// the actual forwarding must be configured on the router, outside of the API.
this.eventEmitter.emit(EVENTS.ENABLE_WAN_ACCESS);
return this.urlResolverService.getRemoteAccessUrl();
}
}

View File

@@ -1,43 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ConfigType } from '../config/connect.config.js';
import { EVENTS } from '../helper/nest-tokens.js';
import { UpnpService } from '../network/upnp.service.js';
import { UrlResolverService } from '../network/url-resolver.service.js';
@Injectable()
export class UpnpRemoteAccessService {
constructor(
private readonly upnpService: UpnpService,
private readonly configService: ConfigService<ConfigType>,
private readonly urlResolverService: UrlResolverService,
private readonly eventEmitter: EventEmitter2
) {}
private readonly logger = new Logger(UpnpRemoteAccessService.name);
async stop() {
await this.upnpService.disableUpnp();
this.eventEmitter.emit(EVENTS.DISABLE_WAN_ACCESS);
}
async begin() {
this.logger.log('Begin UPNP Remote Access');
const { httpsPort, httpPort, sslMode } = this.configService.getOrThrow('store.emhttp.nginx');
const localPort = sslMode === 'no' ? Number(httpPort) : Number(httpsPort);
if (isNaN(localPort)) {
throw new Error(`Invalid local port configuration: ${localPort}`);
}
try {
const mapping = await this.upnpService.createOrRenewUpnpLease({ localPort });
this.configService.set('connect.config.wanport', mapping.publicPort);
this.eventEmitter.emit(EVENTS.ENABLE_WAN_ACCESS);
return this.urlResolverService.getRemoteAccessUrl();
} catch (error) {
this.logger.error(error, 'Failed to begin UPNP Remote Access');
await this.stop();
}
}
}

View File

@@ -1,134 +0,0 @@
import { Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { type Layout } from '@jsonforms/core';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { DataSlice } from '@unraid/shared/jsonforms/settings.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { GraphQLJSON } from 'graphql-scalars';
import { EVENTS } from '../helper/nest-tokens.js';
import { ConnectSettingsService } from './connect-settings.service.js';
import {
AllowedOriginInput,
ConnectSettings,
ConnectSettingsInput,
ConnectSettingsValues,
ConnectSignInInput,
EnableDynamicRemoteAccessInput,
RemoteAccess,
SetupRemoteAccessInput,
} from './connect.model.js';
@Resolver(() => ConnectSettings)
export class ConnectSettingsResolver {
private readonly logger = new Logger(ConnectSettingsResolver.name);
constructor(
private readonly connectSettingsService: ConnectSettingsService,
private readonly eventEmitter: EventEmitter2
) {}
@ResolveField(() => PrefixedID)
public async id(): Promise<string> {
return 'connectSettingsForm';
}
@ResolveField(() => GraphQLJSON)
public async dataSchema(): Promise<{ properties: DataSlice; type: 'object' }> {
const { properties } = await this.connectSettingsService.buildRemoteAccessSlice();
return {
type: 'object',
properties,
};
}
@ResolveField(() => GraphQLJSON)
public async uiSchema(): Promise<Layout> {
const { elements } = await this.connectSettingsService.buildRemoteAccessSlice();
return {
type: 'VerticalLayout',
elements,
};
}
@ResolveField(() => ConnectSettingsValues)
public async values(): Promise<ConnectSettingsValues> {
return await this.connectSettingsService.getCurrentSettings();
}
@Query(() => RemoteAccess)
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.CONNECT,
})
public async remoteAccess(): Promise<RemoteAccess> {
return this.connectSettingsService.dynamicRemoteAccessSettings();
}
@Mutation(() => ConnectSettingsValues)
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CONFIG,
})
public async updateApiSettings(@Args('input') settings: ConnectSettingsInput) {
this.logger.verbose(`Attempting to update API settings: ${JSON.stringify(settings, null, 2)}`);
const restartRequired = await this.connectSettingsService.syncSettings(settings);
const currentSettings = await this.connectSettingsService.getCurrentSettings();
if (restartRequired) {
setTimeout(async () => {
// Send restart out of band to avoid blocking the return of this resolver
this.logger.log('Restarting API');
await this.connectSettingsService.restartApi();
}, 300);
}
return currentSettings;
}
@Mutation(() => Boolean)
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
})
public async connectSignIn(@Args('input') input: ConnectSignInInput): Promise<boolean> {
return this.connectSettingsService.signIn(input);
}
@Mutation(() => Boolean)
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
})
public async connectSignOut() {
this.eventEmitter.emit(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API' });
return true;
}
@Mutation(() => Boolean)
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
})
public async setupRemoteAccess(@Args('input') input: SetupRemoteAccessInput): Promise<boolean> {
await this.connectSettingsService.syncSettings({
accessType: input.accessType,
forwardType: input.forwardType,
port: input.port,
});
return true;
}
@Mutation(() => Boolean)
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT__REMOTE_ACCESS,
})
public async enableDynamicRemoteAccess(
@Args('input') dynamicRemoteAccessInput: EnableDynamicRemoteAccessInput
): Promise<boolean> {
await this.connectSettingsService.enableDynamicRemoteAccess(dynamicRemoteAccessInput);
return true;
}
}

View File

@@ -1,461 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { JsonSchema7, SchemaBasedCondition } from '@jsonforms/core';
import type { DataSlice, SettingSlice, UIElement } from '@unraid/shared/jsonforms/settings.js';
import { RuleEffect } from '@jsonforms/core';
import { createLabeledControl } from '@unraid/shared/jsonforms/control.js';
import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js';
import { URL_TYPE } from '@unraid/shared/network.model.js';
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
import { execa } from 'execa';
import { GraphQLError } from 'graphql/error/GraphQLError.js';
import { decodeJwt } from 'jose';
import type {
ConnectSettingsInput,
ConnectSettingsValues,
ConnectSignInInput,
EnableDynamicRemoteAccessInput,
RemoteAccess,
SetupRemoteAccessInput,
} from './connect.model.js';
import { ConfigType, MyServersConfig } from '../config/connect.config.js';
import { EVENTS } from '../helper/nest-tokens.js';
import { NetworkService } from '../network/network.service.js';
import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js';
import { DynamicRemoteAccessType, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE } from './connect.model.js';
declare module '@unraid/shared/services/user-settings.js' {
interface UserSettings {
'remote-access': RemoteAccess;
}
}
@Injectable()
export class ConnectSettingsService {
constructor(
private readonly configService: ConfigService<ConfigType>,
private readonly remoteAccess: DynamicRemoteAccessService,
private readonly eventEmitter: EventEmitter2,
private readonly userSettings: UserSettingsService,
private readonly networkService: NetworkService
) {
this.userSettings.register('remote-access', {
buildSlice: async () => this.buildRemoteAccessSlice(),
getCurrentValues: async () => this.getCurrentSettings(),
updateValues: async (settings: Partial<RemoteAccess>) => {
await this.syncSettings(settings);
return {
restartRequired: false,
values: await this.getCurrentSettings(),
};
},
});
}
private readonly logger = new Logger(ConnectSettingsService.name);
async restartApi() {
try {
await execa('unraid-api', ['restart'], { shell: 'bash', stdio: 'ignore' });
} catch (error) {
this.logger.error(error);
}
}
public async extraAllowedOrigins(): Promise<Array<string>> {
return this.configService.get('api.extraOrigins', []);
}
isConnectPluginInstalled(): boolean {
return true;
}
public async enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput) {
const { dynamicRemoteAccessType } =
this.configService.getOrThrow<MyServersConfig>('connect.config');
if (!dynamicRemoteAccessType || dynamicRemoteAccessType === DynamicRemoteAccessType.DISABLED) {
throw new GraphQLError('Dynamic Remote Access is not enabled.', {
extensions: { code: 'FORBIDDEN' },
});
}
await this.remoteAccess.enableDynamicRemoteAccess({
allowedUrl: {
ipv4: input.url.ipv4?.toString() ?? null,
ipv6: input.url.ipv6?.toString() ?? null,
type: input.url.type,
name: input.url.name,
},
type: dynamicRemoteAccessType,
});
}
async isSignedIn(): Promise<boolean> {
const { apikey } = this.configService.getOrThrow<MyServersConfig>('connect.config');
return Boolean(apikey) && apikey.trim().length > 0;
}
async isSSLCertProvisioned(): Promise<boolean> {
const { certificateName = '' } = this.configService.get('store.emhttp.nginx', {});
return certificateName?.endsWith('.myunraid.net') ?? false;
}
/**------------------------------------------------------------------------
* Settings Form Data
*------------------------------------------------------------------------**/
async getCurrentSettings(): Promise<ConnectSettingsValues> {
// const connect = this.configService.getOrThrow<ConnectConfig>('connect');
return {
...(await this.dynamicRemoteAccessSettings()),
};
}
/**
* Syncs the settings to the store and writes the config to disk
* @param settings - The settings to sync
* @returns true if a restart is required, false otherwise
*/
async syncSettings(settings: Partial<ConnectSettingsInput>): Promise<boolean> {
let restartRequired = false;
const { nginx } = this.configService.getOrThrow('store.emhttp');
if (settings.accessType === WAN_ACCESS_TYPE.DISABLED) {
settings.port = null;
}
if (
!nginx.sslEnabled &&
settings.accessType === WAN_ACCESS_TYPE.DYNAMIC &&
settings.forwardType === WAN_FORWARD_TYPE.STATIC
) {
throw new GraphQLError(
'SSL must be provisioned and enabled for dynamic access and static port forwarding.'
);
}
if (settings.accessType) {
await this.updateRemoteAccess({
accessType: settings.accessType,
forwardType: settings.forwardType,
port: settings.port,
});
}
// const { writeConfigSync } = await import('@app/store/sync/config-disk-sync.js');
// writeConfigSync('flash');
return restartRequired;
}
async signIn(input: ConnectSignInInput) {
const status = this.configService.get('store.emhttp.status');
if (status === 'LOADED') {
const userInfo = input.userInfo ?? null;
if (
!userInfo ||
!userInfo.preferred_username ||
!userInfo.email ||
typeof userInfo.preferred_username !== 'string' ||
typeof userInfo.email !== 'string'
) {
throw new GraphQLError('Missing User Attributes', {
extensions: { code: 'BAD_REQUEST' },
});
}
try {
// Update config with user info
this.configService.set(
'connect.config.avatar',
typeof userInfo.avatar === 'string' ? userInfo.avatar : ''
);
this.configService.set('connect.config.username', userInfo.preferred_username);
this.configService.set('connect.config.email', userInfo.email);
this.configService.set('connect.config.apikey', input.apiKey);
// Emit login event
this.eventEmitter.emit(EVENTS.LOGIN, {
username: userInfo.preferred_username,
avatar: typeof userInfo.avatar === 'string' ? userInfo.avatar : '',
email: userInfo.email,
apikey: input.apiKey,
});
return true;
} catch (error) {
throw new GraphQLError(`Failed to login user: ${error}`, {
extensions: { code: 'INTERNAL_SERVER_ERROR' },
});
}
} else {
return false;
}
}
private getDynamicRemoteAccessType(
accessType: WAN_ACCESS_TYPE,
forwardType?: WAN_FORWARD_TYPE | undefined | null
): DynamicRemoteAccessType {
// If access is disabled or always, DRA is disabled
if (accessType === WAN_ACCESS_TYPE.DISABLED) {
return DynamicRemoteAccessType.DISABLED;
}
// if access is enabled and forward type is UPNP, DRA is UPNP, otherwise it is static
return forwardType === WAN_FORWARD_TYPE.UPNP
? DynamicRemoteAccessType.UPNP
: DynamicRemoteAccessType.STATIC;
}
private async updateRemoteAccess(input: SetupRemoteAccessInput): Promise<boolean> {
const dynamicRemoteAccessType = this.getDynamicRemoteAccessType(
input.accessType,
input.forwardType
);
// Currently, Dynamic Remote Access (WAN_ACCESS_TYPE.DYNAMIC) is not enabled,
// so we treat it as disabled for this condition.
const wanaccessEnabled = input.accessType === WAN_ACCESS_TYPE.ALWAYS;
this.configService.set(
'connect.config.upnpEnabled',
wanaccessEnabled && input.forwardType === WAN_FORWARD_TYPE.UPNP
);
if (wanaccessEnabled && input.forwardType === WAN_FORWARD_TYPE.STATIC) {
this.configService.set('connect.config.wanport', input.port);
// when forwarding with upnp, the upnp service will clear & set the wanport as necessary
}
this.configService.set('connect.config.wanaccess', wanaccessEnabled);
// do the wanaccess port-override last; it should have the highest precedence
if (!wanaccessEnabled) {
this.configService.set('connect.config.wanport', null);
}
// Use the dynamic remote access service to handle the transition
// currently disabled; this call ensures correct migration behavior.
await this.remoteAccess.enableDynamicRemoteAccess({
type: dynamicRemoteAccessType,
allowedUrl: {
ipv4: null,
ipv6: null,
type: URL_TYPE.WAN,
name: null,
},
});
return true;
}
public async dynamicRemoteAccessSettings(): Promise<RemoteAccess> {
const config = this.configService.getOrThrow<MyServersConfig>('connect.config');
return {
accessType: config.wanaccess ? WAN_ACCESS_TYPE.ALWAYS : WAN_ACCESS_TYPE.DISABLED,
forwardType: config.upnpEnabled ? WAN_FORWARD_TYPE.UPNP : WAN_FORWARD_TYPE.STATIC,
port: config.wanport ? Number(config.wanport) : null,
};
}
/**------------------------------------------------------------------------
* Settings Form Slices
*------------------------------------------------------------------------**/
async buildRemoteAccessSlice(): Promise<SettingSlice> {
const slice = await this.remoteAccessSlice();
/**------------------------------------------------------------------------
* UX: Only validate 'port' when relevant
*
* 'port' will be null when remote access is disabled, and it's irrelevant
* when using upnp (because it becomes read-only for the end-user).
*
* In these cases, we should omit type and range validation for 'port'
* to avoid confusing end-users.
*
* But, when using static port forwarding, 'port' is required, so we validate it.
*------------------------------------------------------------------------**/
return {
properties: {
'remote-access': {
type: 'object',
properties: slice.properties as JsonSchema7['properties'],
allOf: [
{
if: {
properties: {
forwardType: { const: WAN_FORWARD_TYPE.STATIC },
accessType: { const: WAN_ACCESS_TYPE.ALWAYS },
},
required: ['forwardType', 'accessType'],
},
then: {
required: ['port'],
properties: {
port: {
type: 'number',
minimum: 1,
maximum: 65535,
},
},
},
},
],
},
},
elements: slice.elements,
};
}
buildFlashBackupSlice(): SettingSlice {
return mergeSettingSlices([this.flashBackupSlice()], {
as: 'flash-backup',
});
}
/**
* Computes the JSONForms schema definition for remote access settings.
*/
async remoteAccessSlice(): Promise<SettingSlice> {
const isSignedIn = await this.isSignedIn();
const isSSLCertProvisioned = await this.isSSLCertProvisioned();
const { sslEnabled } = this.configService.getOrThrow('store.emhttp.nginx');
const precondition = isSignedIn && isSSLCertProvisioned && sslEnabled;
/** shown when preconditions are not met */
const requirements: UIElement[] = [
{
type: 'UnraidSettingsLayout',
elements: [
{
type: 'Label',
text: 'Allow Remote Access:',
},
{
type: 'Label',
text: 'Allow Remote Access',
options: {
format: 'preconditions',
description: 'Remote Access is disabled. To enable, please make sure:',
items: [
{
text: 'You are signed in to Unraid Connect',
status: isSignedIn,
},
{
text: 'You have provisioned a valid SSL certificate',
status: isSSLCertProvisioned,
},
{
text: 'SSL is enabled',
status: sslEnabled,
},
],
},
},
],
},
];
/** shown when preconditions are met */
const formControls: UIElement[] = [
createLabeledControl({
scope: '#/properties/remote-access/properties/accessType',
label: 'Allow Remote Access',
controlOptions: {},
}),
createLabeledControl({
scope: '#/properties/remote-access/properties/forwardType',
label: 'Remote Access Forward Type',
controlOptions: {},
rule: {
effect: RuleEffect.DISABLE,
condition: {
scope: '#/properties/remote-access/properties/accessType',
schema: {
enum: [WAN_ACCESS_TYPE.DISABLED],
},
} as SchemaBasedCondition,
},
}),
createLabeledControl({
scope: '#/properties/remote-access/properties/port',
label: 'Remote Access WAN Port',
controlOptions: {
format: 'short',
formatOptions: {
useGrouping: false,
},
},
rule: {
effect: RuleEffect.DISABLE,
condition: {
scope: '#/properties/remote-access',
schema: {
anyOf: [
{
properties: {
accessType: {
const: WAN_ACCESS_TYPE.DISABLED,
},
},
required: ['accessType'],
},
{
properties: {
forwardType: {
const: WAN_FORWARD_TYPE.UPNP,
},
},
required: ['forwardType'],
},
],
},
},
},
}),
];
/** shape of the data associated with remote access settings, as json schema properties*/
const properties: DataSlice = {
accessType: {
type: 'string',
enum: [WAN_ACCESS_TYPE.DISABLED, WAN_ACCESS_TYPE.ALWAYS],
title: 'Allow Remote Access',
default: WAN_ACCESS_TYPE.DISABLED,
},
forwardType: {
type: 'string',
enum: Object.values(WAN_FORWARD_TYPE),
title: 'Forward Type',
default: WAN_FORWARD_TYPE.STATIC,
},
port: {
// 'port' is null when remote access is disabled.
type: ['number', 'null'],
title: 'WAN Port',
minimum: 0,
maximum: 65535,
},
};
return {
properties,
elements: precondition ? formControls : requirements,
};
}
/**
* Flash backup settings slice
*/
flashBackupSlice(): SettingSlice {
return {
properties: {
status: {
type: 'string',
enum: ['inactive', 'active', 'updating'],
default: 'inactive',
},
},
elements: [], // No UI elements needed for this system-managed setting
};
}
}

View File

@@ -1,278 +0,0 @@
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Node } from '@unraid/shared/graphql.model.js';
import { AccessUrl, URL_TYPE } from '@unraid/shared/network.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsEmail,
IsEnum,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
MinLength,
ValidateNested,
} from 'class-validator';
import { GraphQLJSON, GraphQLURL } from 'graphql-scalars';
export enum WAN_ACCESS_TYPE {
DYNAMIC = 'DYNAMIC',
ALWAYS = 'ALWAYS',
DISABLED = 'DISABLED',
}
export enum WAN_FORWARD_TYPE {
UPNP = 'UPNP',
STATIC = 'STATIC',
}
export enum DynamicRemoteAccessType {
STATIC = 'STATIC',
UPNP = 'UPNP',
DISABLED = 'DISABLED',
}
registerEnumType(DynamicRemoteAccessType, {
name: 'DynamicRemoteAccessType',
});
registerEnumType(WAN_ACCESS_TYPE, {
name: 'WAN_ACCESS_TYPE',
});
registerEnumType(WAN_FORWARD_TYPE, {
name: 'WAN_FORWARD_TYPE',
});
@InputType()
export class AccessUrlInput {
@Field(() => URL_TYPE)
@IsEnum(URL_TYPE)
type!: URL_TYPE;
@Field(() => String, { nullable: true })
@IsOptional()
name?: string | null;
@Field(() => GraphQLURL, { nullable: true })
@IsOptional()
ipv4?: URL | null;
@Field(() => GraphQLURL, { nullable: true })
@IsOptional()
ipv6?: URL | null;
}
@InputType()
export class ConnectUserInfoInput {
@Field(() => String, { description: 'The preferred username of the user' })
@IsString()
@IsNotEmpty()
preferred_username!: string;
@Field(() => String, { description: 'The email address of the user' })
@IsEmail()
@IsNotEmpty()
email!: string;
@Field(() => String, { nullable: true, description: 'The avatar URL of the user' })
@IsString()
@IsOptional()
avatar?: string;
}
@InputType()
export class ConnectSignInInput {
@Field(() => String, { description: 'The API key for authentication' })
@IsString()
@IsNotEmpty()
@MinLength(5)
apiKey!: string;
@Field(() => ConnectUserInfoInput, {
nullable: true,
description: 'User information for the sign-in',
})
@ValidateNested()
@IsOptional()
userInfo?: ConnectUserInfoInput;
}
@InputType()
export class AllowedOriginInput {
@Field(() => [String], { description: 'A list of origins allowed to interact with the API' })
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
origins!: string[];
}
@ObjectType()
export class RemoteAccess {
@Field(() => WAN_ACCESS_TYPE, { description: 'The type of WAN access used for Remote Access' })
@IsEnum(WAN_ACCESS_TYPE)
accessType!: WAN_ACCESS_TYPE;
@Field(() => WAN_FORWARD_TYPE, {
nullable: true,
description: 'The type of port forwarding used for Remote Access',
})
@IsEnum(WAN_FORWARD_TYPE)
@IsOptional()
forwardType?: WAN_FORWARD_TYPE;
@Field(() => Int, { nullable: true, description: 'The port used for Remote Access' })
@IsOptional()
port?: number | null;
}
@InputType()
export class SetupRemoteAccessInput {
@Field(() => WAN_ACCESS_TYPE, { description: 'The type of WAN access to use for Remote Access' })
@IsEnum(WAN_ACCESS_TYPE)
accessType!: WAN_ACCESS_TYPE;
@Field(() => WAN_FORWARD_TYPE, {
nullable: true,
description: 'The type of port forwarding to use for Remote Access',
})
@IsEnum(WAN_FORWARD_TYPE)
@IsOptional()
forwardType?: WAN_FORWARD_TYPE | null;
@Field(() => Int, {
nullable: true,
description:
'The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP.',
})
@IsOptional()
port?: number | null;
}
@InputType()
export class EnableDynamicRemoteAccessInput {
@Field(() => AccessUrlInput, { description: 'The AccessURL Input for dynamic remote access' })
@ValidateNested()
url!: AccessUrlInput;
@Field(() => Boolean, { description: 'Whether to enable or disable dynamic remote access' })
@IsBoolean()
enabled!: boolean;
}
@ObjectType()
export class DynamicRemoteAccessStatus {
@Field(() => DynamicRemoteAccessType, {
description: 'The type of dynamic remote access that is enabled',
})
@IsEnum(DynamicRemoteAccessType)
enabledType!: DynamicRemoteAccessType;
@Field(() => DynamicRemoteAccessType, {
description: 'The type of dynamic remote access that is currently running',
})
@IsEnum(DynamicRemoteAccessType)
runningType!: DynamicRemoteAccessType;
@Field(() => String, {
nullable: true,
description: 'Any error message associated with the dynamic remote access',
})
@IsString()
@IsOptional()
error?: string;
}
@ObjectType()
export class ConnectSettingsValues {
@Field(() => WAN_ACCESS_TYPE, { description: 'The type of WAN access used for Remote Access' })
@IsEnum(WAN_ACCESS_TYPE)
accessType!: WAN_ACCESS_TYPE;
@Field(() => WAN_FORWARD_TYPE, {
nullable: true,
description: 'The type of port forwarding used for Remote Access',
})
@IsEnum(WAN_FORWARD_TYPE)
@IsOptional()
forwardType?: WAN_FORWARD_TYPE;
@Field(() => Int, { nullable: true, description: 'The port used for Remote Access' })
@IsOptional()
@IsNumber()
port?: number | null;
}
@InputType()
export class ConnectSettingsInput {
@Field(() => WAN_ACCESS_TYPE, {
nullable: true,
description: 'The type of WAN access to use for Remote Access',
})
@IsEnum(WAN_ACCESS_TYPE)
@IsOptional()
accessType?: WAN_ACCESS_TYPE | null;
@Field(() => WAN_FORWARD_TYPE, {
nullable: true,
description: 'The type of port forwarding to use for Remote Access',
})
@IsEnum(WAN_FORWARD_TYPE)
@IsOptional()
forwardType?: WAN_FORWARD_TYPE | null;
@Field(() => Int, {
nullable: true,
description:
'The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP.',
})
@IsOptional()
port?: number | null;
}
@ObjectType({
implements: () => Node,
})
export class ConnectSettings implements Node {
@Field(() => PrefixedID)
@IsString()
@IsNotEmpty()
id!: string;
@Field(() => GraphQLJSON, { description: 'The data schema for the Connect settings' })
@IsObject()
dataSchema!: Record<string, any>;
@Field(() => GraphQLJSON, { description: 'The UI schema for the Connect settings' })
@IsObject()
uiSchema!: Record<string, any>;
@Field(() => ConnectSettingsValues, { description: 'The values for the Connect settings' })
@ValidateNested()
values!: ConnectSettingsValues;
}
@ObjectType({
implements: () => Node,
})
export class Connect extends Node {
@Field(() => DynamicRemoteAccessStatus, { description: 'The status of dynamic remote access' })
@ValidateNested()
dynamicRemoteAccess?: DynamicRemoteAccessStatus;
@Field(() => ConnectSettings, { description: 'The settings for the Connect instance' })
@ValidateNested()
settings?: ConnectSettings;
}
@ObjectType({
implements: () => Node,
})
export class Network extends Node {
@Field(() => [AccessUrl], { nullable: true })
accessUrls?: AccessUrl[];
}

View File

@@ -1,32 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
import { ConnectLoginHandler } from '../authn/connect-login.events.js';
import { ConnectConfigService } from '../config/connect.config.service.js';
import { RemoteAccessModule } from '../remote-access/remote-access.module.js';
import { ConnectSettingsResolver } from './connect-settings.resolver.js';
import { ConnectSettingsService } from './connect-settings.service.js';
import { ConnectResolver } from './connect.resolver.js';
@Module({
imports: [RemoteAccessModule, ConfigModule, UserSettingsModule],
providers: [
ConnectSettingsService,
ConnectLoginHandler,
ConnectSettingsResolver,
ConnectResolver,
ConnectConfigService,
],
exports: [
ConnectSettingsService,
ConnectLoginHandler,
ConnectSettingsResolver,
ConnectResolver,
ConnectConfigService,
RemoteAccessModule,
],
})
export class ConnectModule {}

View File

@@ -1,43 +0,0 @@
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import {
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { ConfigType, ConnectConfig, DynamicRemoteAccessType } from '../config/connect.config.js';
import { Connect, ConnectSettings, DynamicRemoteAccessStatus } from './connect.model.js';
@Resolver(() => Connect)
export class ConnectResolver {
protected logger = new Logger(ConnectResolver.name);
constructor(private readonly configService: ConfigService<ConfigType>) {}
@Query(() => Connect)
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.CONNECT,
})
public connect(): Connect {
return {
id: 'connect',
};
}
@ResolveField(() => DynamicRemoteAccessStatus)
public dynamicRemoteAccess(): DynamicRemoteAccessStatus {
const state = this.configService.getOrThrow<ConnectConfig>('connect');
return {
runningType: state.dynamicRemoteAccess.runningType,
enabledType: state.config.dynamicRemoteAccessType ?? DynamicRemoteAccessType.DISABLED,
error: state.dynamicRemoteAccess.error ?? undefined,
};
}
@ResolveField(() => ConnectSettings)
public async settings(): Promise<ConnectSettings> {
return {} as ConnectSettings;
}
}

View File

@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "nodenext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true },
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

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

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

4
pnpm-lock.yaml generated
View File

@@ -309,7 +309,7 @@ importers:
version: 7.15.0
unraid-api-plugin-connect:
specifier: workspace:*
version: link:../packages/unraid-api-plugin-connect-2
version: link:../packages/unraid-api-plugin-connect
uuid:
specifier: 13.0.0
version: 13.0.0
@@ -501,7 +501,7 @@ importers:
specifier: 8.8.1
version: 8.8.1
packages/unraid-api-plugin-connect-2:
packages/unraid-api-plugin-connect:
dependencies:
'@unraid/shared':
specifier: workspace:*