feat: move external plugins into api

This commit is contained in:
Alexis Tyler
2020-11-10 23:37:22 +10:30
parent c554096c64
commit 291c01ec07
35 changed files with 19894 additions and 392 deletions

View File

@@ -1,7 +1,7 @@
import { config } from '@unraid/core';
const internalWsAddress = () => {
const port = config.get('node-api-port');
const port = config.get('port');
return isNaN(port as any)
// Unix Socket
? `ws+unix:${port}`

View File

@@ -6,11 +6,15 @@
import get from 'lodash.get';
import { v4 as uuid } from 'uuid';
import * as core from '@unraid/core';
import { apiManager, errors, log, states, config, pluginManager, modules } from '@unraid/core';
import { bus, apiManager, errors, log, states, config, pluginManager, modules } from '@unraid/core';
import { makeExecutableSchema, SchemaDirectiveVisitor } from 'graphql-tools';
import { mergeTypes } from 'merge-graphql-schemas';
import gql from 'graphql-tag';
import { typeDefs, resolvers } from './schema';
import dee from '@gridplus/docker-events';
import { setIntervalAsync } from 'set-interval-async/dynamic';
import { run, publish } from '../run';
import { typeDefs } from './schema';
import * as resolvers from './resolvers';
import { wsHasConnected, wsHasDisconnected } from '../ws';
const { AppError, FatalAppError, PluginError } = errors;
@@ -257,23 +261,19 @@ const schema = makeExecutableSchema({
});
const ensureApiKey = (apiKeyToCheck: string) => {
try {
// Check there is atleast one valid key
if (core.apiManager.getValidKeys().length !== 0) {
if (!apiKeyToCheck) {
throw new AppError('Missing API key.');
}
if (!apiManager.isValid(apiKeyToCheck)) {
throw new AppError('Invalid API key.');
}
} else {
if (process.env.NODE_ENV !== 'development') {
throw new AppError('No valid API keys active.');
}
// Check there is atleast one valid key
if (core.apiManager.getValidKeys().length !== 0) {
if (!apiKeyToCheck) {
throw new AppError('Missing API key.');
}
if (!apiManager.isValid(apiKeyToCheck)) {
throw new AppError('Invalid API key.');
}
} else {
if (process.env.NODE_ENV !== 'development') {
throw new AppError('No valid API keys active.');
}
} catch {
throw new AppError('Invalid Api key.');
}
};
@@ -295,6 +295,69 @@ const apiKeyToUser = (apiKey: string) => {
return { name: 'guest', role: 'guest' };
};
// Update array values when slots change
bus.on('slots', async () => {
// @todo: Create a system user for this
const user = usersState.findOne({ name: 'root' });
await run('array', 'UPDATED', {
moduleToRun: modules.getArray,
context: {
user
}
});
});
// Update info/hostname when hostname changes
bus.on('varstate', async (data) => {
const hostname = data.varstate.node.name;
// @todo: Create a system user for this
const user = usersState.findOne({ name: 'root' });
if (user) {
publish('info', 'UPDATED', {
os: {
hostname
}
});
}
});
// On Docker event update info with { apps: { installed, started } }
dee.on('*', async (data: { Type: string }) => {
// Only listen to container events
if (data.Type !== 'container') {
return;
}
// @todo: Create a system user for this
const user = usersState.findOne({ name: 'root' });
if (user) {
const { json } = await modules.getAppCount({
user
});
publish('info', 'UPDATED', {
apps: json
});
}
});
dee.listen();
// This needs to be fixed to run from events
setIntervalAsync(async () => {
// @todo: Create a system user for this
const user = usersState.findOne({ name: 'root' });
await run('services', 'UPDATED', {
moduleToRun: modules.getServices,
context: {
user
}
});
}, 1000);
export const graphql = {
introspection: debug,
playground: debug ? {
@@ -359,6 +422,7 @@ export const graphql = {
if (req) {
const apiKey = req.headers['x-api-key'];
const user = apiKeyToUser(apiKey);
return {
user
};

View File

@@ -0,0 +1,21 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import GraphQLJSON from 'graphql-type-json';
import GraphQLLong from 'graphql-type-long';
import GraphQLUUID from 'graphql-type-uuid';
import { Query } from './query';
import { Subscription } from './subscription';
import { UserAccount } from './user-account';
export const JSON = GraphQLJSON;
export const Long = GraphQLLong;
export const UUID = GraphQLUUID;
export {
Query,
Subscription,
UserAccount
};

View File

@@ -0,0 +1,154 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { join } from 'path';
import { promises as fs, statSync, existsSync } from 'fs';
import { paths, log } from '@unraid/core';
// Consts
const ONE_BYTE = 1;
const ONE_KILOBYTE = ONE_BYTE * 1000;
const ONE_MEGABYTE = ONE_KILOBYTE * 1000;
const FIVE_MEGABYTE = ONE_MEGABYTE * 5;
const isOverFileSizeLimit = (filePath, limit = FIVE_MEGABYTE) => {
try {
const stats = statSync(filePath);
const fileSizeInBytes = stats.size;
return fileSizeInBytes > limit;
} catch {
// File likely doesn't exist or there was another error
return true;
}
};
const states = {
// Success
custom: {
url: '',
icon: 'custom',
error: '',
base64: ''
},
default: {
url: '',
icon: 'default',
error: '',
base64: ''
},
// Errors
couldNotReadConfigFile: {
url: '',
icon: 'custom',
error: 'could-not-read-config-file',
base64: ''
},
couldNotReadImage: {
url: '',
icon: 'custom',
error: 'could-not-read-image',
base64: ''
},
imageMissing: {
url: '',
icon: 'custom',
error: 'image-missing',
base64: ''
},
imageTooBig: {
url: '',
icon: 'custom',
error: 'image-too-big',
base64: ''
},
imageCorrupt: {
url: '',
icon: 'custom',
error: 'image-corrupt',
base64: ''
}
};
export default async () => {
const dynamixBasePath = paths.get('dynamix-base')!;
const configFilePath = join(dynamixBasePath, 'case-model.cfg');
const customImageFilePath = join(dynamixBasePath, 'case-model.png');
// If the config file doesn't exist then it's a new OS install
// Default to "default"
if (!existsSync(configFilePath)) {
return { case: states.default };
}
// Attempt to get case from file
const serverCase = await fs.readFile(configFilePath)
.then(buffer => buffer.toString().split('\n')[0])
.catch(() => 'error_reading_config_file');
// Config file can't be read, maybe a permissions issue?
if (serverCase === 'error_reading_config_file') {
return { case: states.couldNotReadConfigFile };
}
// Custom icon
if (serverCase.includes('.')) {
// Ensure image exists
if (!existsSync(customImageFilePath)) {
return { case: states.imageMissing };
}
// Ensure we're within size limits for the proxy
if (isOverFileSizeLimit(customImageFilePath)) {
log.debug('"custom-case.png" is too big to send to mothership.');
return { case: states.imageTooBig };
}
try {
// Get image buffer
const fileBuffer = await fs.readFile(customImageFilePath);
// Likely not an actual image
// 73 bytes is close to the smallest we can get https://garethrees.org/2007/11/14/pngcrush/
if (fileBuffer.length <= 25) {
return {
case: states.couldNotReadImage
}
}
return {
case: {
...states.custom,
base64: fileBuffer.toString('base64'),
url: serverCase
}
};
} catch (error) {
return {
case: states.couldNotReadImage
}
}
}
// Blank cfg file?
if (serverCase.trim().length === 0) {
return {
case: states.default
};
}
// Non-custom icon
return {
case: {
...states.default,
icon: serverCase
}
};
};

View File

@@ -0,0 +1,19 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import display from './display';
import info from './info';
import online from './online';
import server from './server';
import servers from './servers';
import vms from './vms';
export const Query = {
display,
info,
online,
vms,
server,
servers
};

View File

@@ -0,0 +1 @@
export default () => ({});

View File

@@ -0,0 +1,6 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
export default () => true;

View File

@@ -0,0 +1,23 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { utils } from '@unraid/core';
import type { Context } from '../../schema/utils';
import { getServers } from '../../schema/utils';
const { ensurePermission } = utils;
export default async (_: unknown, { name }, context: Context) => {
ensurePermission(context.user, {
resource: 'servers',
action: 'read',
possession: 'any'
});
const servers = await getServers().catch(() => []);
// Single server
return servers.find(server => server.name === name);
};

View File

@@ -0,0 +1,21 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { utils } from '@unraid/core';
import type { Context } from '../../schema/utils';
import { getServers } from '../../schema/utils';
const { ensurePermission } = utils;
export default (_: unknown, __: unknown, context: Context) => {
ensurePermission(context.user, {
resource: 'servers',
action: 'read',
possession: 'any'
});
// All servers
return getServers().catch(() => []);
};

View File

@@ -0,0 +1,6 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
export default () => ({});

View File

@@ -0,0 +1,86 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { pluginManager, pubsub, errors } from '@unraid/core';
import { hasSubscribedToChannel } from '../../../ws';
import { createSubscription, Context } from '../../schema/utils';
const { PluginError } = errors;
export const Subscription = {
display: {
...createSubscription('display')
},
apikeys: {
// Not sure how we're going to secure this
// ...createSubscription('apikeys')
},
array: {
...createSubscription('array')
},
// devices: {
// ...createSubscription('device')
// },
dockerContainers: {
...createSubscription('docker/container')
},
dockerNetworks: {
...createSubscription('docker/network')
},
info: {
...createSubscription('info')
},
ping: {
// subscribe: (_, __, context) => {
// // startPing();
// hasSubscribedToChannel(context.websocketId, 'ping');
// return pubsub.asyncIterator('ping');
// }
},
services: {
...createSubscription('services')
},
servers: {
...createSubscription('servers')
},
shares: {
...createSubscription('shares')
},
unassignedDevices: {
...createSubscription('devices/unassigned')
},
users: {
...createSubscription('users')
},
vars: {
...createSubscription('vars')
},
vms: {
...createSubscription('vms/domains')
},
pluginModule: {
subscribe: async (_: unknown, directiveArgs, context: Context) => {
const { plugin: pluginName, module: pluginModuleName } = directiveArgs;
const channel = `${pluginName}/${pluginModuleName}`;
// Verify plugin is installed and active
if (!pluginManager.isInstalled(pluginName, pluginModuleName)) {
throw new PluginError('Plugin not installed.', 500);
}
if (!pluginManager.isActive(pluginName, pluginModuleName)) {
throw new PluginError('Plugin disabled.', 500);
}
// It's up to the plugin to publish new data as needed
// so we'll just return the Iterator
hasSubscribedToChannel(context.websocketId, channel);
return pubsub.asyncIterator(channel);
}
},
online: {
...createSubscription('online')
}
};

View File

@@ -0,0 +1,15 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
export const UserAccount = {
__resolveType(obj) {
// Only a user has a password field, the current user aka "me" doesn't.
if (obj.password) {
return 'User';
}
return 'Me';
}
};

View File

@@ -1,2 +1,13 @@
export * from './resolvers';
export * from './type-defs';
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { join } from 'path';
import { fileLoader, mergeTypes } from 'merge-graphql-schemas';
const files = fileLoader(join(__dirname, './types/**/*.graphql'));
export const typeDefs = mergeTypes(files, {
all: true
});

View File

@@ -1,286 +0,0 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { pluginManager, pubsub, utils, bus, errors, states, modules, apiManager, log } from '@unraid/core';
import dee from '@gridplus/docker-events';
import { setIntervalAsync } from 'set-interval-async/dynamic';
import GraphQLJSON from 'graphql-type-json';
import GraphQLLong from 'graphql-type-long';
import GraphQLUUID from 'graphql-type-uuid';
import { run, publish } from '../../run';
import { userCache, CachedServer, CachedServers } from '../../cache';
import { hasSubscribedToChannel } from '../../ws';
import { getServers as getUserServers } from '../../utils'
const { ensurePermission } = utils;
const { usersState, varState, networkState } = states;
const { AppError, PluginError } = errors;
// Update array values when slots change
bus.on('slots', async () => {
// @todo: Create a system user for this
const user = usersState.findOne({ name: 'root' });
await run('array', 'UPDATED', {
moduleToRun: modules.getArray,
context: {
user
}
});
});
// Update info/hostname when hostname changes
bus.on('varstate', async (data) => {
const hostname = data.varstate.node.name;
// @todo: Create a system user for this
const user = usersState.findOne({ name: 'root' });
if (user) {
publish('info', 'UPDATED', {
os: {
hostname
}
});
}
});
// On Docker event update info with { apps: { installed, started } }
dee.on('*', async (data: { Type: string }) => {
// Only listen to container events
if (data.Type !== 'container') {
return;
}
// @todo: Create a system user for this
const user = usersState.findOne({ name: 'root' });
if (user) {
const { json } = await modules.getAppCount({
user
});
publish('info', 'UPDATED', {
apps: json
});
}
});
dee.listen();
// This needs to be fixed to run from events
setIntervalAsync(async () => {
// @todo: Create a system user for this
const user = usersState.findOne({ name: 'root' });
await run('services', 'UPDATED', {
moduleToRun: modules.getServices,
context: {
user
}
});
}, 1000);
interface Context {
user: any;
websocketId: string;
};
/**
* Create a pubsub subscription.
* @param channel The pubsub channel to subscribe to.
* @param resource The access-control permission resource to check against.
*/
const createSubscription = (channel: string, resource?: string) => ({
subscribe(_: unknown, __: unknown, context: Context) {
if (!context.user) {
throw new AppError('<ws> No user found in context.', 500);
}
// Check the user has permissison to subscribe to this endpoint
ensurePermission(context.user, {
resource: resource || channel,
action: 'read',
possession: 'any'
});
hasSubscribedToChannel(context.websocketId, channel);
return pubsub.asyncIterator(channel);
}
});
// Add null to types
type makeNullUndefinedAndOptional<T> = {
[K in keyof T]?: T[K] | null | undefined;
};
type Server = makeNullUndefinedAndOptional<CachedServer>;
const getServers = async (): Promise<Server[]> => {
const cachedServers = userCache.get<CachedServers>('mine')?.servers;
if (cachedServers) {
return cachedServers;
}
// For now use the my_servers key
// Later we should return the correct one for the current user with the correct scope, etc.
const apiKey = apiManager.getValidKeys().find(key => key.name === 'my_servers')?.key.toString()!;
// No cached servers found
if (!cachedServers) {
// Fetch servers from mothership
const servers = await getUserServers(apiKey);
log.debug('Using upstream for /servers endpoint');
// No servers found
if (!servers || servers.length === 0) {
return [];
}
// Cache servers
userCache.set<CachedServers>('mine', {
servers
});
// Return servers from mothership
return servers;
}
log.debug('Falling back to local state for /servers endpoint');
const guid = varState?.data?.regGuid;
const name = varState?.data?.name;
const wanip = null;
const lanip = networkState.data[0].ipaddr[0];
const localurl = `http://${lanip}:${varState?.data?.port}`;
const remoteurl = null;
return [{
owner: {
username: 'root',
url: '',
avatar: ''
},
guid,
apikey: apiKey,
name,
status: 'online',
wanip,
lanip,
localurl,
remoteurl
}];
};
export const resolvers = {
Query: {
online: () => true,
info: () => ({}),
vms: () => ({}),
async server(_: unknown, { name }, context: Context) {
ensurePermission(context.user, {
resource: 'servers',
action: 'read',
possession: 'any'
});
// Single server
return getServers().then(server => server.find(server => server.name === name));
},
servers(_: unknown, __: unknown, context: Context) {
ensurePermission(context.user, {
resource: 'servers',
action: 'read',
possession: 'any'
});
// All servers
return getServers();
}
},
Subscription: {
apikeys: {
// Not sure how we're going to secure this
// ...createSubscription('apikeys')
},
array: {
...createSubscription('array')
},
// devices: {
// ...createSubscription('device')
// },
dockerContainers: {
...createSubscription('docker/container')
},
dockerNetworks: {
...createSubscription('docker/network')
},
info: {
...createSubscription('info')
},
ping: {
// subscribe: (_, __, context) => {
// // startPing();
// hasSubscribedToChannel(context.websocketId, 'ping');
// return pubsub.asyncIterator('ping');
// }
},
services: {
...createSubscription('services')
},
servers: {
...createSubscription('servers')
},
shares: {
...createSubscription('shares')
},
unassignedDevices: {
...createSubscription('devices/unassigned')
},
users: {
...createSubscription('users')
},
vars: {
...createSubscription('vars')
},
vms: {
...createSubscription('vms/domains')
},
pluginModule: {
subscribe: async (_: unknown, directiveArgs, context: Context) => {
const {plugin: pluginName, module: pluginModuleName} = directiveArgs;
const channel = `${pluginName}/${pluginModuleName}`;
// Verify plugin is installed and active
if (!pluginManager.isInstalled(pluginName, pluginModuleName)) {
throw new PluginError('Plugin not installed.', 500);
}
if (!pluginManager.isActive(pluginName, pluginModuleName)) {
throw new PluginError('Plugin disabled.', 500);
}
// It's up to the plugin to publish new data as needed
// so we'll just return the Iterator
hasSubscribedToChannel(context.websocketId, channel);
return pubsub.asyncIterator(channel);
}
},
online: {
...createSubscription('online')
}
},
JSON: GraphQLJSON,
Long: GraphQLLong,
UUID: GraphQLUUID,
UserAccount: {
__resolveType(obj) {
// Only a user has a password field, the current user aka "me" doesn't.
if (obj.password) {
return 'User';
}
return 'Me';
}
}
};

View File

@@ -1,13 +0,0 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { join } from 'path';
import { fileLoader, mergeTypes } from 'merge-graphql-schemas';
const files = fileLoader(join(__dirname, './types/**/*.graphql'));
export const typeDefs = mergeTypes(files, {
all: true
});

View File

@@ -0,0 +1,18 @@
type Query {
display: Display
}
type Subscription {
display: Display
}
type Display {
case: Case
}
type Case {
icon: String
url: String
error: String
base64: String
}

105
app/graphql/schema/utils.ts Normal file
View File

@@ -0,0 +1,105 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { pubsub, utils, errors, states, apiManager, log } from '@unraid/core';
import { hasSubscribedToChannel } from '../../ws';
import { userCache, CachedServer, CachedServers } from '../../cache';
import { getServers as getUserServers } from '../../utils'
const { varState, networkState } = states;
const { ensurePermission } = utils;
const { AppError } = errors;
export interface Context {
user: any;
websocketId: string;
};
/**
* Create a pubsub subscription.
* @param channel The pubsub channel to subscribe to.
* @param resource The access-control permission resource to check against.
*/
export const createSubscription = (channel: string, resource?: string) => ({
subscribe(_: unknown, __: unknown, context: Context) {
if (!context.user) {
throw new AppError('<ws> No user found in context.', 500);
}
// Check the user has permissison to subscribe to this endpoint
ensurePermission(context.user, {
resource: resource || channel,
action: 'read',
possession: 'any'
});
hasSubscribedToChannel(context.websocketId, channel);
return pubsub.asyncIterator(channel);
}
});
// Add null to types
type makeNullUndefinedAndOptional<T> = {
[K in keyof T]?: T[K] | null | undefined;
};
type Server = makeNullUndefinedAndOptional<CachedServer>;
export const getServers = async (): Promise<Server[]> => {
const cachedServers = userCache.get<CachedServers>('mine')?.servers;
if (cachedServers) {
return cachedServers;
}
// For now use the my_servers key
// Later we should return the correct one for the current user with the correct scope, etc.
const apiKey = apiManager.getValidKeys().find(key => key.name === 'my_servers')?.key.toString()!;
// No cached servers found
if (!cachedServers) {
// Fetch servers from mothership
const servers = await getUserServers(apiKey);
log.debug('Using upstream for /servers endpoint');
// No servers found
if (!servers || servers.length === 0) {
return [];
}
// Cache servers
userCache.set<CachedServers>('mine', {
servers
});
// Return servers from mothership
return servers;
}
log.debug('Falling back to local state for /servers endpoint');
const guid = varState?.data?.regGuid;
const name = varState?.data?.name;
const wanip = null;
const lanip = networkState.data[0].ipaddr[0];
const localurl = `http://${lanip}:${varState?.data?.port}`;
const remoteurl = null;
return [{
owner: {
username: 'root',
url: '',
avatar: ''
},
guid,
apikey: apiKey,
name,
status: 'online',
wanip,
lanip,
localurl,
remoteurl
}];
};

80
app/my_servers.ts Normal file
View File

@@ -0,0 +1,80 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { utils, log, apiManager, paths, pubsub } from '@unraid/core';
import path from 'path';
import chokidar from 'chokidar';
import waitFor from 'p-wait-for';
import dotProp from 'dot-prop';
import display from './graphql/resolvers/query/display';
const { validateApiKeyFormat, loadState } = utils;
/**
* One second in milliseconds.
*/
const ONE_SECOND = 1000;
export const init = async () => {
const filePath = paths.get('dynamix-config')!;
const configFilePath = path.join(paths.get('dynamix-base')!, 'case-model.cfg');
const customImageFilePath = path.join(paths.get('dynamix-base')!, 'case-model.png');
const getApiKey = () => dotProp.get(loadState(filePath), 'remote.apikey') as string;
// Wait for api key to be valid
// We have to use await otherwise the module will keep loading without the apikey being added to the api manager
await waitFor(() => getApiKey() !== undefined, {
// Check every 1 second
interval: ONE_SECOND
}).then(() => {
log.debug('Found my_servers apiKey, adding to manager.');
// Add key to manager
apiManager.add('my_servers', getApiKey(), {
userId: '0'
});
});
// Update or remove key when file changes
chokidar.watch(filePath).on('all', () => {
// Invalidate old API key
apiManager.expire('my_servers');
// Get current API key
const apiKey = getApiKey();
// Ensure API key is in the correct format
try {
validateApiKeyFormat(apiKey);
} catch (error) {
return;
}
log.debug('my_servers API key was updated, updating ApiManager.');
log.debug('Using %s for my_servers API key', apiKey.replace(/./g, '*'));
process.nextTick(() => {
// Bail if we have no API key
if (apiKey === undefined) {
return;
}
// Either add or update the key
apiManager.add('my_servers', apiKey, {
userId: '0'
});
});
});
const updatePubsub = async () => {
pubsub.publish('display', {
display: await display()
});
};
// Update pub/sub when config/image file is added/updated/removed
chokidar.watch(configFilePath).on('all', updatePubsub);
chokidar.watch(customImageFilePath).on('all', updatePubsub);
};

View File

@@ -16,6 +16,10 @@ import { ApolloServer } from 'apollo-server-express';
import { log, config, utils, paths } from '@unraid/core';
import { graphql } from './graphql';
import { connectToMothership } from './mothership';
import { init as loadMyServers } from './my_servers';
// @todo: move this
loadMyServers();
const { getEndpoints, globalErrorHandler, exitApp, loadState, validateApiKeyFormat } = utils;
@@ -28,13 +32,13 @@ const ONE_SECOND = 1000;
* The Graphql server.
*/
const app = express();
const port = String(config.get('node-api-port'));
const port = process.env.PORT ?? '0' ?? String(config.get('port'));
app.use(async (_req, res, next) => {
// Only get the machine ID on first request
// We do this to avoid using async in the main server function
if (!app.get('x-machine-id')) {
// eslint-disable-next-line require-atomic-updates
app.set('x-machine-id', await utils.getMachineId());
}
@@ -158,6 +162,7 @@ stoppableServer.on('upgrade', (request, socket, head) => {
graphApp.installSubscriptionHandlers(wsServer);
export const server = {
httpServer,
server: stoppableServer,
async start() {
const filePath = paths.get('dynamix-config')!;

View File

@@ -1,7 +1,7 @@
import fetch from 'cross-fetch';
import * as Sentry from '@sentry/node';
import { MOTHERSHIP_GRAPHQL_LINK } from './consts';
import { CachedServer } from './cache';
import fetch from 'cross-fetch';
export const getServers = (apiKey: string) => fetch(MOTHERSHIP_GRAPHQL_LINK, {
method: 'POST',
@@ -17,9 +17,13 @@ export const getServers = (apiKey: string) => fetch(MOTHERSHIP_GRAPHQL_LINK, {
})
})
.then(async response => {
const data = await response.json();
return data.data.servers as Promise<CachedServer[]>;
const { data, errors } = await response.json();
if (errors) {
return new Error(errors[0].message);
}
return data.servers as Promise<CachedServer[]>;
})
.catch(error => {
Sentry.captureException(error);
return error;
});

24
ava.config.cjs Normal file
View File

@@ -0,0 +1,24 @@
const path = require('path');
const config = {
environmentVariables: {
DEBUG: 'true',
NCHAN: 'disable',
PATHS_UNRAID_DATA: path.resolve(__dirname, './dev/data'),
PATHS_STATES: path.resolve(__dirname, './dev/states'),
PATHS_DYNAMIX_BASE: path.resolve(__dirname, './dev/dynamix'),
PATHS_DYNAMIX_CONFIG: path.resolve(__dirname, './dev/dynamix/dynamix.cfg'),
API_KEY: 'TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST'
},
files: [
'./test/**/*'
],
extensions: [
'ts'
],
require: [
'ts-node/register/transpile-only'
]
};
module.exports = config;

191
dev/data/permissions.json Normal file
View File

@@ -0,0 +1,191 @@
{
"admin": {
"extends": "user",
"permissions": [
{
"resource": "apikey",
"action": "read:any",
"attributes": "*"
},
{
"resource": "array",
"action": "read:any",
"attributes": "*"
},
{
"resource": "cpu",
"action": "read:any",
"attributes": "*"
},
{
"resource": "device",
"action": "read:any",
"attributes": "*"
},
{
"resource": "device/unassigned",
"action": "read:any",
"attributes": "*"
},
{
"resource": "disk",
"action": "read:any",
"attributes": "*"
},
{
"resource": "disk/settings",
"action": "read:any",
"attributes": "*"
},
{
"resource": "display",
"action": "read:any",
"attributes": "*"
},
{
"resource": "docker/container",
"action": "read:any",
"attributes": "*"
},
{
"resource": "docker/network",
"action": "read:any",
"attributes": "*"
},
{
"resource": "license-key",
"action": "read:any",
"attributes": "*"
},
{
"resource": "memory",
"action": "read:any",
"attributes": "*"
},
{
"resource": "os",
"action": "read:any",
"attributes": "*"
},
{
"resource": "parity-history",
"action": "read:any",
"attributes": "*"
},
{
"resource": "permission",
"action": "read:any",
"attributes": "*"
},
{
"resource": "plugin",
"action": "read:any",
"attributes": "*"
},
{
"resource": "service",
"action": "read:any",
"attributes": "*"
},
{
"resource": "service/emhttpd",
"action": "read:any",
"attributes": "*"
},
{
"resource": "service/node-api",
"action": "read:any",
"attributes": "*"
},
{
"resource": "services",
"action": "read:any",
"attributes": "*"
},
{
"resource": "share",
"action": "read:any",
"attributes": "*"
},
{
"resource": "machine-id",
"action": "read:any",
"attributes": "*"
},
{
"resource": "unraid-version",
"action": "read:any",
"attributes": "*"
},
{
"resource": "software-versions",
"action": "read:any",
"attributes": "*"
},
{
"resource": "user",
"action": "read:any",
"attributes": "*"
},
{
"resource": "var",
"action": "read:any",
"attributes": "*"
},
{
"resource": "info",
"action": "read:any",
"attributes": "*"
},
{
"resource": "vm/domain",
"action": "read:any",
"attributes": "*"
},
{
"resource": "vm/network",
"action": "read:any",
"attributes": "*"
},
{
"resource": "servers",
"action": "read:any",
"attributes": "*"
},
{
"resource": "vars",
"action": "read:any",
"attributes": "*"
},
{
"resource": "online",
"action": "read:any",
"attributes": "*"
}
]
},
"user": {
"extends": "guest",
"permissions": [
{
"resource": "apikey",
"action": "read:own",
"attributes": "*"
},
{
"resource": "permission",
"action": "read:any",
"attributes": "*"
}
]
},
"guest": {
"permissions": [
{
"resource": "welcome",
"action": "read:any",
"attributes": "*"
}
]
}
}

View File

@@ -0,0 +1 @@
case-model.png

BIN
dev/dynamix/case-model.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -19,7 +19,7 @@ hot="45"
max="55"
sysinfo="/Tools/SystemProfiler"
[remote]
apikey="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
apikey="TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST"
wanaccess="yes"
wanport="0"
sshprivkey="-----BEGIN OPENSSH PRIVATE KEY-----

18474
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
"lint": "xo --verbose",
"lint:quiet": "xo --quiet",
"lint:fix": "xo --fix",
"test": "exit 0",
"test": "npx nyc@latest ava",
"cover": "npm run cover:unit && npm run cover:integration && npm run cover:report",
"cover:unit": "nyc --silent npm run test:unit",
"cover:integration": "nyc --silent --no-clean npm run test:integration",
@@ -39,6 +39,7 @@
"apollo-server": "2.18.2",
"apollo-server-express": "2.18.2",
"camelcase": "6.1.0",
"clean-cache": "github:omgimalexis/clean-cache",
"cross-fetch": "^3.0.6",
"dot-prop": "^6.0.0",
"express": "^4.17.1",
@@ -59,28 +60,48 @@
"stoppable": "^1.1.0",
"subscriptions-transport-ws": "^0.9.18"
},
"optionalDependencies": {},
"devDependencies": {
"@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0",
"@types/lodash.get": "^4.4.6",
"@types/semver-regex": "^3.1.0",
"@types/stoppable": "^1.1.0",
"@types/supertest": "^2.0.10",
"@types/uuid": "^8.3.0",
"ava": "^3.13.0",
"ava-env": "^2.0.2",
"bundle-dependencies": "^1.0.2",
"cpx": "1.5.0",
"cz-conventional-changelog": "3.3.0",
"husky": "4.3.0",
"modclean": "^3.0.0-beta.1",
"node-env-run": "^4.0.2",
"p-each-series": "^2.1.0",
"source-map-support": "0.5.19",
"standard-version": "^9.0.0",
"supertest": "^6.0.1",
"ts-node": "9.0.0",
"tsup": "2.0.3",
"typescript": "4.0.3",
"typescript-coverage-report": "^0.1.3",
"xo": "0.33.1"
},
"bundledDependencies": [
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"xo": {
"rules": {
"max-params": 0
}
},
"bundleDependencies": [
"@apollo/client",
"@gridplus/docker-events",
"@sentry/node",
@@ -108,21 +129,8 @@
"redact-secrets",
"set-interval-async",
"stoppable",
"subscriptions-transport-ws"
],
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"xo": {
"rules": {
"max-params": 0
}
}
"subscriptions-transport-ws",
"source-map-support",
"clean-cache"
]
}

143
test/_helpers.ts Normal file
View File

@@ -0,0 +1,143 @@
// @ts-check
import { ExecutionContext } from 'ava';
import supertest from 'supertest';
import { gql } from 'apollo-server-express';
import pEachSeries from 'p-each-series';
import pMap from 'p-map';
interface BasicInput {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
query?: string | object;
body?: string | object;
};
interface BasicExpected {
status?: number;
body?: {
[key: string]: any;
}
text: string;
};
interface GraphqlInput {
apiKey: string;
query: ReturnType<typeof gql> | string;
variables?: object;
};
interface GraphqlExpected {
data?: { [key: string]: any };
errors?: any[];
status?: number;
};
export const queryToString = query => typeof query === 'string' ? query : query?.loc?.source?.body;
export const sendGraphqlRequest = (request: ReturnType<typeof supertest>) => (input: GraphqlInput) => {
return request
.post('/graphql')
.set({
Accept: 'application/json',
'x-api-key': input.apiKey ?? '123456789'
})
.send({
query: queryToString(input.query),
...(input.variables ? { variables: input.variables } : {})
});
};
export const getObjectWithRegex = async (input: object, expected: object) => {
if (typeof input === 'object' && typeof expected === 'object') {
return pMap(Object.entries(input), async ([key, value]) => {
// Check regex
if (expected[key] instanceof RegExp) {
const regex = expected[key];
const match = regex.exec(input[key]);
// Reset lastIndex since this uses a /g flag
// https://stackoverflow.com/a/21123303/2311366
if (regex.global) {
expected[key].lastIndex = 0;
}
return [key, match ? match[0] : match];
}
if (typeof expected[key] === 'object') {
return [key, await getObjectWithRegex(input[key], expected[key])];
}
return [key, value];
}).then(entries => JSON.parse(JSON.stringify(Object.fromEntries(entries as []))));
}
};
export const checkObject = async (t: ExecutionContext, input: object, expected: object) => {
if (typeof input === 'object' && typeof expected === 'object') {
return pEachSeries(Object.entries(expected), ([key, value]) => {
// Check regex
if (value instanceof RegExp) {
t.regex(`${input[key]}`, value);
return;
}
// It's an object, let's go deeper
if (typeof value === 'object') {
return checkObject(t, input[key], value);
}
// Check normal value
t.is(input[key], value);
});
}
};
/**
* Basic request helper
*/
export const createBasicRequestMacro = (request: ReturnType<typeof supertest>) => async (t: ExecutionContext, input: BasicInput, expected: BasicExpected) => {
// Send request
const response = await request[input.method.toLowerCase()](input.url).set('Accept', 'application/json').query(input.query).send(input.body);
// If we expect an "ok" then check we have a body
if (expected.status === 200) {
t.false(response.body === undefined);
}
// String response
if (typeof expected.text === 'string') {
t.is(response.text, expected.text);
}
// Object response
if (typeof expected.body === 'object') {
// Compare body including running any regex we have
await checkObject(t, response.body, expected.body);
// Snapshot
t.snapshot(await getObjectWithRegex(response.body, expected.body), queryToString(input.query));
}
};
/**
* Graphql request helper
*/
export const createGraphqlRequestMacro = (request: ReturnType<typeof supertest>) => async (t: ExecutionContext, input: GraphqlInput, expected: GraphqlExpected) => {
const response = await sendGraphqlRequest(request)(input);
// If we expect an "ok" then check we have no errors,
// else check the errors against our errors array
if (expected.status === 200) {
t.is(response.body.errors, undefined);
} else {
// We need to use "deepEqual" instead of "is" since this is an array
t.deepEqual(response.body.errors, expected.errors);
}
// Compare body data including running any regex we have
await checkObject(t, response.body.data, expected.data);
// Snapshot
t.snapshot(await getObjectWithRegex(response.body.data, expected.data), queryToString(input.query));
};

View File

@@ -0,0 +1,191 @@
{
"admin": {
"extends": "user",
"permissions": [
{
"resource": "apikey",
"action": "read:any",
"attributes": "*"
},
{
"resource": "array",
"action": "read:any",
"attributes": "*"
},
{
"resource": "cpu",
"action": "read:any",
"attributes": "*"
},
{
"resource": "device",
"action": "read:any",
"attributes": "*"
},
{
"resource": "device/unassigned",
"action": "read:any",
"attributes": "*"
},
{
"resource": "disk",
"action": "read:any",
"attributes": "*"
},
{
"resource": "disk/settings",
"action": "read:any",
"attributes": "*"
},
{
"resource": "display",
"action": "read:any",
"attributes": "*"
},
{
"resource": "docker/container",
"action": "read:any",
"attributes": "*"
},
{
"resource": "docker/network",
"action": "read:any",
"attributes": "*"
},
{
"resource": "license-key",
"action": "read:any",
"attributes": "*"
},
{
"resource": "memory",
"action": "read:any",
"attributes": "*"
},
{
"resource": "os",
"action": "read:any",
"attributes": "*"
},
{
"resource": "parity-history",
"action": "read:any",
"attributes": "*"
},
{
"resource": "permission",
"action": "read:any",
"attributes": "*"
},
{
"resource": "plugin",
"action": "read:any",
"attributes": "*"
},
{
"resource": "service",
"action": "read:any",
"attributes": "*"
},
{
"resource": "service/emhttpd",
"action": "read:any",
"attributes": "*"
},
{
"resource": "service/node-api",
"action": "read:any",
"attributes": "*"
},
{
"resource": "services",
"action": "read:any",
"attributes": "*"
},
{
"resource": "share",
"action": "read:any",
"attributes": "*"
},
{
"resource": "machine-id",
"action": "read:any",
"attributes": "*"
},
{
"resource": "unraid-version",
"action": "read:any",
"attributes": "*"
},
{
"resource": "software-versions",
"action": "read:any",
"attributes": "*"
},
{
"resource": "user",
"action": "read:any",
"attributes": "*"
},
{
"resource": "var",
"action": "read:any",
"attributes": "*"
},
{
"resource": "info",
"action": "read:any",
"attributes": "*"
},
{
"resource": "vm/domain",
"action": "read:any",
"attributes": "*"
},
{
"resource": "vm/network",
"action": "read:any",
"attributes": "*"
},
{
"resource": "servers",
"action": "read:any",
"attributes": "*"
},
{
"resource": "vars",
"action": "read:any",
"attributes": "*"
},
{
"resource": "online",
"action": "read:any",
"attributes": "*"
}
]
},
"user": {
"extends": "guest",
"permissions": [
{
"resource": "apikey",
"action": "read:own",
"attributes": "*"
},
{
"resource": "permission",
"action": "read:any",
"attributes": "*"
}
]
},
"guest": {
"permissions": [
{
"resource": "welcome",
"action": "read:any",
"attributes": "*"
}
]
}
}

80
test/app/graphql.ts Normal file
View File

@@ -0,0 +1,80 @@
import ava, { TestInterface } from 'ava';
import getSemverRegex from 'semver-regex';
import { agent } from 'supertest';
import { gql } from 'apollo-server-express';
import { server } from '../../app/server';
import { createGraphqlRequestMacro } from '../_helpers';
const semverRegex = getSemverRegex();
const test = ava as TestInterface<{
requestMacro: ReturnType<typeof createGraphqlRequestMacro>;
apiKey: string;
}>;
test.beforeEach(async t => {
const request = agent(server.httpServer);
t.context.requestMacro = createGraphqlRequestMacro(request);
t.context.apiKey = 'TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST';
});
test('fetch current user', async t => {
await t.context.requestMacro(t, {
query: gql`
query {
me {
id
name
}
}
`,
apiKey: t.context.apiKey
}, {
status: 200,
data: {
me: {
id: /([0-9]+)/,
name: 'root'
}
}
});
});
test('fetch /info', async t => {
await t.context.requestMacro(t, {
query: gql`
query {
info {
apps {
installed
started
}
machineId
os {
hostname
}
versions {
unraid
}
}
}
`,
apiKey: t.context.apiKey
}, {
status: 200,
data: {
info: {
apps: {
installed: /[0-9]/,
started: /[0-9]/
},
os: {
hostname: /([a-z0-9\-\.]+)/i
},
versions: {
unraid: semverRegex
}
}
}
});
});

View File

@@ -0,0 +1,24 @@
import ava, { TestInterface } from 'ava';
import display from '../../../../../app/graphql/resolvers/query/display';
const test = ava as TestInterface<{
apiKey: string;
}>;
test.beforeEach(async t => {
t.context.apiKey = 'TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST';
});
test('icons', async t => {
const result = await display();
// Check base64 seperately
const { base64, ...rest } = result.case;
t.regex(base64, new RegExp('^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'));
t.deepEqual(rest, {
url: 'case-model.png',
icon: 'custom',
error: ''
});
});

View File

@@ -0,0 +1,59 @@
# Snapshot report for `test/app/graphql.ts`
The actual snapshot is saved in `graphql.ts.snap`.
Generated by [AVA](https://avajs.dev).
## fetch /info
>
query {
info {
apps {
installed
started
}
machineId
os {
hostname
}
versions {
unraid
}
}
}
{
info: {
apps: {
installed: '0',
started: '0',
},
machineId: '',
os: {
hostname: 'Sophie-2.local',
},
versions: {
unraid: null,
},
},
}
## fetch current user
>
query {
me {
id
name
}
}
{
me: {
id: '0',
name: 'root',
},
}

Binary file not shown.

View File

@@ -0,0 +1,59 @@
# Snapshot report for `test/app/test.ts`
The actual snapshot is saved in `test.ts.snap`.
Generated by [AVA](https://avajs.dev).
## fetch /info
>
query {
info {
apps {
installed
started
}
machineId
os {
hostname
}
versions {
unraid
}
}
}
{
info: {
apps: {
installed: '0',
started: '0',
},
machineId: '',
os: {
hostname: 'Sophie-2.local',
},
versions: {
unraid: null,
},
},
}
## fetch current user
>
query {
me {
id
name
}
}
{
me: {
id: '0',
name: 'root',
},
}

Binary file not shown.