refactor: add core into node-api

This commit is contained in:
Alexis Tyler
2020-11-11 16:13:30 +10:30
parent 9866414544
commit 04120e7373
208 changed files with 8699 additions and 17467 deletions
+15 -7
View File
@@ -1,6 +1,6 @@
# @unraid/node-api
Graphql wrapper around [@unraid/core](https://github.com/unraid/core).
Unraid API
## Installation
@@ -13,7 +13,7 @@ This script should be run automatically on every boot.
## Connecting
### HTTP
This can be accessed by default via `http://tower.local/graph`. If the server is connected to my servers then it's likely to have a DNS hash address, something like `https://www.__HASH_HERE__.unraid.net/graph`.
This can be accessed by default via `http://tower.local/graphql`. If the server is connected to my servers then it's likely to have a DNS hash address, something like `https://www.__HASH_HERE__.unraid.net/graphql`.
See https://graphql.org/learn/serving-over-http/#http-methods-headers-and-body
@@ -22,14 +22,14 @@ If you're using the ApolloClient please see https://github.com/apollographql/sub
## Logs
If installed on a unraid machine logs can be accessed via `/etc/rc.d/rc.unraid-api logs` or directly at `/var/run/graphql-api.log`; otherwise please see stdout.
If installed on a unraid machine logs can be accessed via syslog.
Debug logs can be enabled via `/etc/rc.d/rc.unraid-api debug` or by sending a USR2 signal to the supervisor process.
Debug logs can be enabled via stdout while running with `start-debug`.
## Playground
The playground can be enabled via `DEBUG=true /etc/rc.d/rc.unraid-api start`.
To get your api key open a terminal on your server and run `cat /boot/config/plugins/dynamix/dynamix.cfg | grep apikey= | cut -d '"' -f2`. Add that api key in the "HTTP headers" panel of the playground.
The playground can be access via `http://tower.local/graphql` while `PLAYGROUND=true` and `INTROSPECTION=true`. These values can be set in the `ecosystem.config.js` file in `/usr/local/bin/node/node-api`.
To get your API key open a terminal on your server and run `cat /boot/config/plugins/dynamix/dynamix.cfg | grep apikey= | cut -d '"' -f2`. Add that api key in the "HTTP headers" panel of the playground.
```json
{
@@ -64,7 +64,15 @@ For exploring the schema visually I'd suggest using [Voyager](https://apis.guru/
## Running this locally
```bash
NCHAN=disable DEBUG=true LOG_LEVEL=info PATHS_STATES=$(pwd)/dev/states PATHS_DYNAMIX_CONFIG=$(pwd)/dev/dynamix.cfg PORT=5000 node index.js
NCHAN=disable \ # Disable nchan polling
MOTHERSHIP_RELAY_WS_LINK=ws://localhost:8000 \ # Switch to local copy of mothership
DEBUG=true \ # Enable debug logging
PATHS_UNRAID_DATA=$(pwd)/dev/data \ # Where we store plugin data (e.g. permissions.json)
PATHS_STATES=$(pwd)/dev/states \ # Where .ini files live (e.g. vars.ini)
PATHS_DYNAMIX_BASE=$(pwd)/dev/dynamix \ # Dynamix's data directory
PATHS_DYNAMIX_CONFIG=$(pwd)/dev/dynamix/dynamix.cfg \ # Dynamix's config file
PORT=8500 \ # What port node-api should start on (e.g. /var/run/node-api.sock or 8000)
node index.js
```
## License
+1 -1
View File
@@ -1,4 +1,4 @@
import { config } from '@unraid/core';
import { config } from './core/config';
const internalWsAddress = () => {
const port = config.get('port');
+197
View File
@@ -0,0 +1,197 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import toMillisecond from 'ms';
import { Cache as MemoryCache } from 'clean-cache';
// @ts-ignore
import { validate as validateArgument } from 'bycontract';
export interface CacheItem {
/** Machine readable name of the key. */
name: string;
/** Owner's id */
userId: string;
/** The API key. */
key: string | number;
/** When the key will expire in human readable form. This will be converted internally to ms. */
expiration: string;
}
export interface AddOptions {
/** Owner's id */
userId?: string;
/** When the key will expire in human readable form. This will be converted internally to ms. */
expiration?: string;
}
interface ApiKey {
name: string;
key: string | number;
userId: string;
expiresAt: number;
}
/**
* Api manager
*/
export class ApiManager {
private static instance: ApiManager;
/** Note: Keys expire by default after 365 days. */
private readonly keys = new MemoryCache<CacheItem>(Number(toMillisecond('1y')));
constructor() {
if (ApiManager.instance) {
// This is needed as this is a singleton class
// @eslint-disable-next-line no-constructor-return
return ApiManager.instance;
}
ApiManager.instance = this;
}
/**
* Add a new key.
*
* Note: Keys expire by default after 365 days.
*
* @memberof ApiManager
*/
add(name: string, key: string|number, options: AddOptions): void {
const { userId, expiration = '1y' } = options;
validateArgument(name, 'string');
validateArgument(key, 'string|number');
validateArgument(expiration, 'string|number');
const ttl = Number(toMillisecond(expiration));
this.keys.add(name, {
name,
key,
userId
}, ttl);
}
/**
* Is valid based on "name and key" or just "key".
*
* @param nameOrKey The name or key of the API key.
* @param key The API key.
* @returns `true` if the key is valid, otherwise `false`.
* @memberof ApiManager
*/
isValid(nameOrKey: string|number, key?: string|number): boolean {
validateArgument(nameOrKey, 'string|number');
validateArgument(key, 'string|number|undefined');
if (!key) {
try {
const name = this.getNameFromKey(nameOrKey);
if (!name) {
return false;
}
// We still have to run the retrieve after finding the key
// as this will run the cache validation check
// without this the key would be "valid" even after
// it's over the cache time
return this.keys.get(name) !== null;
} catch {
return false;
}
}
const foundKey = this.keys.get(`${nameOrKey}`)?.key;
if (!foundKey) {
return false;
}
return foundKey === key;
}
/**
* Return key based on name.
*
* @param name The API key's machine readable name.
* @returns {Object} The API key based on the name provided.
* @memberof ApiManager
*/
getKey(name: string): CacheItem | null {
validateArgument(name, 'string');
return this.keys.get(name);
}
/**
* Is key expired based on name.
*
* @param name The API key's machine readable name.
* @returns `true` if the key has expired, otherwise `false`.
* @memberof ApiManager
*/
expired(name: string): boolean {
validateArgument(name, 'string');
return this.keys.get(name) === null;
}
/**
* Invalidate an API Key.
*
* @param name The API key's machine readable name.
* @memberof ApiManager
*/
expire(name: string): void {
validateArgument(name, 'string');
this.keys.invalidate(name);
}
/**
* Return all valid API keys.
*
* @returns All of the API keys.
* @memberof ApiManager
*/
getValidKeys(): ApiKey[] {
const keys = Object.entries(this.keys.items);
return keys
.filter(([, item]) => this.isValid(item.value.key))
.map(([name, item]) => ({
name,
// @ts-ignore
key: item.value.key,
userId: item.value.userId,
expiresAt: item.expiresAt
}));
}
/**
* Return the key's name based on the key value.
*
* @param key The API key.
* @returns The API key's machine readable name.
* @memberof ApiManager
*/
getNameFromKey(key: string|number): string {
validateArgument(key, 'string|number');
const keyObject = Object
.entries(this.keys.items)
// @ts-ignore
.find(([_, item]) => item.value.key === key);
if (!keyObject) {
throw new Error(`No name found for "${key}".`);
}
return keyObject[0];
}
}
export const apiManager = new ApiManager();
+12
View File
@@ -0,0 +1,12 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import NanoBus from 'nanobus';
/**
* Main event bus.
*/
export const bus = new NanoBus();
+54
View File
@@ -0,0 +1,54 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { Cache } from 'clean-cache';
export const caches = new Map<string, Cache>();
// In seconds
const ONE_MINUTE = 60 * 1000;
/**
* Cache manager.
*/
export class CacheManager {
ttl: number;
constructor(private readonly name: string, ttl: number = ONE_MINUTE) {
// Get cache
let cache = caches.get(name);
this.ttl = ttl;
// Create new cache if we can't find one
if (!cache) {
cache = new Cache(ttl);
caches.set(name, cache);
}
}
get<T>(key: string): T {
return caches.get(this.name)?.get(key);
}
set<T = null>(key: string, value: T, ttl?: number): T {
// Get cache
const cache = caches.get(this.name);
// Check for existing entry and return that
const item: T = cache?.get(key);
if (item) {
return item;
}
// Update cache
cache?.add(key, value, ttl ?? this.ttl ?? ONE_MINUTE);
return value;
}
keys(): string[] {
return [...caches.keys()];
}
}
+22
View File
@@ -0,0 +1,22 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { envs } from './envs';
const debug = envs.DEBUG;
const nodeEnv = envs.NODE_ENV;
const safe = nodeEnv === 'safe-mode';
const dev = nodeEnv === 'development';
/**
* Main config.
*/
export const config = new Map<string, boolean | string | number>([
['debug', debug],
['node-env', nodeEnv],
['safe-mode', safe],
['port', envs.PORT ?? (dev ? 5000 : '/var/run/unraid-api.sock')],
['system-version-cache-expiry', 30000] // 30s
]);
+318
View File
@@ -0,0 +1,318 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { StoppableServer } from 'stoppable';
import path from 'path';
import glob from 'glob';
import exitHook from 'async-exit-hook';
import camelCase from 'camelcase';
import globby from 'globby';
import pWaitFor from 'p-wait-for';
import getServerAddress from 'get-server-address';
import pIteration from 'p-iteration';
import clearModule from 'clear-module';
import { log } from './log';
import { paths } from './paths';
import { subscribeToNchanEndpoint, isNchanUp } from './utils';
import { config } from './config';
import { pluginManager } from './plugin-manager';
import * as watchers from './watchers';
// Has server been started
let serverUp = false;
// Have plugins loaded at least once
let pluginsLoaded = false;
// Magic values
const ONE_SECOND = 1000;
const TEN_SECONDS = 10 * ONE_SECOND;
/**
* Decorated loading logger.
* @param namespace
* @param all
* @param filePath
*/
const loadingLogger = (namespace: string, all: boolean, filePath: string): void => {
log.debug('Loading @unraid/core:%s%s from %s', namespace, all ? ':*' : '', filePath);
};
/**
* Register core path.
*/
const loadCorePath = async(): Promise<void> => {
const filePath = __dirname;
// Don't override already set path
if (!paths.has('core')) {
paths.set('core', filePath);
}
loadingLogger('paths:core', false, paths.get('core')!);
};
/**
* Register state paths.
*/
const loadStatePaths = async(): Promise<void> => {
const statesCwd = paths.get('states')!;
const cwd = path.join(__dirname, 'states');
loadingLogger('paths:state', true, cwd);
const states = glob.sync('*.js', { cwd }).map(state => state.replace('.js', ''));
states.forEach(state => {
const name = `state:${camelCase(state, { pascalCase: true })}`;
const filePath = `${path.join(statesCwd, state)}.ini`;
// Don't override already set paths
// @ts-ignore
if (!paths.has(name)) {
// ['state:Users', '/usr/local/emhttp/state/users.ini']
// @ts-ignore
paths.set(name, filePath);
}
});
};
/**
* Register all plugins with PluginManager.
*/
const loadPlugins = async(): Promise<void> => {
const pluginsCwd = paths.get('plugins');
if (config.get('safe-mode') || !pluginsCwd) {
log.debug('Skipping loading plugins');
return;
}
const packages = globby
.sync(['**/package.json', '!**/node_modules/**'], { cwd: pluginsCwd })
// Remove all files
.filter(packageName => packageName.includes('/'));
const plugins = packages.map(plugin => plugin.replace('/package.json', ''));
loadingLogger('plugins', false, pluginsCwd);
// Reset caches so plugins can load from fresh state
if (pluginsLoaded) {
// Reset plugin manager
pluginManager.reset();
// Reset require cache
// Without this plugin files wouldn't update until the server restarts
await pIteration.forEach(plugins, async pluginName => {
const cwd = path.join(pluginsCwd, pluginName);
const pluginFiles = globby.sync(['**/*', '!**/node_modules/**'], { cwd });
await pIteration.forEach(pluginFiles, pluginFile => {
const filePath = path.join(pluginsCwd, pluginFile);
log.debug('Clearing plugin file from require cache %s', filePath);
clearModule(filePath);
});
});
} else {
// Update flag
pluginsLoaded = true;
}
// Initialize all plugins with plugin manager
await pIteration.forEach(plugins, async pluginName => pluginManager.init(pluginName));
};
/**
* Start all watchers.
*/
const loadWatchers = async(): Promise<void> => {
if (config.get('safe-mode')) {
log.debug('Skipping loading watchers');
return;
}
const watchersCwd = path.join(__dirname, 'watchers');
loadingLogger('watchers', true, watchersCwd);
// Start each watcher
Object.values(watchers).forEach(watcher => {
watcher().start();
});
};
/**
* Add api keys for users, etc.
*
* @name core.loadApiKeys
* @async
* @private
*/
const loadApiKeys = async(): Promise<void> => {
// @todo: We should keep apikeys saved somewhere
// if (!apiManager.getKey('user:root')) {
// const filePath = paths.get('dynamix-config')!;
// const config = loadState<DynamixConfig>(filePath);
// const key = config?.remote?.apikey;
// if (key) {
// apiManager.add('my_servers', {
// key,
// userId: '0'
// });
// }
// }
};
/**
* Connect to nchan endpoints.
*
* @param endpoints
*/
const connectToNchanEndpoints = async(endpoints: string[]): Promise<void> => {
log.debug('Connected to nchan, setting-up endpoints.');
const connections = endpoints.map(async endpoint => subscribeToNchanEndpoint(endpoint));
await Promise.all(connections);
};
/**
* Start nchan subscriptions
*
* @name core.loadNchan
* @async
* @private
*/
const loadNchan = async(): Promise<void> => {
const endpoints = ['devs', 'disks', 'sec', 'sec_nfs', 'shares', 'users', 'var'];
log.debug('Trying to connect to nchan');
// Wait for nchan to be up.
await pWaitFor(isNchanUp, {
timeout: TEN_SECONDS,
interval: ONE_SECOND
})
// Once connected open a connection to each known endpoint
.then(async() => connectToNchanEndpoints(endpoints))
.catch(error => {
// Nchan is likely unreachable
if (error.message.includes('Promise timed out')) {
log.error('Nchan timed out while trying to establish a connection.');
return;
}
// Some other error occured
throw error;
});
};
/**
* Core loaders.
*/
const loaders = {
corePath: loadCorePath,
statePaths: loadStatePaths,
plugins: loadPlugins,
watchers: loadWatchers
};
/**
* Main load function
*
* @name core.load
*/
const load = async(): Promise<void> => {
log.debug('Starting @unraid/core');
await loadCorePath();
await loadStatePaths();
await loadPlugins();
await loadWatchers();
await loadApiKeys();
// Load nchan
if (process.env.NCHAN !== 'disable') {
await loadNchan();
}
log.debug('Started @unraid/core');
};
/**
* A server instance.
*/
interface Server {
server: StoppableServer;
start: () => Promise<StoppableServer> | StoppableServer;
stop: () => Promise<void> | void;
}
/**
* Stop a server
*
* @param name The server instance name.
* @param server The server instance.
*/
const stopServer = async(name: string, server: Server): Promise<void> => {
if (!serverUp) {
log.debug(`${name} is already shutting down.`);
}
// Ensure we go back to the start of the line
// this causes the ^C the be overridden
process.stdout.write('\r');
log.info(`Stopping ${name}`);
// Stop the server
await server.stop();
};
/**
* Start a server
*
* @param name The server instance name.
* @param server The server instance.
*/
const startServer = async(name: string, server: Server): Promise<Server> => {
// Log only if the server actually binds to the port
server.server.on('listening', () => {
log.info('Listening at %s.', getServerAddress(server.server));
});
// Start server
await server.start();
log.debug(`Started ${name}`);
return server;
};
/**
* Loads a server.
*
* @name core.loadServer
* @param name The name of the server instance to load.
*/
export const loadServer = async(name: string, server: Server): Promise<void> => {
// Set process title
process.title = name;
// Human readable name
const serverName = `@unraid/${name}`;
// Start the server.
log.debug(`Starting ${serverName}`);
await startServer(serverName, server);
// Prevents SIGINT calling close multiple times
serverUp = true;
// On process exit
exitHook(async() => {
// Stop the server
await stopServer(name, server);
});
};
export const core = {
loaders,
load,
loadServer
};
+61
View File
@@ -0,0 +1,61 @@
export const admin = {
extends: 'user',
permissions: [
// @NOTE: Uncomment the first line to enable creation of api keys.
// See the README.md for more information.
// @WARNING: This is currently unsupported, please be careful.
// { resource: 'apikey', action: 'create:any', attributes: '*' },
{ 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: '*' }
]
};
export const user = {
extends: 'guest',
permissions: [
{ resource: 'apikey', action: 'read:own', attributes: '*' },
{ resource: 'permission', action: 'read:any', attributes: '*' }
]
};
export const guest = {
permissions: [
{ resource: 'welcome', action: 'read:any', attributes: '*' }
]
};
export const permissions = {
admin,
user,
guest
}
+39
View File
@@ -0,0 +1,39 @@
import stw from 'spread-the-word';
import { boolToString } from '../utils';
import { varState } from '../states';
import { AppError } from '../errors';
/**
* Announce to the local network via mDNS.
*/
export const announce = async(): Promise<void> => {
const name = varState.data?.name;
const localTld = varState.data?.localTld;
const version = varState.data?.version;
if (!name || !localTld || !version) {
throw new AppError('Missing require fields to announce.');
}
await stw.spread({
name,
type: 'unraid',
subtypes: [],
protocol: 'http',
hostname: `${name}.${localTld}`,
port: 80,
txt: {
is_setup: boolToString(false),
version,
// By default new servers won't need a key
requires_api_key: boolToString(false)
}
}).catch(error => {
// We need to change our hostname
if (error.messae === 'service_exists') {
throw new AppError('Hostname is taken.');
}
throw error;
});
};
+4
View File
@@ -0,0 +1,4 @@
// Created from 'create-ts-index'
export * from './announce';
export * from './listen';
+24
View File
@@ -0,0 +1,24 @@
import stw from 'spread-the-word';
import { log } from '../log';
/**
* Listen to devices on the local network via mDNS.
*/
export const listen = (): void => {
stw
.on('up', service => {
if (service.type === 'unraid') {
if (service.txt?.is_setup === 'false') {
const ipv4 = service.addresses.find(address => address.includes('.'));
const ipv6 = service.addresses.find(address => address.includes(':'));
log.info(`Found a new local server [${ipv4 ?? ipv6}], visit your my servers dashboard to claim.`);
}
}
// Console.log(`${service.name} is up! (from ${referrer.address}`);
})
.on('down', (remoteService, _res, referrer) => {
log.debug(`${remoteService.name} is down! (from ${referrer.address})`);
});
stw.listen();
};
+20
View File
@@ -0,0 +1,20 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
const _ = process.env;
/**
* Proxy for process.env
*
* @note Add known envs here for better typing
*/
export const envs = {
..._,
NODE_ENV: _.NODE_ENV!,
DEBUG: _.DEBUG === 'true',
PORT: _.PORT!,
NODE_API_PORT: _.NODE_API_PORT!,
DRY_RUN: Boolean(_.DRY_RUN)
};
+15
View File
@@ -0,0 +1,15 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* API key error.
*/
export class ApiKeyError extends AppError {
constructor(message: string) {
super(message);
}
}
+42
View File
@@ -0,0 +1,42 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
/**
* Generic application error.
*/
export class AppError extends Error {
/** The HTTP status associated with this error. */
status: number;
/** Should we kill the application when thrown. */
fatal = false;
constructor(message: string, status?: number) {
// Calling parent constructor of base Error class.
super(message);
// Saving class name in the property of our custom error as a shortcut.
this.name = this.constructor.name;
// Capturing stack trace, excluding constructor call from it.
Error.captureStackTrace(this, this.constructor);
// We're using HTTP status codes with `500` as the default
this.status = status ?? 500;
}
/**
* Convert error to JSON format.
*/
toJSON() {
return {
error: {
name: this.name,
message: this.message,
stacktrace: this.stack
}
};
}
}
+15
View File
@@ -0,0 +1,15 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* The attempted operation can only be processed while the array is stopped.
*/
export class ArrayRunningError extends AppError {
constructor() {
super('Array needs to be stopped before any changes can occur.');
}
}
+15
View File
@@ -0,0 +1,15 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { FatalAppError } from './fatal-error';
/**
* Atomic write error
*/
export class AtomicWriteError extends FatalAppError {
constructor(message: string, private readonly filePath: string, status = 500) {
super(message, status);
}
}
+16
View File
@@ -0,0 +1,16 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { FatalAppError } from './fatal-error';
/**
* Em cmd client error.
*/
export class EmCmdError extends FatalAppError {
constructor(method: string, option: string, options: string[]) {
const message = `Invalid option "${option}" for ${method}, allowed options ${JSON.stringify(options)}`;
super(message);
}
}
+13
View File
@@ -0,0 +1,13 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* Fatal application error.
*/
export class FatalAppError extends AppError {
fatal = true;
}
+16
View File
@@ -0,0 +1,16 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* Module is missing a needed field
*/
export class FieldMissingError extends AppError {
constructor(private readonly field: string) {
// Overriding both message and status code.
super('Field missing: ' + field, 400);
}
}
+19
View File
@@ -0,0 +1,19 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* The provided file is missing
*/
export class FileMissingError extends AppError {
/**
* @hideconstructor
*/
constructor(private readonly filePath: string) {
// Overriding both message and status code.
super('File missing: ' + filePath, 400);
}
}
+16
View File
@@ -0,0 +1,16 @@
// Created from 'create-ts-index'
export * from './api-key-error';
export * from './app-error';
export * from './array-running-error';
export * from './atomic-write-error';
export * from './em-cmd-error';
export * from './fatal-error';
export * from './field-missing-error';
export * from './file-missing-error';
export * from './not-implemented-error';
export * from './param-invalid-error';
export * from './param-missing-error';
export * from './permission-error';
export * from './php-error';
export * from './plugin-error';
+16
View File
@@ -0,0 +1,16 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* Whatever this is attached to isn't yet implemented.
* Sorry about that. 😔
*/
export class NotImplementedError extends AppError {
constructor() {
super('Not implemented!');
}
}
+16
View File
@@ -0,0 +1,16 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* Invalid param provided to module
*/
export class ParamInvalidError extends AppError {
constructor(parameterName: string, parameter) {
// Overriding both message and status code.
super(`Param invalid: ${parameterName} = ${parameter}`, 500);
}
}
+16
View File
@@ -0,0 +1,16 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* Required param is missing
*/
export class ParameterMissingError extends AppError {
constructor(parameterName: string) {
// Override both message and status code.
super(`Param missing: ${parameterName}`, 500);
}
}
+15
View File
@@ -0,0 +1,15 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* Non fatal permission error
*/
export class PermissionError extends AppError {
constructor(message: string) {
super(message || 'Permission denied!');
}
}
+11
View File
@@ -0,0 +1,11 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* Error bubbled up from a PHP script.
*/
export class PhpError extends AppError {}
+11
View File
@@ -0,0 +1,11 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from './app-error';
/**
* Wrapped error thrown by a plugin
*/
export class PluginError extends AppError {}
+19
View File
@@ -0,0 +1,19 @@
export * as discovery from './discovery';
export * as errors from './errors';
export * as modules from './modules';
export * as notifiers from './notifiers';
export * as states from './states';
export * as utils from './utils';
export * as watchers from './watchers';
export * from './api-manager';
export * from './bus';
export * from './cache-manager';
export * from './config';
export * from './core';
export * from './envs';
export * from './log';
export * from './paths';
export * from './permission-manager';
export * from './permissions';
export * from './plugin-manager';
export * from './pubsub';
+132
View File
@@ -0,0 +1,132 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { existsSync } from 'fs';
import { format } from 'util';
import { redactSecrets } from 'redact-secrets';
import SysLogger from 'ain2';
import { config } from './config';
const noop = () => {};
// If warning is selected then only 4, 5 and 6 will be shown
// All others will be set to a noop function.
const levels = {
trace: 0 as const,
debug: 1 as const,
info: 2 as const,
warning: 3 as const,
error: 4 as const,
fatal: 5 as const,
silent: 6 as const
};
const env = config.get('node-env');
const prod = env === 'production';
const silent = env === 'test';
const debug = config.get('debug');
const loggerExists = (path: string) => existsSync(path) ? path : undefined;
const loggerPath = [loggerExists('/dev/log'), loggerExists('/var/run/syslog')].filter(Boolean);
const syslog = new SysLogger({
tag: 'node',
path: loggerPath
});
syslog.setMessageComposer(function(message: string, severity: number) {
const severityLevel = {
0: 'emergency',
1: 'alert',
2: 'critical',
3: 'error',
4: 'warning',
5: 'notice',
6: 'info',
7: 'debug'
};
// @ts-ignore
return new Buffer(`<${this.facility * 8 + severity}> ${this.tag} [${severityLevel[severity]}]: ${message}`);
});
// Replace secrets with the following
const redact = redactSecrets('[REDACTED]', {
keys: [],
values: []
});
/**
* Set the starting level
*
* "NODE_ENV=test" - all logs are silenced
* "DEBUG=true" - all logs printed via console
*
* By default only info and above logs are printed.
*/
let currentLogLevel = silent ? levels.silent : (levels[Object.keys(levels).includes(process.env.LOG_LEVEL!) ? process.env.LOG_LEVEL as keyof typeof levels : (prod ? 'info' : 'debug')]);
const aliases = {
log: 'info',
warning: 'warn'
};
const logger = (level: keyof typeof logger) => (message?: any, ...optionalParams: any[]) => {
const resolvedLevel = Object.keys(aliases).includes(String(level)) ? aliases[level] : level;
// Only log if the level is the same or higher,
// For example level = 6 would mean only fatal logs are shown
// level = 0 would show every single log
if (levels[level] >= currentLogLevel) {
// Only log to console when in debug mode
// This is mainly used when running this on a
// non-unraid system as syslog isn't always accessible
const args = format(message, ...optionalParams.map(param => redact.map(param)));
const log = debug ? console[resolvedLevel] : syslog[resolvedLevel].bind(syslog);
return log(args);
}
};
/**
* Main logger.
*/
export const log = {
trace: logger('trace'),
debug: logger('debug'),
info: logger('info'),
error: logger('error'),
warning: logger('warning'),
timer: process.env.TIMERS ? logger('debug') : noop,
/**
* Update the current log level
* @param level string | number of log level
*/
setLevel(level: keyof typeof levels) {
// Only update if in allowed levels
if (!Object.keys(levels).includes(level)) {
this.warning(`Invalid level ${level}, try one of the following ${Object.keys(levels)}.`);
return currentLogLevel;
}
// Update level
currentLogLevel = levels[level];
// Return newly set level
return currentLogLevel;
},
getLevel: () => currentLogLevel,
getLevelName: () => {
const level = Object.entries(levels).find(([key, value]) => value === currentLogLevel);
return level?.[0];
}
};
process.on('SIGUSR2', () => {
if (log.getLevel() === levels.debug) {
log.setLevel('info');
} else {
log.setLevel('debug');
}
log.info(`Log level updated to ${log.getLevelName()}.`);
});
+57
View File
@@ -0,0 +1,57 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { v4 as uuid } from 'uuid';
import uuidApiKey from 'uuid-apikey';
import { CoreContext, CoreResult } from '../types';
import { ensurePermission } from '../utils';
import { apiManager, CacheItem } from '../api-manager';
interface Context extends CoreContext {
data: {
name?: string;
key?: string;
userId?: string;
expiration?: string;
};
}
interface Result extends CoreResult {
json: CacheItem;
}
/**
* Register an api key.
*
* NOTE: If the name or key is missing they'll be generated.
*/
export const addApikey = async(context: Context): Promise<Result | void> => {
ensurePermission(context.user, {
resource: 'apikey',
action: 'create',
possession: 'any'
});
const name = context.data?.name ?? uuid();
const key = context.data?.key ?? uuidApiKey.create().apiKey;
const userId = context.data?.userId ?? context.user.id;
const expiration = context.data?.expiration;
if (name && key) {
apiManager.add(name, key, {
userId,
expiration
});
const result = apiManager.getKey(name);
if (result) {
return {
json: result
};
}
}
};
+130
View File
@@ -0,0 +1,130 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
// import fs from 'fs';
// import fetch from 'node-fetch';
// import { log } from '../log';
import { AppError, NotImplementedError } from '../errors';
import { CoreContext, CoreResult } from '../types';
import { ensurePermission } from '../utils';
import { varState } from '../states';
interface Context extends CoreContext {
data: {
keyUri?: string;
trial?: boolean;
replacement?: boolean;
email?: string;
keyFile?: string;
};
}
interface Result extends CoreResult {
json: {
key?: string;
type?: string;
};
}
/**
* Register a license key.
*/
export const addLicenseKey = async(context: Context): Promise<Result | void> => {
ensurePermission(context.user, {
resource: 'license-key',
action: 'create',
possession: 'any'
});
// const { data } = context;
const guid = varState?.data?.regGuid;
// const timestamp = new Date();
if (!guid) {
throw new AppError('guid missing');
}
throw new NotImplementedError();
// // Connect to unraid.net to request a trial key
// if (data?.trial) {
// const body = new FormData();
// body.append('guid', guid);
// body.append('timestamp', timestamp.getTime().toString());
// const key = await fetch('https://keys.lime-technology.com/account/trial', { method: 'POST', body })
// .then(response => response.json())
// .catch(error => {
// log.error(error);
// throw new AppError(`Sorry, a HTTP ${error.status} error occurred while registering USB Flash GUID ${guid}`);
// });
// // Update the trial key file
// await fs.promises.writeFile('/boot/config/Trial.key', Buffer.from(key, 'base64'));
// return {
// text: 'Thank you for registering, your trial key has been accepted.',
// json: {
// key
// }
// };
// }
// // Connect to unraid.net to request a new replacement key
// if (data?.replacement) {
// const { email, keyFile } = data;
// if (!email || !keyFile) {
// throw new AppError('email or keyFile is missing');
// }
// const body = new FormData();
// body.append('guid', guid);
// body.append('timestamp', timestamp.getTime().toString());
// body.append('email', email);
// body.append('keyfile', keyFile);
// const { body: key } = await fetch('https://keys.lime-technology.com/account/license/transfer', { method: 'POST', body })
// .then(response => response.json())
// .catch(error => {
// log.error(error);
// throw new AppError(`Sorry, a HTTP ${error.status} error occurred while issuing a replacement for USB Flash GUID ${guid}`);
// });
// // Update the trial key file
// await fs.promises.writeFile('/boot/config/Trial.key', Buffer.from(key, 'base64'));
// return {
// text: 'Thank you for registering, your trial key has been registered.',
// json: {
// key
// }
// };
// }
// // Register a new server
// if (data?.keyUri) {
// const parts = data.keyUri.split('.key')[0].split('/');
// const { [parts.length - 1]: keyType } = parts;
// // Download key blob
// const { body: key } = await fetch(data.keyUri)
// .then(response => response.json())
// .catch(error => {
// log.error(error);
// throw new AppError(`Sorry, a HTTP ${error.status} error occurred while registering your key for USB Flash GUID ${guid}`);
// });
// // Save key file
// await fs.promises.writeFile(`/boot/config/${keyType}.key`, Buffer.from(key, 'base64'));
// return {
// text: `Thank you for registering, your ${keyType} key has been accepted.`,
// json: {
// type: keyType
// }
// };
// }
};
+69
View File
@@ -0,0 +1,69 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import path from 'path';
import packageJson from 'package-json';
import dlTgz from 'dl-tgz';
import observableToPromise from 'observable-to-promise';
import { CoreContext, CoreResult } from '../types';
import { AppError, FieldMissingError } from '../errors';
import { hasFields, ensurePermission } from '../utils';
import { paths } from '../paths';
interface Context extends CoreContext {
data: {
/** Plugin's npm name. */
name: string;
/** Plugin's version. */
version: string;
};
}
/**
* Install plugin.
* @returns The newly installed plugin.
*/
export const addPlugin = async(context: Context): Promise<CoreResult> => {
// Check permissions
ensurePermission(context.user, {
resource: 'plugin',
action: 'create',
possession: 'any'
});
// Validation
const missingFields = hasFields(context.data, ['name']);
if (missingFields.length !== 0) {
// Log first error.
throw new FieldMissingError(missingFields[0]);
}
// Get package metadata
const { name, version } = context.data;
const pkg = await packageJson(name, {
allVersions: Boolean(version)
});
// Plugin tgz url
const latest = pkg.versions[version];
const url = latest.dist.tarball;
const pluginCwd = paths.get('plugins')!;
// Download tgz to plugin dir
await observableToPromise(dlTgz(url, path.join(pluginCwd, name))).catch(() => {
throw new AppError(`Plugin download failed for "${name}".`);
});
// Register plugin with manager
// Run plugin init
return {
text: 'Plugin added successfully.',
json: {
pkg
}
};
};
+36
View File
@@ -0,0 +1,36 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../types';
import { AppError, NotImplementedError } from '../errors';
import { sharesState, slotsState } from '../states';
import { ensurePermission } from '../utils';
export const addShare = async(context: CoreContext): Promise<CoreResult> => {
const { user, data = {} } = context;
// Check permissions
ensurePermission(user, {
resource: 'share',
action: 'create',
possession: 'any'
});
const { name } = data;
const userShares = sharesState.find().map(({ name }) => name);
const diskShares = slotsState.find({ exportable: 'yes' }).filter(({ name }) => name.startsWith('disk')).map(({ name }) => name);
// Existing share names
const inUseNames = new Set([
...userShares,
...diskShares
]);
if (inUseNames.has(name)) {
throw new AppError(`Share already exists with name: ${name}`, 400);
}
throw new NotImplementedError();
};
+87
View File
@@ -0,0 +1,87 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../types';
import { hasFields, emcmd, ensurePermission } from '../utils';
import { bus } from '../bus';
import { AppError, FieldMissingError } from '../errors';
import { usersState } from '../states';
interface Context extends CoreContext {
readonly data: {
/** Display name. */
readonly name: string;
/** User's password. */
readonly password: string;
/** Friendly description. */
readonly description: string;
};
}
/**
* Add user account.
*/
export const addUser = async(context: Context): Promise<CoreResult> => {
const { data } = context;
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'create',
possession: 'any'
});
// Validation
const { name, description = '', password } = data;
const missingFields = hasFields(data, ['name', 'password']);
if (missingFields.length !== 0) {
// Only log first error.
throw new FieldMissingError(missingFields[0]);
}
// Check user name isn't taken
if (usersState.findOne({ name })) {
throw new AppError('A user account with that name already exists.');
}
// Create user
await emcmd({
userName: name,
userDesc: description,
userPassword: password,
userPasswordConf: password,
cmdUserEdit: 'Add'
});
// Get fresh copy of Users with the new user
const user = usersState.findOne({ name });
if (!user) {
// User managed to disappear between us creating it and the lookup?
throw new AppError('Internal Server Error!');
}
// Update users channel with new user
bus.emit('users', {
users: {
mutation: 'CREATED',
node: [user]
}
});
// Update user channel with new user
bus.emit('user', {
user: {
mutation: 'CREATED',
node: user
}
});
return {
text: `User created successfully. ${JSON.stringify(user, null, 2)}`,
json: user
};
};
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './name';
@@ -0,0 +1,24 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { NotImplementedError } from '../../../errors';
import { CoreContext, CoreResult } from '../../../types';
interface Context extends CoreContext {
params: {
username: string;
};
data: {
password: string;
};
}
/**
* Invalidate an apiKey.
* @returns The deleted apikey.
*/
export const deleteApikey = async(_: Context): Promise<CoreResult> => {
throw new NotImplementedError();
};
@@ -0,0 +1,93 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext } from '../../../types';
import { apiManager } from '../../../api-manager';
import { AppError } from '../../../errors';
import { ensurePermission } from '../../../utils';
interface Result extends CoreResult {
json: {
/** Display name. */
name: string;
/** The key. */
key: string | number;
/** When the key expires. */
expiresAt: number;
/** Which scopes this key is valid for. */
scopes: any;
};
}
interface Context extends CoreContext {
data: {
password: string;
};
params: {
name: string;
};
}
/**
* Get an apiKey
*
* @memberof Core
* @module apikeys/name/get-apikey
* @param {Core~Context} context
* @param {Object} context.params
* @param {string} context.params.name
* @returns {Core~Result} The API key, the user who owns the key, when the key expires and the scopes the key can use.
*/
export const getApikey = async(context: Context): Promise<Result> => {
const { params, user } = context;
const { name } = params;
// Check permissions
ensurePermission(user, {
resource: 'apikey',
action: 'read',
possession: 'any'
});
// All valid API key names
const apiKeys = apiManager.getValidKeys().map(item => item.name);
// When the API key expires
const expiresAt = apiManager.getValidKeys()
// We have to use the name here otherwise it'd match the
// first "owner" of the key and not the actual user
.filter(item => item.name === name)
.map(item => item.expiresAt)[0];
// Check if API key is expired
// @todo: Move this check to after the auth happens to prevent leaking when a key has expired to a non-authenticated or non-privileged user.
if (expiresAt <= Date.now()) {
throw new AppError('Key expired!');
}
// Check name is valid
if (!apiKeys.includes(name)) {
throw new AppError('Invalid name');
}
const scopes = [];
const apiKey = apiManager.getKey(name)?.key;
if (!apiKey) {
throw new AppError(`A key under this name hasn't been issued or it has expired.`);
}
return {
text: `ApiKey: ${apiKey}`,
json: {
name,
key: apiKey,
expiresAt: expiresAt || Date.now(),
scopes
}
};
};
export default getApikey;
+5
View File
@@ -0,0 +1,5 @@
// Created from 'create-ts-index'
export * from './delete-apikey';
export * from './get-apikey';
export * from './update-apikey';
@@ -0,0 +1,37 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { PermissionError } from '../../../errors';
import { CoreResult, CoreContext } from '../../../types';
import { getApikey } from '../..';
interface Context extends CoreContext {
data: {
password: string;
};
params: {
name: string;
};
}
/**
* Update an apiKey.
*
* @returns The apikey, when the key expires and the scopes the key can use.
*/
export const updateApiKey = async(context: Context): Promise<CoreResult> => {
// Since we pass the context we don't need to worry about checking if the user has permissions
const key = await getApikey(context).then(result => result.json);
if (!key) {
throw new PermissionError('Access denied!');
}
return {
json: {
...key
}
};
};
@@ -0,0 +1,50 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../types';
import { FieldMissingError, ArrayRunningError } from '../../errors';
import { hasFields, arrayIsRunning, emcmd, ensurePermission } from '../../utils';
import { getArray } from '..';
/**
* Add a disk to the array.
*/
export const addDiskToArray = async function(context: CoreContext): Promise<CoreResult> {
const { data = {}, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'create',
possession: 'any'
});
const missingFields = hasFields(data, ['id']);
if (missingFields.length !== 0) {
// Just log first error
throw new FieldMissingError(missingFields[0]);
}
if (arrayIsRunning()) {
throw new ArrayRunningError();
}
const { id: diskId, slot: preferredSlot } = data;
const slot = Number.parseInt(preferredSlot, 10);
// Add disk
await emcmd({
changeDevice: 'apply',
[`slotId.${slot}`]: diskId
});
const array = getArray(context);
// Disk added successfully
return {
text: `Disk was added to the array in slot ${slot}.`,
json: array.json
};
};
+6
View File
@@ -0,0 +1,6 @@
// Created from 'create-ts-index'
export * from './add-disk-to-array';
export * from './remove-disk-from-array';
export * from './update-array';
export * from './update-parity-check';
@@ -0,0 +1,56 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../types';
import { hasFields, arrayIsRunning, ensurePermission } from '../../utils';
import { ArrayRunningError, FieldMissingError } from '../../errors';
import { getArray } from '..';
interface Context extends CoreContext {
data: {
/** The slot the disk is in. */
slot: string;
};
}
/**
* Remove a disk from the array.
* @returns The updated array.
*/
export const removeDiskFromArray = async(context: Context): Promise<CoreResult> => {
const { data, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'create',
possession: 'any'
});
const missingFields = hasFields(data, ['id']);
if (missingFields.length !== 0) {
// Only log first error
throw new FieldMissingError(missingFields[0]);
}
if (arrayIsRunning()) {
throw new ArrayRunningError();
}
const { slot } = data;
// Error removing disk
// if () {
// }
const array = getArray(context);
// Disk removed successfully
return {
text: `Disk was removed from the array in slot ${slot}.`,
json: array.json
};
};
+87
View File
@@ -0,0 +1,87 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../types';
import { hasFields, ensurePermission, emcmd, arrayIsRunning, uppercaseFirstChar } from '../../utils';
import { AppError, FieldMissingError, ParamInvalidError } from '../../errors';
import { getArray } from '..';
// @TODO: Fix this not working across node apps
// each app has it's own lock since the var is scoped
// ideally this should have a timeout to prevent it sticking
let locked = false;
export const updateArray = async(context: CoreContext): Promise<CoreResult> => {
const { data = {}, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'update',
possession: 'any'
});
const missingFields = hasFields(data, ['state']);
if (missingFields.length !== 0) {
// Only log first error
throw new FieldMissingError(missingFields[0]);
}
const { state: nextState } = data;
const startState = arrayIsRunning() ? 'started' : 'stopped';
const pendingState = nextState === 'stop' ? 'stopping' : 'starting';
if (!['start', 'stop'].includes(nextState)) {
throw new ParamInvalidError('state', nextState);
}
// Prevent this running multiple times at once
if (locked) {
throw new AppError('Array state is still being updated.');
}
// Prevent starting/stopping array when it's already in the same state
if ((arrayIsRunning && nextState === 'start') || (!arrayIsRunning && nextState === 'stop')) {
throw new AppError(`The array is already ${startState}`);
}
// Set lock then start/stop array
locked = true;
const command = {
[`cmd${uppercaseFirstChar(nextState)}`]: uppercaseFirstChar(nextState),
startState: startState.toUpperCase()
};
// `await` has to be used otherwise the catch
// will finish after the return statement below
await emcmd(command).finally(() => {
locked = false;
});
// Get new array JSON
const array = getArray(context);
/**
* Update array details
*
* @memberof Core
* @module array/update-array
* @param {Core~Context} context Context object.
* @param {Object} context.data The data object.
* @param {'start'|'stop'} context.data.state If the array should be started or stopped.
* @param {State~User} context.user The current user.
* @returns {Core~Result} The updated array.
*/
return {
text: `Array was ${startState}, ${pendingState}.`,
json: {
...array.json,
state: nextState === 'start' ? 'started' : 'stopped',
previousState: startState,
pendingState
}
};
};
@@ -0,0 +1,80 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../types';
import { FieldMissingError, ParamInvalidError } from '../../errors';
import { emcmd, ensurePermission } from '../../utils';
import { varState } from '../../states';
type State = 'start' | 'cancel' | 'resume' | 'cancel';
interface Context extends CoreContext {
data: {
state?: State
correct?: boolean
}
}
/**
* Remove a disk from the array.
* @returns The update array.
*/
export const updateParityCheck = async (context: Context): Promise<CoreResult> => {
const { user, data } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'update',
possession: 'any'
});
// Validation
if (!data.state) {
throw new FieldMissingError('state');
}
const { state: wantedState } = data;
const running = varState?.data?.mdResync !== 0;
const states = {
pause: {
cmdNoCheck: 'Pause'
},
resume: {
cmdCheck: 'Resume'
},
cancel: {
cmdNoCheck: 'Cancel'
},
start: {
cmdCheck: 'Check'
}
};
let allowedStates = Object.keys(states);
// Only allow starting a check if there isn't already one running
if (running) {
allowedStates = allowedStates.splice(allowedStates.indexOf('start'), 1);
}
// Only allow states from states object
if (!allowedStates.includes(wantedState)) {
throw new ParamInvalidError('state', wantedState);
}
// Should we write correction to the parity during the check
const writeCorrectionsToParity = wantedState === 'start' && data.correct;
await emcmd({
startState: 'STARTED',
...states[wantedState],
...(writeCorrectionsToParity ? { optionCorrect: 'correct' } : {})
});
return {
json: {}
};
};
+17
View File
@@ -0,0 +1,17 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../types';
/**
* Get internal context object.
*/
export const getContext = (context: CoreContext): CoreResult => {
return {
text: `Context: ${JSON.stringify(context, null, 2)}`,
json: context,
html: `<h1>Context</h1>\n<pre>${JSON.stringify(context, null, 2)}</pre>`
};
};
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './get-context';
+40
View File
@@ -0,0 +1,40 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../../types';
import { AppError } from '../../../errors';
import { ensurePermission } from '../../../utils';
interface Context extends CoreContext {
params: {
id: string;
};
}
/**
* Get a single disk.
*/
export const getDisk = async(context: Context, Disks): Promise<CoreResult> => {
const { params, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'disk',
action: 'read',
possession: 'any'
});
const { id } = params;
const disk = await Disks.findOne({ id });
if (!disk) {
throw new AppError(`No disk found matching ${id}`, 404);
}
return {
text: `Disk: ${JSON.stringify(disk, null, 2)}`,
json: disk
};
};
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './get-disk';
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './id';
@@ -0,0 +1,67 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import fs from 'fs';
import camelCaseKeys from 'camelcase-keys';
import { paths } from '../../paths';
import { docker, catchHandlers, ensurePermission } from '../../utils';
import { CoreContext, CoreResult } from '../../types';
interface Context extends CoreContext {
readonly query: {
readonly all: string;
};
}
/**
* Get all Docker containers.
* @returns All the in/active Docker containers on the system.
*/
export const getDockerContainers = async(context: Context): Promise<CoreResult> => {
const { query, user } = context;
const { all } = query;
// Check permissions
ensurePermission(user, {
resource: 'docker/container',
action: 'read',
possession: 'any'
});
/**
* Docker auto start file
*
* @note Doesn't exist if array is offline.
* @see https://github.com/limetech/webgui/issues/502#issue-480992547
*/
const autoStartFile = await fs.promises.readFile(paths.get('docker-autostart')!, 'utf-8').then(file => file.toString()).catch(() => '');
const autoStarts = autoStartFile.split('\n');
const containers = await docker
.listContainers({
all,
size: true
})
.then(containers => containers.map(object => camelCaseKeys(object, { deep: true })))
// If docker throws an error return no containers
.catch(catchHandlers.docker);
// Cleanup container object
const result = containers
.map(object => camelCaseKeys(object, { deep: true }))
.map(container => {
// This will be fixed once camelCaseKeys has correct typings
// @ts-ignore
const names = container.names[0];
return {
...container,
autoStart: autoStarts.includes(names.split('/')[1])
};
});
return {
text: `Containers: ${JSON.stringify(result, null, 2)}`,
json: result
};
};
@@ -0,0 +1,39 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import camelCaseKeys from 'camelcase-keys';
import { docker, catchHandlers, ensurePermission } from '../../utils';
import { CoreContext, CoreResult } from '../../types';
export const getDockerNetworks = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'docker/network',
action: 'read',
possession: 'any'
});
const networks = await docker.listNetworks()
// If docker throws an error return no networks
.catch(catchHandlers.docker)
.then((networks = []) => {
return networks.map(object => camelCaseKeys(object, { deep: true }));
});
/**
* Get all Docker networks
*
* @memberof Core
* @module docker/get-networks
* @param {Core~Context} context
* @returns {Core~Result} All the in/active Docker networks on the system.
*/
return {
text: `Networks: ${JSON.stringify(networks, null, 2)}`,
json: networks
};
};
+4
View File
@@ -0,0 +1,4 @@
// Created from 'create-ts-index'
export * from './get-docker-containers';
export * from './get-docker-networks';
+34
View File
@@ -0,0 +1,34 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext } from '../types';
import { getShares, ensurePermission } from '../utils';
/**
* Get all shares.
*/
export const getAllShares = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'share',
action: 'read',
possession: 'any'
});
const userShares = getShares('users');
const diskShares = getShares('disks');
const shares = [
...userShares,
...diskShares
];
return {
text: `Shares: ${JSON.stringify(shares, null, 2)}`,
json: shares
};
};
+32
View File
@@ -0,0 +1,32 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { apiManager } from '../api-manager';
import { checkPermission } from '../utils';
import { CoreResult, CoreContext } from '../types';
/**
* Get all apikeys
*
* @returns All apikeys with their respective `name`, `key` and `expiresAt`.
*/
export const getApikeys = async function(context: CoreContext): Promise<CoreResult> {
const { user } = context;
const canReadAny = checkPermission(user, {
resource: 'apikey',
action: 'read',
possession: 'any'
});
const validKeys = apiManager.getValidKeys();
const keys = canReadAny ? validKeys : validKeys.filter(key => key.name === `user:${user.name}`);
return {
text: `ApiKeys: ${JSON.stringify(keys, null, 2)}`,
json: keys
};
};
export default getApikeys;
+18
View File
@@ -0,0 +1,18 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult } from '../types';
/**
* Get all apps.
*/
export const getApps = async(): Promise<CoreResult> => {
const apps = [];
return {
text: `Apps: ${JSON.stringify(apps, null, 2)}`,
json: apps
};
};
+71
View File
@@ -0,0 +1,71 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext } from '../types';
import { addTogether, ensurePermission } from '../utils';
import { varState, slotsState } from '../states';
/**
* Get array info.
* @returns Array state and array/disk capacity.
*/
export const getArray = (context: CoreContext): CoreResult => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'read',
possession: 'any'
});
// Array state
const arrayState = varState?.data?.mdState.toLowerCase();
const state = arrayState.startsWith('error') ? arrayState.split(':')[1] : arrayState;
// All known disks
const allDisks = slotsState.find().filter(disk => disk.device);
// Array boot/parities/disks/caches
const boot = allDisks.find(disk => disk.name === 'flash');
const parities = allDisks.filter(disk => disk.name.startsWith('parity'));
const disks = allDisks.filter(disk => disk.name.startsWith('disk'));
const caches = allDisks.filter(disk => disk.name.startsWith('cache'));
// Disk sizes
const disksTotalBytes = addTogether(disks.map(_ => _.fsSize * 1024));
const disksFreeBytes = addTogether(disks.map(_ => _.fsFree * 1024));
// Max
const maxDisks = varState?.data?.maxArraysz ?? disks.length;
// Array capacity
const capacity = {
bytes: {
free: disksFreeBytes,
used: disksTotalBytes - disksFreeBytes,
total: disksTotalBytes
},
disks: {
free: maxDisks - disks.length,
used: disks.length,
total: maxDisks
}
};
const text = `State: ${state}\nCapacity: ${JSON.stringify(capacity, null, 2)}\n${JSON.stringify(disks, null, 2)}`;
return {
text,
json: {
state,
capacity,
boot,
parities,
disks,
caches
}
};
};
+30
View File
@@ -0,0 +1,30 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext } from '../types';
import { devicesState } from '../states';
import { ensurePermission } from '../utils';
/**
* Get all devices.
* @returns All currently connected devices.
*/
export const getDevices = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'device',
action: 'read',
possession: 'any'
});
const devices = devicesState.find();
return {
text: `Devices: ${JSON.stringify(devices, null, 2)}`,
json: devices
};
};
+91
View File
@@ -0,0 +1,91 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import execa from 'execa';
import si from 'systeminformation';
import { map as asyncMap } from 'p-iteration';
import { CoreContext, CoreResult } from '../types';
import { uppercaseFirstChar, ensurePermission } from '../utils';
interface Partition {
name: string;
fsType: string;
size: number;
}
interface Disk extends si.Systeminformation.DiskLayoutData {
smartStatus: string;
interfaceType: string;
temperature: number;
partitions: Partition[];
}
const getTemperature = async(disk: si.Systeminformation.DiskLayoutData): Promise<number> => {
const stdout = await execa('smartctl', ['-A', disk.device]).then(({ stdout }) => stdout).catch(() => '');
const lines = stdout.split('\n');
const header = lines.find(line => line.startsWith('ID#'))!;
const fields = lines.splice(lines.indexOf(header) + 1, lines.length);
const field = fields.find(line => {
return line.includes('Temperature_Celsius') || line.includes('Airflow_Temperature_Cel');
});
if (!field) {
return -1;
}
if (field.includes('Min/Max')) {
return Number.parseInt(field.split(' - ')[1].trim().split(' ')[0], 10);
}
const line = field.split(' ');
return Number.parseInt(line[line.length - 1], 10);
};
const parseDisk = async(disk: si.Systeminformation.DiskLayoutData, partitionsToParse: si.Systeminformation.BlockDevicesData[]): Promise<Disk> => {
const partitions = partitionsToParse
// Only get partitions from this disk
.filter(partition => partition.name.startsWith(disk.device.split('/dev/')[1]))
// Remove unneeded fields
.map(({ name, fstype, size }) => ({
name,
fsType: fstype,
size
}));
return {
...disk,
smartStatus: uppercaseFirstChar(disk.smartStatus.toLowerCase()),
interfaceType: disk.interfaceType || 'UNKNOWN',
temperature: await getTemperature(disk),
partitions
};
};
interface Result extends CoreResult {
json: Disk[];
}
/**
* Get all disks.
*/
export const getDisks = async(context: CoreContext): Promise<Result> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'disk',
action: 'read',
possession: 'any'
});
const blockDevices = await si.blockDevices();
const partitions = blockDevices.filter(device => device.type === 'part');
const disks = await asyncMap(await si.diskLayout(), async disk => parseDisk(disk, partitions));
return {
text: `Disks: ${JSON.stringify(disks, null, 2)}`,
json: disks
};
};
+24
View File
@@ -0,0 +1,24 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../types';
import { getPermissions } from '../utils';
/**
* Get current user.
*/
export const getMe = (context: CoreContext): CoreResult => {
const { user } = context;
const me = {
...user,
permissions: getPermissions(user.role)
};
return {
text: `Me: ${JSON.stringify(me, null, 2)}`,
json: me
};
};
+65
View File
@@ -0,0 +1,65 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { promises as fs } from 'fs';
import { CoreResult, CoreContext } from '../types';
import { paths } from '../paths';
import { FileMissingError } from '../errors';
import { ensurePermission } from '../utils';
const Table = require('cli-table');
/**
* Get parity history.
* @returns All parity checks with their respective date, duration, speed, status and errors.
*/
export const getParityHistory = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'parity-history',
action: 'read',
possession: 'any'
});
const historyFilePath = paths.get('parity-checks')!;
const history = await fs.readFile(historyFilePath).catch(() => {
throw new FileMissingError(historyFilePath);
});
// Convert checks into array of objects
const lines = history.toString().trim().split('\n').reverse();
const parityChecks = lines.map(line => {
const [date, duration, speed, status, errors = '0'] = line.split('|');
return {
date,
duration: Number.parseInt(duration, 10),
speed,
status,
errors: Number.parseInt(errors, 10)
};
});
// Create table for text output
const table = new Table({
head: ['Date', 'Duration', 'Speed', 'Status', 'Errors']
});
// Update raw values with strings
parityChecks.forEach(check => {
const array = Object.values({
...check,
speed: check.speed ? check.speed : 'Unavailable',
duration: check.duration >= 0 ? check.duration : 'Unavailable',
status: check.status === '-4' ? 'Cancelled' : 'OK'
});
table.push(array);
});
return {
text: table.toString(),
json: parityChecks
};
};
+54
View File
@@ -0,0 +1,54 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { ac } from '../permissions';
import { ensurePermission, getPermissions as getUserPermissions } from '../utils';
import { CoreContext, CoreResult } from '../types';
/**
* Get all permissions.
*/
export const getPermissions = async function(context: CoreContext): Promise<CoreResult> {
const { user } = context;
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'permission',
action: 'read',
possession: 'any'
});
// Get all scopes
const scopes = Object.assign({}, ...Object.values(ac.getGrants()).map(grant => {
// @ts-ignore
const { $extend, ...grants } = grant;
return {
...grants,
...$extend && getUserPermissions($extend)
};
}));
// Get all roles and their scopes
const grants = Object.entries(ac.getGrants())
.map(([name, grant]) => {
// @ts-ignore
const { $extend, ...grants } = grant;
return [name, grants];
})
.reduce((object, {
0: key,
1: value
}) => Object.assign(object, {
[key.toString()]: value
}), {});
return {
text: `Scopes: ${JSON.stringify(scopes, null, 2)}`,
json: {
scopes,
grants
}
};
};
+85
View File
@@ -0,0 +1,85 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../types';
import { ParamInvalidError } from '../errors';
import { pluginManager } from '../plugin-manager';
import { ensurePermission } from '../utils';
import { Plugin } from '../plugin-manager';
interface Context extends CoreContext {
readonly query: {
readonly filter: string;
};
}
interface Result extends CoreResult {
json: Plugin[]
}
export const getPlugins = (context: Readonly<Context>): Result => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'plugin',
action: 'read',
possession: 'any'
});
const { query } = context;
const { filter = 'all' } = query;
if (!['all', 'active', 'inactive'].includes(filter)) {
throw new ParamInvalidError('filter', filter);
}
const plugins = pluginManager.getAllPlugins().map(plugin => {
// Plugin is likely disabled
if (!plugin.modules) {
return plugin;
}
// Get modules with names
const modules = Object.entries(plugin.modules).map(([name, value]) => {
return {
name,
...value
};
});
return {
...plugin,
modules
};
});
const title: string = {
all: 'Plugins',
active: 'Active',
inactive: 'InActive'
}[filter];
const json = {
all: () => plugins,
active: () => plugins.filter(({ isActive }) => isActive),
inactive: () => plugins.filter(({ isActive }) => !isActive)
}[filter]();
const names = json.map(({ name }) => name);
/**
* Get all plugins
*
* @memberof Core
* @module get-plugins
* @param {Core~Context} context
* @param {Object} [context.query = {}]
* @param {'all' | 'active' | 'inactive'} [context.query.filter = 'all']
* @returns {Core~Result}
*/
return {
text: `${title}: ${JSON.stringify(names, null, 2)}`,
json
};
};
+72
View File
@@ -0,0 +1,72 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { getEmhttpdService, getNodeApiService } from './services';
import { log } from '../log';
import { envs } from '../envs';
import { NodeService } from '../utils';
import { CoreResult, CoreContext } from '../types';
const devNames = [
'emhttpd',
'rest-api'
];
const coreNames = [
'node-api'
];
interface ServiceResult extends CoreResult {
json: NodeService;
}
interface NodeServiceWithName extends NodeService {
name: string;
}
/**
* Add name to services.
*
* @param services
* @param names
*/
const addNameToService = (services: ServiceResult[], names: string[]): NodeServiceWithName[] => {
return services.map((service, index) => ({
name: names[index],
...service.json
}));
};
interface Result extends CoreResult {
json: NodeServiceWithName[];
}
/**
* Get all services.
*/
export const getServices = async(context: CoreContext): Promise<Result> => {
const logErrorAndReturnEmptyArray = (error: Error) => {
log.error(error);
return [];
};
const devServices = envs.NODE_ENV === 'development' ? await Promise.all([
getEmhttpdService(context)
]).catch(logErrorAndReturnEmptyArray) : [];
const coreServices = await Promise.all([
getNodeApiService(context)
]).catch(logErrorAndReturnEmptyArray);
const result = [
...addNameToService(devServices, devNames),
...addNameToService(coreServices, coreNames)
];
return {
text: `Services: ${JSON.stringify(result, null, 2)}`,
json: result
};
};
@@ -0,0 +1,33 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { AppError } from '../errors';
import { CoreResult, CoreContext } from '../types';
import { ensurePermission } from '../utils';
/**
* Get all unassigned devices.
*/
export const getUnassignedDevices = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'devices/unassigned',
action: 'read',
possession: 'any'
});
const devices = [];
if (devices.length === 0) {
throw new AppError('No devices found.', 404);
}
return {
text: `Unassigned devices: ${JSON.stringify(devices, null, 2)}`,
json: devices
};
};
+55
View File
@@ -0,0 +1,55 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../types';
import { User } from '../types/states';
import { ensurePermission } from '../utils';
import { AppError } from '../errors';
import { usersState } from '../states';
interface Context extends CoreContext {
query: {
/** Should all fields be returned? */
slim: string;
};
}
/**
* Get all users.
*/
export const getUsers = async(context: Context): Promise<CoreResult> => {
const { query, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'user',
action: 'read',
possession: 'any'
});
// Default to only showing limited fields
const { slim = 'true' } = query;
const users = usersState.find();
if (users.length === 0) {
// This is likely a new install or something went horribly wrong
throw new AppError('No users found.', 404);
}
const result = slim === 'true' ? users.map((user: User) => {
const { id, name, description, role } = user;
return {
id,
name,
description,
role
};
}) : users;
return {
text: `Users: ${JSON.stringify(result, null, 2)}`,
json: result
};
};
+29
View File
@@ -0,0 +1,29 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../types';
import { varState } from '../states';
import { ensurePermission } from '../utils';
/**
* Get all system vars.
*/
export const getVars = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'vars',
action: 'read',
possession: 'any'
});
return {
text: `Vars: ${JSON.stringify(varState.data, null, 2)}`,
json: {
...varState.data
}
};
};
+33
View File
@@ -0,0 +1,33 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext } from '../types';
import { ensurePermission } from '../utils';
import { getUnraidVersion } from '.';
/**
* Get welcome message.
* @returns Welcomes a user.
*/
export const getWelcome = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'welcome',
action: 'read',
possession: 'any'
});
const version = await getUnraidVersion(context).then(result => result.json.unraid);
const message = `Welcome ${user.name} to this Unraid ${version} server`;
return {
text: message,
json: {
message
}
};
};
+32
View File
@@ -0,0 +1,32 @@
// Created from 'create-ts-index'
export * from './apikeys';
export * from './array';
export * from './debug';
export * from './disks';
export * from './docker';
export * from './info';
export * from './services';
export * from './settings';
export * from './shares';
export * from './users';
export * from './vms';
export * from './add-license-key';
export * from './add-plugin';
export * from './add-share';
export * from './add-user';
export * from './get-all-shares';
export * from './get-apikeys';
export * from './get-apps';
export * from './get-array';
export * from './get-devices';
export * from './get-disks';
export * from './get-me';
export * from './get-parity-history';
export * from './get-permissions';
export * from './get-plugins';
export * from './get-services';
export * from './get-unassigned-devices';
export * from './get-users';
export * from './get-vars';
export * from './get-welcome';
+272
View File
@@ -0,0 +1,272 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import pProps from 'p-props';
import execa from 'execa';
import pathExists from 'path-exists';
import { filter as asyncFilter } from 'p-iteration';
import { isSymlink } from 'path-type';
import { vmRegExps, sanitizeProduct, sanitizeVendor, filterDevices, getPciDevices, ensurePermission } from '../../utils';
import { PciDevice, CoreResult, CoreContext } from '../../types';
import { varState } from '../../states';
/**
* System Network interfaces.
*/
// const systemNetworkInterfaces = si.networkInterfaces();
// System Disk controllers
// const systemDiskControllers = [];
// if (!empty($arrDisk['device']) && file_exists('/dev/'.$arrDisk['device'])) {
// $strOSDiskController = trim(exec("udevadm info -q path -n /dev/".$arrDisk['device']." | grep -Po '0000:\K\w{2}:\w{2}\.\w{1}'"));
// }
// $arrOSDiskControllers = array_values(array_unique($arrOSDiskControllers));
// $arrBlacklistIDs = $arrOSDiskControllers;
// if (!empty($strOSNetworkDevice)) {
// $arrBlacklistIDs[] = $strOSNetworkDevice;
// }
// $arrValidPCIDevices = [];
/**
* Set device class to device.
* @param device The device to modify.
* @returns The same device passed in but with the class modified.
*/
const addDeviceClass = (device: Readonly<PciDevice>): PciDevice => {
const modifiedDevice: PciDevice = {
...device,
class: 'other'
};
// GPU
if (vmRegExps.allowedGpuClassId.test(device.typeid)) {
modifiedDevice.class = 'vga';
// Specialized product name cleanup for GPU
// GF116 [GeForce GTX 550 Ti] --> GeForce GTX 550 Ti
const regex = new RegExp(/.+\[(?<gpuName>.+)]/);
const productName = regex.exec(device.productname)?.groups?.gpuName;
if (productName) {
modifiedDevice.productname = productName;
}
return modifiedDevice;
// Audio
}
if (vmRegExps.allowedAudioClassId.test(device.typeid)) {
modifiedDevice.class = 'audio';
return modifiedDevice;
}
return modifiedDevice;
};
/**
* System PCI devices.
*/
const systemPciDevices = async (): Promise<PciDevice[]> => {
const devices = await getPciDevices();
const basePath = '/sys/bus/pci/devices/0000:';
// Remove devices with no IOMMU support
const filteredDevices = await asyncFilter(devices, async (device: Readonly<PciDevice>) => pathExists(`${basePath}${device.id}/iommu_group/`));
/**
* Run device cleanup
*
* Tasks:
* - Mark disallowed devices
* - Add class
* - Add whether kernel-bound driver exists
* - Cleanup device vendor/product names
*/
const processedDevices = await filterDevices(filteredDevices).then(devices => {
return devices
// @ts-ignore
.map(addDeviceClass)
.map(device => {
// Attempt to get the current kernel-bound driver for this pci device
isSymlink(`${basePath}${device.id}/driver`).then(symlink => {
if (symlink) {
// $strLink = @readlink('/sys/bus/pci/devices/0000:'.$arrMatch['id']. '/driver');
// if (!empty($strLink)) {
// $strDriver = basename($strLink);
// }
}
});
// Clean up the vendor and product name
device.vendorname = sanitizeVendor(device.vendorname);
device.productname = sanitizeProduct(device.productname);
return device;
});
});
return processedDevices;
};
/**
* System GPU Devices
*
* @name systemGPUDevices
* @ignore
* @private
*/
const systemGPUDevices = systemPciDevices().then(devices => {
return devices.filter(device => {
return device.class === 'vga' && !device.allowed;
});
});
/**
* System Audio Devices
*
* @name systemAudioDevices
* @ignore
* @private
*/
const systemAudioDevices = systemPciDevices().then(devices => {
return devices.filter(device => device.class === 'audio' && !device.allowed);
});
/**
* System usb devices.
* @returns Array of USB devices.
*/
const getSystemUSBDevices = async(): Promise<any[]> => {
// Get a list of all usb hubs so we can filter the allowed/disallowed
const usbHubs = await execa('cat /sys/bus/usb/drivers/hub/*/modalias', { shell: true }).then(({ stdout }) => {
return stdout.split('\n').map(line => {
const [, id] = line.match(/usb:v(\w{9})/)!;
return id.replace('p', ':');
});
}).catch(() => []);
// Remove boot drive
const filterBootDrive = (device: Readonly<PciDevice>): boolean => varState?.data?.flashGuid !== device.guid;
// Remove usb hubs
// @ts-ignore
const filterUsbHubs = (device: Readonly<PciDevice>): boolean => !usbHubs.includes(device.id);
// Clean up the name
const sanitizeVendorName = (device: Readonly<PciDevice>) => {
const vendorname = sanitizeVendor(device.vendorname || '');
return {
...device,
vendorname
};
};
const parseDeviceLine = (line: Readonly<string>): { value: string, string: string } => {
const emptyLine = { value: '', string: '' };
// If the line is blank return nothing
if (!line) {
return emptyLine;
}
// Parse the line
const [, _] = line.split(/[ \t]{2,}/).filter(Boolean);
const match = _.match(/^(\S+)\s(.*)/)?.slice(1);
// If there's no match return nothing
if (!match) {
return emptyLine;
}
return {
value: match[0],
string: match[1]
};
};
// Add extra fields to device
const parseDevice = (device: Readonly<PciDevice>) => {
const modifiedDevice: PciDevice = {
...device
};
const info = execa.commandSync(`lsusb -d ${device.id} -v`).stdout.split('\n');
const deviceName = device.name.trim();
const iSerial = parseDeviceLine(info.filter(line => line.includes('iSerial'))[0]);
const iProduct = parseDeviceLine(info.filter(line => line.includes('iProduct'))[0]);
const iManufacturer = parseDeviceLine(info.filter(line => line.includes('iManufacturer'))[0]);
const idProduct = parseDeviceLine(info.filter(line => line.includes('idProduct'))[0]);
const idVendor = parseDeviceLine(info.filter(line => line.includes('idVendor'))[0]);
const serial = `${iSerial.string.slice(8).slice(0, 4)}-${iSerial.string.slice(8).slice(4)}`;
const guid = `${idVendor.value.slice(2)}-${idProduct.value.slice(2)}-${serial}`;
modifiedDevice.serial = iSerial.string;
modifiedDevice.product = iProduct.string;
modifiedDevice.manufacturer = iManufacturer.string;
modifiedDevice.guid = guid;
// Set name if missing
if (deviceName === '') {
modifiedDevice.name = `${iProduct.string} ${iManufacturer.string}`.trim();
}
// Name still blank? Replace using fallback default
if (deviceName === '') {
modifiedDevice.name = '[unnamed device]';
}
// Ensure name is trimmed
modifiedDevice.name = device.name.trim();
return modifiedDevice;
};
const parseUsbDevices = (stdout: string) => stdout.split('\n').map(line => {
const regex = new RegExp(/^.+: ID (?<id>\S+)(?<name>.*)$/);
const result = regex.exec(line);
return (result!.groups as unknown as PciDevice);
}) || [];
// Get all usb devices
const usbDevices = await execa('lsusb').then(async({ stdout }) => {
return parseUsbDevices(stdout)
.map(parseDevice)
.filter(filterBootDrive)
.filter(filterUsbHubs)
.map(sanitizeVendorName);
});
return usbDevices;
};
/**
* Get device info.
*/
export const getAllDevices = async function(context: Readonly<CoreContext>): Promise<CoreResult> {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'devices',
action: 'read',
possession: 'any'
});
const devices = await pProps({
// Scsi: await scsiDevices,
gpu: await systemGPUDevices,
audio: await systemAudioDevices,
// Move this to interfaces
// network: await si.networkInterfaces(),
pci: await systemPciDevices(),
usb: await getSystemUSBDevices()
});
return {
text: `Devices: ${JSON.stringify(devices, null, 2)}`,
json: devices
};
};
+47
View File
@@ -0,0 +1,47 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext } from '../../types';
import { docker, ensurePermission } from '../../utils';
/**
* Two arrays containing the installed and started containers.
*
* @param installed The amount of installed containers.
* @param started The amount of running containers.
* @interface Result
* @extends {CoreResult}
*/
interface Result extends CoreResult {
json: {
installed: number;
started: number;
};
}
/**
* Get count of docker containers
*/
export const getAppCount = async function(context: Readonly<CoreContext>): Promise<Result> {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'docker/container',
action: 'read',
possession: 'any'
});
const installed = await docker.listContainers({ all: true }).catch(() => []).then(containers => containers.length);
const started = await docker.listContainers().catch(() => []).then(containers => containers.length);
return {
text: `Installed: ${installed} \nStarted: ${started}`,
json: {
installed,
started
}
};
};
+29
View File
@@ -0,0 +1,29 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import si from 'systeminformation';
import { CoreContext, CoreResult } from '../../types';
import { ensurePermission } from '../../utils';
export const getBaseboard = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'baseboard',
action: 'read',
possession: 'any'
});
// @TODO: Convert baseboard.model to known model name
// e.g. 084YMW -> R510
const baseboard = await si.baseboard();
return {
json: {
...baseboard
}
};
};
+37
View File
@@ -0,0 +1,37 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import si from 'systeminformation';
import { CoreResult, CoreContext } from '../../types';
import { ensurePermission } from '../../utils';
/**
* Get CPU info.
*/
export const getCpu = async function(context: CoreContext): Promise<CoreResult> {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'cpu',
action: 'read',
possession: 'any'
});
const { cores, physicalCores, ...cpu } = await si.cpu();
const flags = await si.cpuFlags().then(flags => flags.split(' '));
const result = {
...cpu,
cores: physicalCores,
threads: cores,
flags
};
return {
text: `CPU info: ${JSON.stringify(result, null, 2)}`,
json: result
};
};
+64
View File
@@ -0,0 +1,64 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext, DynamixConfig } from '../../types';
import { paths } from '../../paths';
import { loadState, ensurePermission } from '../../utils';
interface Result extends CoreResult {
json: {
scale: boolean;
tabs: boolean;
resize: boolean;
wwn: boolean;
total: boolean;
usage: boolean;
text: boolean;
warning: number;
critical: number;
hot: number;
max: number;
};
}
const toBoolean = (prop: string): boolean => prop.toLowerCase() === 'true';
/**
* Get display info.
*/
export const getDisplay = async function(context: CoreContext): Promise<Result> {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'display',
action: 'read',
possession: 'any'
});
const filePath = paths.get('dynamix-config')!;
const { display } = loadState<DynamixConfig>(filePath);
const result = {
...display,
scale: toBoolean(display.scale),
tabs: toBoolean(display.tabs),
resize: toBoolean(display.resize),
wwn: toBoolean(display.wwn),
total: toBoolean(display.total),
usage: toBoolean(display.usage),
text: toBoolean(display.text),
warning: Number.parseInt(display.warning, 10),
critical: Number.parseInt(display.critical, 10),
hot: Number.parseInt(display.hot, 10),
max: Number.parseInt(display.max, 10)
};
return {
text: `Display: ${JSON.stringify(result, null, 2)}`,
json: {
...result
}
};
};
+28
View File
@@ -0,0 +1,28 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext } from '../../types';
import { ensurePermission, getMachineId as getMachineIdFromFile } from '../../utils';
/**
* Get the machine ID.
*/
export const getMachineId = async function(context: CoreContext): Promise<CoreResult> {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'machine-id',
action: 'read',
possession: 'any'
});
const machineId = getMachineIdFromFile();
return {
text: `Machine ID: ${JSON.stringify(machineId, null, 2)}`,
json: machineId
};
};
+59
View File
@@ -0,0 +1,59 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import execa from 'execa';
import si from 'systeminformation';
import toBytes from 'bytes';
import { CoreContext, CoreResult } from '../../types';
import { AppError } from '../../errors';
import { cleanStdout, ensurePermission } from '../../utils';
/**
* Get memory.
*/
export const getMemory = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'memory',
action: 'read',
possession: 'any'
});
const layout = await si.memLayout();
const info = await si.mem();
let max = info.total;
// Max memory
try {
const memoryInfo = await execa('dmidecode', ['-t', 'memory'])
.then(cleanStdout)
.catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') {
throw new AppError('The dmidecode cli utility is missing.');
}
throw error;
});
const lines = memoryInfo.split('\n');
const header = lines.find(line => line.startsWith('Physical Memory Array'))!;
const start = lines.indexOf(header);
const nextHeaders = lines.slice(start, -1).find(line => line.startsWith('Handle '))!;
const end = lines.indexOf(nextHeaders);
const fields = lines.slice(start, end);
max = toBytes(fields.find(line => line.trim().startsWith('Maximum Capacity'))!.trim().split(': ')[1]);
} catch {}
const result = {
layout,
max,
...info
};
return {
json: result
};
};
+38
View File
@@ -0,0 +1,38 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import si from 'systeminformation';
import { CoreContext, CoreResult } from '../../types';
import { ensurePermission } from '../../utils';
/**
* Get OS info
*
* @memberof Core
* @module info/get-os
*/
export const getOs = async function(context: CoreContext): Promise<CoreResult> {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'os',
action: 'read',
possession: 'any'
});
const os = await si.osInfo();
return {
get text() {
return `OS info: ${JSON.stringify(os, null, 2)}`;
},
get json() {
return {
...os
};
}
};
};
@@ -0,0 +1,41 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import si from 'systeminformation';
import { CacheManager } from '../../cache-manager';
import { CoreResult, CoreContext } from '../../types';
import { ensurePermission } from '../../utils';
const cache = new CacheManager('unraid:modules:get-system');
/**
* Software versions.
* @returns Versions of all the core software.
*/
export const getSoftwareVersions = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'software-versions',
action: 'read',
possession: 'any'
});
let versions = cache.get<si.Systeminformation.VersionData>('versions');
// Only update when cache is empty or doesn't exist yet
if (!versions) {
versions = await si.versions();
cache.set('versions', versions);
}
return {
text: `System versions: ${JSON.stringify(versions, null, 2)}`,
json: {
...versions
}
};
};
@@ -0,0 +1,66 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import fs from 'fs';
import semver from 'semver';
import { paths } from '../../paths';
import { CacheManager } from '../../cache-manager';
import { FileMissingError, FatalAppError } from '../../errors';
import { ensurePermission } from '../../utils';
import { CoreResult, CoreContext } from '../../types';
const cache = new CacheManager('unraid:modules:get-unraid');
interface Result extends CoreResult {
json: {
unraid: string
}
}
/**
* Unraid version string.
* @returns The current version.
*/
export const getUnraidVersion = async(context: CoreContext): Promise<Result> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'unraid-version',
action: 'read',
possession: 'any'
});
let version = cache.get<string>('version');
// Only update when cache is empty or doesn't exist yet
if (!version) {
const filePath = paths.get('unraid-version')!;
const file = await fs.promises.readFile(filePath)
.catch(() => {
throw new FileMissingError(filePath);
})
.then(buffer => buffer.toString());
// Ensure string is semver compliant
const semverVersion = semver.parse(file.split('"')[1])?.version;
if (!semverVersion) {
throw new FatalAppError('Invalid unraid version file.');
}
version = semverVersion;
// Update cache
cache.set('version', version);
}
return {
text: `Version: ${version}`,
json: {
unraid: version
}
};
};
+27
View File
@@ -0,0 +1,27 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { getUnraidVersion, getSoftwareVersions } from '.';
import { CoreResult, CoreContext } from '../../types';
/**
* Get all version info.
*/
export const getVersions = async function(context: CoreContext): Promise<CoreResult> {
const unraidVersion = await getUnraidVersion(context).then(result => result.json);
const softwareVersions = await getSoftwareVersions(context).then(result => result.json);
const versions = {
...unraidVersion,
...softwareVersions
};
return {
text: `Versions: ${JSON.stringify(versions, null, 2)}`,
json: {
...versions
}
};
};
+13
View File
@@ -0,0 +1,13 @@
// Created from 'create-ts-index'
export * from './get-all-devices';
export * from './get-app-count';
export * from './get-baseboard';
export * from './get-cpu';
export * from './get-display';
export * from './get-machine-id';
export * from './get-memory';
export * from './get-os';
export * from './get-software-versions';
export * from './get-unraid-version';
export * from './get-versions';
+45
View File
@@ -0,0 +1,45 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import execa from 'execa';
import { cleanStdout, ensurePermission } from '../../utils';
import { CoreContext, CoreResult } from '../../types';
interface Result extends CoreResult {
json: {
online: boolean;
uptime: number;
};
}
/**
* Get emhttpd service info.
*/
export const getEmhttpdService = async(context: CoreContext): Promise<Result> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'service/emhttpd',
action: 'read',
possession: 'any'
});
// Only get uptime if process is online
const uptime = await execa('ps', ['-C', 'emhttpd', '-o', 'etimes', '--no-headers'])
.then(cleanStdout)
.then(uptime => Number.parseInt(uptime, 10))
.catch(() => -1);
const online = uptime >= 1;
return {
text: `Online: ${online}\n Uptime: ${uptime}`,
json: {
online,
uptime
}
};
};
+24
View File
@@ -0,0 +1,24 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { getNodeService, NodeService } from '../../utils';
import { CoreContext, CoreResult } from '../../types';
const namespace = 'node-api';
interface Result extends CoreResult {
json: NodeService
}
/**
* Get node api service info.
*/
export const getNodeApiService = async(context: CoreContext): Promise<Result> => {
const service = await getNodeService(context.user, namespace);
return {
text: `Service: ${JSON.stringify(service, null, 2)}`,
json: service
};
};
+4
View File
@@ -0,0 +1,4 @@
// Created from 'create-ts-index'
export * from './get-emhttpd';
export * from './get-node-api';
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './update-disk';
+143
View File
@@ -0,0 +1,143 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../types';
import { Var } from '../../types/states';
import { varState } from '../../states';
import { EmCmdError } from '../../errors';
import { emcmd, ensurePermission } from '../../utils';
interface Context extends CoreContext {
data: Var;
}
interface Result extends CoreResult {
json: {
mdwriteMethod?: number;
startArray?: boolean;
spindownDelay?: number;
defaultFormat?: any;
defaultFsType?: any;
};
}
/**
* Update disk settings.
*/
export const updateDisk = async(context: Context): Promise<Result> => {
const { data, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'disk/settings',
action: 'update',
possession: 'any'
});
/**
* Check context.data[property] is using an allowed value.
*
* @param property The property of data to check values against.
* @param allowedValues Which values which are allowed.
* @param optional If the value can also be undefined.
*/
const check = (property: string, allowedValues: { [key: string]: string } | string[], optional = true): void => {
const value = data[property];
// Skip checking if the value isn't needed and it's not set
if (optional && value === undefined) {
return;
}
// AllowedValues is an object
if (!Array.isArray(allowedValues)) {
allowedValues = Object.keys(allowedValues);
}
if (!allowedValues.includes(value)) {
throw new EmCmdError(property, value, allowedValues);
}
};
// If set to 'Yes' then if the device configuration is correct upon server start - up, the array will be automatically started and shares exported.
// If set to 'No' then you must start the array yourself.
check('startArray', ['yes', 'no']);
// Define the 'default' time-out for spinning hard drives down after a period of no I/O activity.
// You may also override the default value for an individual disk.
check('spindownDelay', {
0: 'Never',
15: '15 minutes',
30: '30 minutes',
45: '45 minutes',
1: '1 hour',
2: '2 hours',
3: '3 hours',
4: '4 hours',
5: '5 hours',
6: '6 hours',
7: '7 hours',
8: '8 hours',
9: '9 hours'
});
// Defines the type of partition layout to create when formatting hard drives 2TB in size and smaller **only**. (All devices larger then 2TB are always set up with GPT partition tables.)
// **MBR: unaligned** setting will create MBR-style partition table, where the single partition 1 will start in the **63rd sector** from the start of the disk. This is the *traditional* setting for virtually all MBR-style partition tables.
// **MBR: 4K-aligned** setting will create an MBR-style partition table, where the single partition 1 will start in the **64th sector** from the start of the disk. Since the sector size is 512 bytes, this will *align* the start of partition 1 on a 4K-byte boundary. This is required for proper support of so-called *Advanced Format* drives.
// Unless you have a specific requirement do not change this setting from the default **MBR: 4K-aligned**.
check('defaultFormat', {
1: 'MBR: unaligned',
2: 'MBR: 4K-aligned'
});
// Selects the method to employ when writing to enabled disk in parity protected array.
check('writeMethod', {
auto: 'Auto - read/modify/write',
0: 'read/modify/write',
1: 'reconstruct write'
});
// Defines the default file system type to create when an * unmountable * array device is formatted.
// The default file system type for a single or multi - device cache is always Btrfs.
check('defaultFsType', {
xfs: 'xfs',
btrfs: 'btrfs',
reiserfs: 'reiserfs',
'luks:xfs': 'xfs - encrypted',
'luks:btrfs': 'btrfs - encrypted',
'luks:reiserfs': 'reiserfs - encrypted'
});
const {
startArray,
spindownDelay,
defaultFormat,
defaultFsType,
mdWriteMethod
} = data;
await emcmd({
startArray,
spindownDelay,
defaultFormat,
defaultFsType,
md_write_method: mdWriteMethod,
changeDisk: 'Apply'
});
// @todo: return all disk settings
const result = {
mdwriteMethod: varState?.data?.mdWriteMethod,
startArray: varState?.data?.startArray,
spindownDelay: varState?.data?.spindownDelay,
defaultFormat: varState?.data?.defaultFormat,
defaultFsType: varState?.data?.defaultFormat
};
return {
text: `Disk settings: ${JSON.stringify(result, null, 2)}`,
json: result
};
};
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './name';
+51
View File
@@ -0,0 +1,51 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult, UserShare, DiskShare } from '../../../types';
import { AppError } from '../../../errors';
import { getShares, ensurePermission } from '../../../utils';
interface Context extends CoreContext {
params: {
/** Name of the share */
name: string;
};
}
interface Result extends CoreResult {
json: UserShare | DiskShare;
}
/**
* Get single share.
*/
export const getShare = async function(context: Context): Promise<Result> {
const { params, user } = context;
const { name } = params;
// Check permissions
ensurePermission(user, {
resource: 'share',
action: 'read',
possession: 'any'
});
const userShare = getShares('user', { name });
const diskShare = getShares('disk', { name });
const share = [
userShare,
diskShare
].filter(_ => _)[0];
if (!share) {
throw new AppError('No share found with that name.', 404);
}
return {
text: `Share: ${JSON.stringify(share, null, 2)}`,
json: share
};
};
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './get-share';
+49
View File
@@ -0,0 +1,49 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../../types';
import { AppError, FieldMissingError } from '../../../errors';
import { ensurePermission, hasFields } from '../../../utils';
import { usersState } from '../../../states';
interface Context extends CoreContext {
params: {
/** Name of user to add the role to. */
name: string;
};
}
/**
* Add role to user.
*/
export const addRole = async(context: Context): Promise<CoreResult> => {
const { user, params } = context;
// Check permissions
ensurePermission(user, {
resource: 'user',
action: 'update',
possession: 'any'
});
// Validation
const { name } = params;
const missingFields = hasFields(params, ['name']);
if (missingFields.length !== 0) {
throw new FieldMissingError(missingFields[0]);
}
// Check user exists
if (!usersState.findOne({ name })) {
throw new AppError('No user exists with this name.');
}
// @todo: add user role
return {
text: 'User updated successfully.'
};
};
+53
View File
@@ -0,0 +1,53 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../../types';
import { AppError, FieldMissingError } from '../../../errors';
import { emcmd, hasFields, ensurePermission } from '../../../utils';
import { usersState } from '../../../states';
interface Context extends CoreContext {
params: {
/** Name of user to delete. */
name: string;
};
}
/**
* Delete user account.
*/
export const deleteUser = async(context: Context): Promise<CoreResult> => {
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'delete',
possession: 'any'
});
const { params } = context;
const { name } = params;
const missingFields = hasFields(params, ['name']);
if (missingFields.length !== 0) {
// Just throw the first error
throw new FieldMissingError(missingFields[0]);
}
// Check user exists
if (!usersState.findOne({ name })) {
throw new AppError('No user exists with this name.');
}
// Delete user
await emcmd({
userName: name,
confirmDelete: 'on',
cmdUserEdit: 'Delete'
});
return {
text: 'User deleted successfully.'
};
};
+48
View File
@@ -0,0 +1,48 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../../types';
import { AppError } from '../../../errors';
import { usersState } from '../../../states';
import { ensureParameter } from '../../../utils/validation/context';
import { ensurePermission } from '../../../utils';
interface Context extends CoreContext {
params: {
/** User ID */
id: string;
};
}
/**
* Get single user.
* @returns The selected user.
*/
export const getUser = async(context: Context): Promise<CoreResult> => {
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'create',
possession: 'any'
});
ensureParameter(context, 'id');
const id = context?.params?.id;
if (!id) {
throw new AppError('No id passed.');
}
const user = usersState.findOne({ id });
if (!user) {
// This is likely a new install or something went horribly wrong
throw new AppError(`No users found matching ${id}`, 404);
}
return {
text: `User: ${JSON.stringify(user, null, 2)}`,
json: user
};
};
+5
View File
@@ -0,0 +1,5 @@
// Created from 'create-ts-index'
export * from './add-role';
export * from './delete-user';
export * from './get-user';
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './id';
@@ -0,0 +1,45 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreContext, CoreResult } from '../../../../types';
import { AppError } from '../../../../errors';
import { parseDomain, getHypervisor, ensurePermission } from '../../../../utils';
interface Context extends CoreContext {
params: {
/** Domain name. */
name: string;
};
}
/**
* Get a single vm domain.
*/
export const getDomain = async function(context: Context): Promise<CoreResult> {
const { params, user } = context;
const { name } = params;
// Check permissions
ensurePermission(user, {
resource: 'domain',
action: 'read',
possession: 'any'
});
const hypervisor = await getHypervisor();
// If domain doesn't exist return not found
await hypervisor.lookupDomainByNameAsync(name).catch(() => {
throw new AppError(`No domain found with name: ${name}`);
});
// Get domain info
const domain = await parseDomain('name', name);
return {
text: `${domain.name}: ${JSON.stringify(domain, null, 2)}`,
json: domain
};
};
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './get-domain';
+3
View File
@@ -0,0 +1,3 @@
// Created from 'create-ts-index'
export * from './domain';
+33
View File
@@ -0,0 +1,33 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { CoreResult, CoreContext } from '../../types';
import { parseDomains, getHypervisor, ensurePermission } from '../../utils';
/**
* Get vm domains.
*/
export const getDomains = async(context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'domain',
action: 'read',
possession: 'any'
});
const hypervisor = await getHypervisor();
const defined = await parseDomains('name', await hypervisor.listDefinedDomainsAsync());
const active = await parseDomains('id', await hypervisor.listActiveDomainsAsync());
return {
text: `Defined domains: ${JSON.stringify(defined, null, 2)}\nActive domains: ${JSON.stringify(active, null, 2)}`,
json: [
...defined,
...active
]
};
};
+4
View File
@@ -0,0 +1,4 @@
// Created from 'create-ts-index'
export * from './domains';
export * from './get-domains';
+35
View File
@@ -0,0 +1,35 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { Notifier, NotifierOptions, NotifierSendOptions } from './notifier';
import { log } from '../log';
/**
* Console notifier.
*/
export class ConsoleNotifier extends Notifier {
private readonly log: typeof log;
constructor(options: NotifierOptions) {
super(options);
this.level = options.level || 'info';
this.helpers = options.helpers ?? {};
this.template = options.template ?? '{{{ json }}}';
this.log = log;
}
/**
* Send notification.
*/
send(options: NotifierSendOptions) {
const { title, data } = options;
const { level, helpers } = this;
// Render template
const template = this.render({ ...data }, helpers);
this.log[level](title, template);
}
}
+66
View File
@@ -0,0 +1,66 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import sendmail from 'sendmail';
import { log } from '../log';
import { Notifier, NotifierOptions, NotifierSendOptions } from './notifier';
interface Options extends NotifierOptions {
to: string
from?: string
replyTo?: string
}
interface SendOptions extends NotifierSendOptions {};
/**
* Email notifer
*/
export class EmailNotifier extends Notifier {
private readonly to: string;
private readonly from: string;
private readonly replyTo: string;
constructor(options: Options) {
super(options);
this.to = options.to;
// @todo: replace with `no-reply@host.tld`.
this.from = options.from ?? 'no-reply@tower.local';
// @todo: replace with `user@host.tld`.
this.replyTo = options.replyTo ?? 'root@tower.local';
}
send(options: SendOptions) {
const { type = 'generic', title = 'Unraid Server Notification' } = options;
const { to, from, replyTo, level } = this;
// Only show info when in debug
const silent = level !== 'debug';
const sendMail = sendmail({ silent });
// Default html templates
const templates = {
generic: `
<h1>{{ title }}</h1>
<p><pre>{{ json }}</pre></p>
`.trim()
};
// Render template
this.template = Object.keys(templates).includes(type) ? templates[type] : templates.generic;
const html = this.render({ ...options, json: JSON.stringify(options.data, null, 2) }, this.helpers);
return sendMail({
from,
to,
replyTo,
subject: title,
html
}, (error, reply) => {
log.error(error?.stack);
log.info(reply);
});
}
}

Some files were not shown because too many files have changed in this diff Show More