mirror of
https://github.com/unraid/api.git
synced 2026-04-30 20:20:36 -05:00
refactor: add core into node-api
This commit is contained in:
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { config } from '@unraid/core';
|
||||
import { config } from './core/config';
|
||||
|
||||
const internalWsAddress = () => {
|
||||
const port = config.get('port');
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './announce';
|
||||
export * from './listen';
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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!');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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
@@ -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()}.`);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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: {}
|
||||
};
|
||||
};
|
||||
@@ -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>`
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './get-context';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './get-disk';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './get-docker-containers';
|
||||
export * from './get-docker-networks';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './get-emhttpd';
|
||||
export * from './get-node-api';
|
||||
@@ -0,0 +1,3 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './update-disk';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './name';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './get-share';
|
||||
@@ -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.'
|
||||
};
|
||||
};
|
||||
@@ -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.'
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './add-role';
|
||||
export * from './delete-user';
|
||||
export * from './get-user';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,3 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './domain';
|
||||
@@ -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
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './domains';
|
||||
export * from './get-domains';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user