feat: switch to nest-commander

This commit is contained in:
Eli Bosley
2025-01-17 14:03:36 -05:00
parent 3348a47470
commit 7ceac1b184
25 changed files with 1090 additions and 1090 deletions

639
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -101,6 +101,7 @@
"mustache": "^4.2.0",
"nest-access-control": "^3.1.0",
"nest-authz": "^2.11.0",
"nest-commander": "^3.15.0",
"nestjs-pino": "^4.1.0",
"node-cache": "^5.1.2",
"node-window-polyfill": "^1.0.2",
@@ -120,7 +121,6 @@
"stoppable": "^1.1.0",
"strftime": "^0.10.3",
"systeminformation": "^5.23.5",
"ts-command-line-args": "^2.5.1",
"uuid": "^11.0.2",
"ws": "^8.18.0",
"xhr2": "^0.2.1",

View File

@@ -1,13 +1,26 @@
#!/usr/bin/env node
import '@app/dotenv';
import { main } from '@app/cli/index';
import { internalLogger } from '@app/core/log';
import { execSync } from 'child_process';
import { CommandFactory } from 'nest-commander';
import { cliLogger, internalLogger } from '@app/core/log';
import { CliModule } from '@app/unraid-api/cli/cli.module';
try {
await main();
const shellToUse = execSync('which bash');
await CommandFactory.run(CliModule, {
cliName: 'unraid-api',
logger: false,
completion: {
fig: true,
cmd: 'unraid-api',
nativeShell: { executablePath: shellToUse.toString('utf-8') },
},
});
} catch (error) {
console.log(error);
cliLogger.error('ERROR:', error);
internalLogger.error({
message: 'Failed to start unraid-api',
error,

View File

@@ -1,119 +0,0 @@
import { ArgumentConfig, parse } from 'ts-command-line-args';
import { cliLogger } from '@app/core/log';
import { Role } from '@app/graphql/generated/api/types';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
enum Command {
Get = 'get',
Create = 'create',
}
type KeyFlags = {
create?: boolean;
command: string;
description?: string;
name: string;
permissions?: string;
roles?: string;
};
const validRoles: Set<Role> = new Set(Object.values(Role));
const validateRoles = (rolesStr?: string): Role[] => {
if (!rolesStr) return [Role.GUEST];
const requestedRoles = rolesStr.split(',').map((role) => role.trim().toUpperCase() as Role);
const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role));
if (validRequestedRoles.length === 0) {
throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`);
}
const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role));
if (invalidRoles.length > 0) {
cliLogger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
}
return validRequestedRoles;
};
const keyOptions: ArgumentConfig<KeyFlags> = {
command: { type: String, description: 'get or create' },
name: { type: String, description: 'Name of the API key', typeLabel: '{underline name}' },
create: { type: Boolean, optional: true, description: "Create the key if it doesn't exist" },
description: { type: String, optional: true, description: 'Description of the API key' },
roles: {
type: String,
optional: true,
description: `Comma-separated list of roles (${Object.values(Role).join(', ')})`,
typeLabel: '{underline role1,role2}',
},
permissions: {
type: String,
optional: true,
description: 'Comma-separated list of permissions',
typeLabel: '{underline perm1,perm2}',
},
};
export const key = async (...argv: string[]) => {
try {
const options = parse<KeyFlags>(keyOptions, { argv });
const apiKeyService = new ApiKeyService();
if (!options.name) {
throw new Error('Name is required');
}
switch (options.command) {
case Command.Create: {
const roles = validateRoles(options.roles);
const key = await apiKeyService.create(
options.name,
options.description || `CLI generated key: ${options.name}`,
roles,
true
);
cliLogger.info('API Key: ', key);
cliLogger.info('API key created successfully');
break;
}
case Command.Get: {
const key = await apiKeyService.findByField('name', options.name);
if (!key && options.create) {
const roles = validateRoles(options.roles);
const newKey = await apiKeyService.create(
options.name,
options.description || `CLI generated key: ${options.name}`,
roles,
true
);
cliLogger.info('New API Key: ', newKey);
cliLogger.info('API key created successfully');
} else if (key) {
cliLogger.info('API Key: ', key);
} else {
throw new Error(`No API key found with name: ${options.name}`);
}
break;
}
default:
throw new Error(`Invalid command. Use: ${Object.values(Command).join(' or ')}`);
}
} catch (error) {
if (error instanceof Error) {
cliLogger.error(`Failed to process API key: ${error.message}`);
}
process.exit(1);
}
};

View File

@@ -1,365 +0,0 @@
import { stdout } from 'process';
import readLine from 'readline';
import { ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@apollo/client/core/index.js';
import ipRegex from 'ip-regex';
import { setEnv } from '@app/cli/set-env';
import { cliLogger } from '@app/core/log';
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running';
import { API_VERSION } from '@app/environment';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { getters, store } from '@app/store';
import { loadConfigFile } from '@app/store/modules/config';
import { loadStateFiles } from '@app/store/modules/emhttp';
import type { getCloudQuery, getServersQuery } from '../../graphql/generated/api/operations';
import { getApiApolloClient } from '../../graphql/client/api/get-api-client';
import { getCloudDocument, getServersDocument } from '../../graphql/generated/api/operations';
type CloudQueryResult = NonNullable<ApolloQueryResult<getCloudQuery>['data']['cloud']>;
type ServersQueryResultServer = NonNullable<ApolloQueryResult<getServersQuery>['data']['servers']>[0];
type Verbosity = '' | '-v' | '-vv';
type ServersPayload = {
online: ServersQueryResultServer[];
offline: ServersQueryResultServer[];
invalid: ServersQueryResultServer[];
};
type ReportObject = {
os: {
serverName: string;
version: string;
};
api: {
version: string;
status: 'running' | 'stopped';
environment: string;
nodeVersion: string;
};
apiKey: 'valid' | 'invalid' | string;
servers?: ServersPayload | null;
myServers: {
status: 'authenticated' | 'signed out';
myServersUsername?: string;
};
minigraph: {
status: MinigraphStatus;
timeout: number | null;
error: string | null;
};
cloud: {
status: string;
error?: string;
ip?: string;
allowedOrigins?: string[] | null;
};
};
// This should return the status of the apiKey and mothership
export const getCloudData = async (
client: ApolloClient<NormalizedCacheObject>
): Promise<CloudQueryResult | null> => {
try {
const cloud = await client.query({ query: getCloudDocument });
return cloud.data.cloud ?? null;
} catch (error: unknown) {
cliLogger.trace(
'Failed fetching cloud from local graphql with "%s"',
error instanceof Error ? error.message : 'Unknown Error'
);
return null;
}
};
export const getServersData = async ({
client,
v,
}: {
client: ApolloClient<NormalizedCacheObject>;
v: Verbosity;
}): Promise<ServersPayload | null> => {
if (v === '') {
return null;
}
try {
const servers = await client.query({ query: getServersDocument });
const foundServers = servers.data.servers.reduce<ServersPayload>(
(acc, curr) => {
switch (curr.status) {
case 'online':
acc.online.push(curr);
break;
case 'offline':
acc.offline.push(curr);
break;
default:
acc.invalid.push(curr);
break;
}
return acc;
},
{ online: [], offline: [], invalid: [] }
);
return foundServers;
} catch (error: unknown) {
cliLogger.trace(
'Failed fetching servers from local graphql with "%s"',
error instanceof Error ? error.message : 'Unknown Error'
);
return {
online: [],
offline: [],
invalid: [],
};
}
};
const hashUrlRegex = () => /(.*)([a-z0-9]{40})(.*)/g;
export const anonymiseOrigins = (origins?: string[]): string[] => {
const originsWithoutSocks = origins?.filter((url) => !url.endsWith('.sock')) ?? [];
return originsWithoutSocks
.map((origin) =>
origin
// Replace 40 char hash string with "HASH"
.replace(hashUrlRegex(), '$1HASH$3')
// Replace ipv4 address using . separator with "IPV4ADDRESS"
.replace(ipRegex(), 'IPV4ADDRESS')
// Replace ipv4 address using - separator with "IPV4ADDRESS"
.replace(new RegExp(ipRegex().toString().replace('\\.', '-')), '/IPV4ADDRESS')
// Report WAN port
.replace(`:${getters.config().remote.wanport || 443}`, ':WANPORT')
)
.filter(Boolean);
};
const getAllowedOrigins = (cloud: CloudQueryResult | null, v: Verbosity): string[] | null => {
switch (v) {
case '-vv':
return cloud?.allowedOrigins.filter((url) => !url.endsWith('.sock')) ?? [];
case '-v':
return anonymiseOrigins(cloud?.allowedOrigins ?? []);
default:
return null;
}
};
const getReadableCloudDetails = (reportObject: ReportObject, v: Verbosity): string => {
const error = reportObject.cloud.error ? `\n ERROR [${reportObject.cloud.error}]` : '';
const status = reportObject.cloud.status ? reportObject.cloud.status : 'disconnected';
const ip = reportObject.cloud.ip && v !== '' ? `\n IP: [${reportObject.cloud.ip}]` : '';
return `
STATUS: [${status}] ${ip} ${error}`;
};
const getReadableMinigraphDetails = (reportObject: ReportObject): string => {
const statusLine = `STATUS: [${reportObject.minigraph.status}]`;
const errorLine = reportObject.minigraph.error ? ` ERROR: [${reportObject.minigraph.error}]` : null;
const timeoutLine = reportObject.minigraph.timeout
? ` TIMEOUT: [${(reportObject.minigraph.timeout || 1) / 1_000}s]`
: null; // 1 in case of divide by zero
return `
${statusLine}${errorLine ? `\n${errorLine}` : ''}${timeoutLine ? `\n${timeoutLine}` : ''}`;
};
// Convert server to string output
const serverToString = (v: Verbosity) => (server: ServersQueryResultServer) =>
`${server?.name ?? 'No Server Name'}${
v === '-v' || v === '-vv'
? `[owner="${server.owner?.username ?? 'No Owner Found'}"${
v === '-vv' ? ` guid="${server.guid ?? 'No GUID'}"]` : ']'
}`
: ''
}`;
const getReadableServerDetails = (reportObject: ReportObject, v: Verbosity): string => {
if (!reportObject.servers) {
return '';
}
if (reportObject.api.status === 'stopped') {
return '\nSERVERS: API is offline';
}
const invalid =
(v === '-v' || v === '-vv') && reportObject.servers.invalid.length > 0
? `
INVALID: ${reportObject.servers.invalid.map(serverToString(v)).join(',')}`
: '';
return `
SERVERS:
ONLINE: ${reportObject.servers.online.map(serverToString(v)).join(',')}
OFFLINE: ${reportObject.servers.offline.map(serverToString(v)).join(',')}${invalid}`;
};
const getReadableAllowedOrigins = (reportObject: ReportObject): string => {
const { cloud } = reportObject;
if (cloud?.allowedOrigins) {
return `
ALLOWED_ORIGINS: ${cloud.allowedOrigins.join(', ').trim()}`;
}
return '';
};
const getVerbosity = (argv: string[]): Verbosity => {
if (argv.includes('-v')) {
return '-v';
}
if (argv.includes('-vv')) {
return '-vv';
}
return '';
};
export const report = async (...argv: string[]) => {
// Check if the user has raw output enabled
const rawOutput = argv.includes('--raw');
// Check if we have a tty attached to stdout
// If we don't then this is being piped to a log file, etc.
const hasTty = process.stdout.isTTY;
// Check if we should show interactive logs
// If this has a tty it's interactive
// AND
// If they don't have --raw
const isInteractive = hasTty && !rawOutput;
const stdoutLogger = readLine.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
setEnv('LOG_TYPE', 'raw');
// Show loading message
if (isInteractive) {
stdoutLogger.write('Generating report please wait…');
}
const jsonReport = argv.includes('--json');
const v = getVerbosity(argv);
// Find all processes called "unraid-api" which aren't this process
const unraidApiRunning = await isUnraidApiRunning();
// Load my servers config file into store
await store.dispatch(loadConfigFile());
await store.dispatch(loadStateFiles());
const { config, emhttp } = store.getState();
if (!config.upc.apikey) throw new Error('Missing UPC API key');
const client = getApiApolloClient({ localApiKey: config.remote.localApiKey || '' });
// Fetch the cloud endpoint
const cloud = await getCloudData(client);
// Log cloud response
cliLogger.trace('Cloud response %s', JSON.stringify(cloud, null, 0));
// Query local graphql using upc's API key
// Get the servers array
const servers = await getServersData({ client, v });
// Check if the API key is valid
const isApiKeyValid = cloud?.apiKey.valid ?? false;
const reportObject: ReportObject = {
os: {
serverName: emhttp.var.name,
version: emhttp.var.version,
},
api: {
version: API_VERSION,
status: unraidApiRunning ? 'running' : 'stopped',
environment: process.env.ENVIRONMENT ?? 'THIS_WILL_BE_REPLACED_WHEN_BUILT',
nodeVersion: process.version,
},
apiKey: isApiKeyValid ? 'valid' : (cloud?.apiKey.error ?? 'invalid'),
...(servers ? { servers } : {}),
myServers: {
status: config?.remote?.username ? 'authenticated' : 'signed out',
...(config?.remote?.username
? {
myServersUsername: config?.remote?.username?.includes('@')
? 'REDACTED'
: config?.remote.username,
}
: {}),
},
minigraph: {
status: cloud?.minigraphql.status ?? MinigraphStatus.PRE_INIT,
timeout: cloud?.minigraphql.timeout ?? null,
error:
(cloud?.minigraphql.error ?? !cloud?.minigraphql.status) ? 'API Disconnected' : null,
},
cloud: {
status: cloud?.cloud.status ?? 'error',
...(cloud?.cloud.error ? { error: cloud.cloud.error } : {}),
...(cloud?.cloud.status === 'ok' ? { ip: cloud.cloud.ip ?? 'NO_IP' } : {}),
...(getAllowedOrigins(cloud, v) ? { allowedOrigins: getAllowedOrigins(cloud, v) } : {}),
},
};
// If we have trace logs or the user selected --raw don't clear the screen
if (process.env.LOG_LEVEL !== 'trace' && isInteractive) {
// Clear the original log about the report being generated
readLine.cursorTo(process.stdout, 0, 0);
readLine.clearScreenDown(process.stdout);
}
if (jsonReport) {
stdout.write(JSON.stringify(reportObject) + '\n');
stdoutLogger.close();
return reportObject;
} else {
// Generate the actual report
const report = `
<-----UNRAID-API-REPORT----->
SERVER_NAME: ${reportObject.os.serverName}
ENVIRONMENT: ${reportObject.api.environment}
UNRAID_VERSION: ${reportObject.os.version}
UNRAID_API_VERSION: ${reportObject.api.version}
UNRAID_API_STATUS: ${reportObject.api.status}
API_KEY: ${reportObject.apiKey}
MY_SERVERS: ${reportObject.myServers.status}${
reportObject.myServers.myServersUsername
? `\nMY_SERVERS_USERNAME: ${reportObject.myServers.myServersUsername}`
: ''
}
CLOUD: ${getReadableCloudDetails(reportObject, v)}
MINI-GRAPH: ${getReadableMinigraphDetails(reportObject)}${getReadableServerDetails(
reportObject,
v
)}${getReadableAllowedOrigins(reportObject)}
</----UNRAID-API-REPORT----->
`;
stdout.write(report);
stdoutLogger.close();
return report;
}
} catch (error: unknown) {
console.log({ error });
if (error instanceof Error) {
cliLogger.trace(error);
stdoutLogger.write(`\nFailed generating report with "${error.message}"\n`);
return;
}
stdout.write(`${error as string}`);
stdoutLogger.close();
}
};

View File

@@ -1,10 +0,0 @@
import { start } from '@app/cli/commands/start';
import { stop } from '@app/cli/commands/stop';
/**
* Stop a running API process and then start it again.
*/
export const restart = async () => {
await stop();
await start();
};

View File

@@ -1,16 +0,0 @@
import { PM2_PATH } from '@app/consts';
import { cliLogger } from '@app/core/log';
import { execSync } from 'child_process';
import { join } from 'node:path';
/**
* Start a new API process.
*/
export const start = async () => {
cliLogger.info('Starting unraid-api with command', `${PM2_PATH} start ${join(import.meta.dirname, 'ecosystem.config.json')} --update-env`);
execSync(`${PM2_PATH} start ${join(import.meta.dirname, '../../', 'ecosystem.config.json')} --update-env`, {
env: process.env,
stdio: 'inherit',
cwd: process.cwd()
});
};

View File

@@ -1,7 +0,0 @@
import { PM2_PATH } from '@app/consts';
import { execSync } from 'child_process';
export const status = async () => {
execSync(`${PM2_PATH} status unraid-api`, { stdio: 'inherit' });
process.exit(0);
};

View File

@@ -1,6 +0,0 @@
import { PM2_PATH } from '@app/consts';
import { execSync } from 'child_process';
export const stop = async () => {
execSync(`${PM2_PATH} stop unraid-api`, { stdio: 'inherit' });
};

View File

@@ -1,64 +0,0 @@
import { copyFile, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { cliLogger } from '@app/core/log';
import { getters } from '@app/store';
import { start } from '@app/cli/commands/start';
import { stop } from '@app/cli/commands/stop';
export const switchEnv = async () => {
const paths = getters.paths();
const basePath = paths['unraid-api-base'];
const envFlashFilePath = paths['myservers-env'];
const envFile = await readFile(envFlashFilePath, 'utf-8').catch(() => '');
await stop();
cliLogger.debug(
'Checking %s for current ENV, found %s',
envFlashFilePath,
envFile
);
// Match the env file env="production" which would be [0] = env="production", [1] = env and [2] = production
const matchArray = /([a-zA-Z]+)=["]*([a-zA-Z]+)["]*/.exec(envFile);
// Get item from index 2 of the regex match or return undefined
const [, , currentEnvInFile] =
matchArray && matchArray.length === 3 ? matchArray : [];
let newEnv = 'production';
// Switch from staging to production
if (currentEnvInFile === 'staging') {
newEnv = 'production';
}
// Switch from production to staging
if (currentEnvInFile === 'production') {
newEnv = 'staging';
}
if (currentEnvInFile) {
cliLogger.debug(
'Switching from "%s" to "%s"...',
currentEnvInFile,
newEnv
);
} else {
cliLogger.debug('No ENV found, setting env to "production"...');
}
// Write new env to flash
const newEnvLine = `env="${newEnv}"`;
await writeFile(envFlashFilePath, newEnvLine);
cliLogger.debug('Writing %s to %s', newEnvLine, envFlashFilePath);
// Copy the new env over to live location before restarting
const source = join(basePath, `.env.${newEnv}`);
const destination = join(basePath, '.env');
cliLogger.debug('Copying %s to %s', source, destination);
await copyFile(source, destination);
cliLogger.info('Now using %s', newEnv);
await start();
};

View File

@@ -1,57 +0,0 @@
import { execSync } from 'child_process';
import type { Flags } from '@app/cli/options';
import { args, mainOptions, options } from '@app/cli/options';
import { setEnv } from '@app/cli/set-env';
import { PM2_PATH } from '@app/consts';
const command = mainOptions.command as unknown as string;
export const main = async (...argv: string[]) => {
if (mainOptions.debug) {
const { cliLogger } = await import('@app/core/log');
const { getters } = await import('@app/store');
const ENVIRONMENT = await import('@app/environment');
cliLogger.debug({ paths: getters.paths(), environment: ENVIRONMENT }, 'Starting CLI');
}
setEnv('PORT', process.env.PORT ?? mainOptions.port ?? '9000');
if (!command) {
// Run help command
const { parse } = await import('ts-command-line-args');
parse<Flags>(args, {
...options,
partial: true,
stopAtFirstUnknown: true,
argv: ['-h'],
});
}
// Only import the command we need when we use it
const commands = {
key: import('@app/cli/commands/key').then((pkg) => pkg.key),
start: import('@app/cli/commands/start').then((pkg) => pkg.start),
stop: import('@app/cli/commands/stop').then((pkg) => pkg.stop),
restart: import('@app/cli/commands/restart').then((pkg) => pkg.restart),
logs: async () => execSync(`${PM2_PATH} logs unraid-api --lines 200`, { stdio: 'inherit' }),
'switch-env': import('@app/cli/commands/switch-env').then((pkg) => pkg.switchEnv),
version: import('@app/cli/commands/version').then((pkg) => pkg.version),
status: import('@app/cli/commands/status').then((pkg) => pkg.status),
report: import('@app/cli/commands/report').then((pkg) => pkg.report),
'validate-token': import('@app/cli/commands/validate-token').then((pkg) => pkg.validateToken),
};
// Unknown command
if (!Object.keys(commands).includes(command)) {
throw new Error(`Invalid command "${command}"`);
}
// Resolve the command import
const commandMethod = await commands[command];
// Run the command
await commandMethod(...argv);
process.exit(0);
};

View File

@@ -20,7 +20,6 @@ const level =
] ?? 'info';
export const logDestination = pino.destination({
minLength: 1_024,
sync: true,
});

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { KeyCommand } from '@app/unraid-api/cli/key.command';
import { LogService } from '@app/unraid-api/cli/log.service';
import { ReportCommand } from '@app/unraid-api/cli/report.command';
import { RestartCommand } from '@app/unraid-api/cli/restart.command';
import { StartCommand } from '@app/unraid-api/cli/start.command';
import { StopCommand } from '@app/unraid-api/cli/stop.command';
import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command';
import { VersionCommand } from '@app/unraid-api/cli/version.command';
import { StatusCommand } from '@app/unraid-api/cli/status.command';
import { ValidateTokenCommand } from '@app/unraid-api/cli/validate-token.command';
@Module({
providers: [
LogService,
StartCommand,
StopCommand,
RestartCommand,
ReportCommand,
KeyCommand,
SwitchEnvCommand,
VersionCommand,
StatusCommand,
ValidateTokenCommand
],
})
export class CliModule {}

View File

@@ -0,0 +1,102 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import { cliLogger } from '@app/core/log';
import { Role } from '@app/graphql/generated/api/types';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
interface KeyOptions {
create: boolean;
description?: string;
roles?: Array<Role>;
permissions?: Array<unknown>;
}
@Command({ name: 'key', arguments: '<name>' })
export class KeyCommand extends CommandRunner {
private readonly logger = new Logger(KeyCommand.name);
@Option({
flags: '--create',
description: 'Create a key if not found',
})
parseCreate(): boolean {
return true;
}
@Option({
flags: '-r, --roles <roles>',
description: `Comma-separated list of roles (${Object.values(Role).join(',')})`,
})
parseRoles(roles: string): Role[] {
if (!roles) return [Role.GUEST];
const validRoles: Set<Role> = new Set(Object.values(Role));
const requestedRoles = roles.split(',').map((role) => role.trim().toLocaleLowerCase() as Role);
const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role));
if (validRequestedRoles.length === 0) {
throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`);
}
const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role));
if (invalidRoles.length > 0) {
cliLogger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
}
return validRequestedRoles;
}
@Option({
flags: '-d, --description <description>',
description: 'Description to assign to the key',
})
parseDescription(description: string): string {
return description;
}
@Option({
flags: '-p, --permissions <permissions>',
description: 'Comma separated list of permissions to assign to the key',
})
parsePermissions(permissions: string) {
throw new Error('Stub Method Until Permissions PR is merged');
}
async run(passedParams: string[], options?: KeyOptions): Promise<void> {
console.log(options, passedParams);
const apiKeyService = new ApiKeyService();
const name = passedParams[0];
const create = options?.create ?? false;
const key = await apiKeyService.findByField('name', name);
if (key) {
this.logger.log(key);
} else if (create) {
if (!options) {
this.logger.error('Invalid Options for Create Flag');
return;
}
if (options.roles?.length === 0 && options.permissions?.length === 0) {
this.logger.error(
'Please add at least one role or permission with --roles or --permissions'
);
return;
}
const key = await apiKeyService.create(
name,
options.description || `CLI generated key: ${name}`,
options.roles ?? [],
true
);
this.logger.log(key);
} else {
this.logger.log('No Key Found');
process.exit(1);
}
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class LogService {
private logger = console;
clear(): void {
this.logger.clear();
}
log(message: string): void {
this.logger.log(message);
}
info(message: string): void {
this.logger.info(message);
}
warn(message: string): void {
this.logger.warn(message);
}
error(message: string, trace: string = ''): void {
this.logger.error(message, trace);
}
debug(message: any, ...optionalParams: any[]): void {
this.logger.debug(message, ...optionalParams);
}
}

View File

@@ -0,0 +1,376 @@
import { ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@apollo/client/core/index.js';
import ipRegex from 'ip-regex';
import { Command, CommandRunner, Option } from 'nest-commander';
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running';
import { API_VERSION } from '@app/environment';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { getters, store } from '@app/store';
import { loadConfigFile } from '@app/store/modules/config';
import { loadStateFiles } from '@app/store/modules/emhttp';
import { LogService } from '@app/unraid-api/cli/log.service';
import type { getCloudQuery, getServersQuery } from '../../graphql/generated/api/operations';
import { getApiApolloClient } from '../../graphql/client/api/get-api-client';
import { getCloudDocument, getServersDocument } from '../../graphql/generated/api/operations';
type CloudQueryResult = NonNullable<ApolloQueryResult<getCloudQuery>['data']['cloud']>;
type ServersQueryResultServer = NonNullable<ApolloQueryResult<getServersQuery>['data']['servers']>[0];
type ServersPayload = {
online: ServersQueryResultServer[];
offline: ServersQueryResultServer[];
invalid: ServersQueryResultServer[];
};
type ReportObject = {
os: {
serverName: string;
version: string;
};
api: {
version: string;
status: 'running' | 'stopped';
environment: string;
nodeVersion: string;
};
apiKey: 'valid' | 'invalid' | string;
servers?: ServersPayload | null;
myServers: {
status: 'authenticated' | 'signed out';
myServersUsername?: string;
};
minigraph: {
status: MinigraphStatus;
timeout: number | null;
error: string | null;
};
cloud: {
status: string;
error?: string;
ip?: string;
allowedOrigins?: string[] | null;
};
};
// This should return the status of the apiKey and mothership
export const getCloudData = async (
client: ApolloClient<NormalizedCacheObject>
): Promise<CloudQueryResult | null> => {
const cloud = await client.query({ query: getCloudDocument });
return cloud.data.cloud ?? null;
};
export const getServersData = async ({
client,
v,
}: {
client: ApolloClient<NormalizedCacheObject>;
v: number;
}): Promise<ServersPayload | null> => {
if (v === 0) {
return null;
}
try {
const servers = await client.query({ query: getServersDocument });
const foundServers = servers.data.servers.reduce<ServersPayload>(
(acc, curr) => {
switch (curr.status) {
case 'online':
acc.online.push(curr);
break;
case 'offline':
acc.offline.push(curr);
break;
default:
acc.invalid.push(curr);
break;
}
return acc;
},
{ online: [], offline: [], invalid: [] }
);
return foundServers;
} catch (error: unknown) {
return {
online: [],
offline: [],
invalid: [],
};
}
};
const hashUrlRegex = () => /(.*)([a-z0-9]{40})(.*)/g;
export const anonymiseOrigins = (origins?: string[]): string[] => {
const originsWithoutSocks = origins?.filter((url) => !url.endsWith('.sock')) ?? [];
return originsWithoutSocks
.map((origin) =>
origin
// Replace 40 char hash string with "HASH"
.replace(hashUrlRegex(), '$1HASH$3')
// Replace ipv4 address using . separator with "IPV4ADDRESS"
.replace(ipRegex(), 'IPV4ADDRESS')
// Replace ipv4 address using - separator with "IPV4ADDRESS"
.replace(new RegExp(ipRegex().toString().replace('\\.', '-')), '/IPV4ADDRESS')
// Report WAN port
.replace(`:${getters.config().remote.wanport || 443}`, ':WANPORT')
)
.filter(Boolean);
};
const getAllowedOrigins = (cloud: CloudQueryResult | null, v: number): string[] | null => {
if (v > 1) {
return cloud?.allowedOrigins.filter((url) => !url.endsWith('.sock')) ?? [];
} else if (v === 1) {
return anonymiseOrigins(cloud?.allowedOrigins ?? []);
}
return null;
};
const getReadableCloudDetails = (reportObject: ReportObject, v: number): string => {
const error = reportObject.cloud.error ? `\n ERROR [${reportObject.cloud.error}]` : '';
const status = reportObject.cloud.status ? reportObject.cloud.status : 'disconnected';
const ip = reportObject.cloud.ip && v !== 0 ? `\n IP: [${reportObject.cloud.ip}]` : '';
return `
STATUS: [${status}] ${ip} ${error}`;
};
const getReadableMinigraphDetails = (reportObject: ReportObject): string => {
const statusLine = `STATUS: [${reportObject.minigraph.status}]`;
const errorLine = reportObject.minigraph.error ? ` ERROR: [${reportObject.minigraph.error}]` : null;
const timeoutLine = reportObject.minigraph.timeout
? ` TIMEOUT: [${(reportObject.minigraph.timeout || 1) / 1_000}s]`
: null; // 1 in case of divide by zero
return `
${statusLine}${errorLine ? `\n${errorLine}` : ''}${timeoutLine ? `\n${timeoutLine}` : ''}`;
};
// Convert server to string output
const serverToString = (v: number) => (server: ServersQueryResultServer) =>
`${server?.name ?? 'No Server Name'}${
v > 0
? `[owner="${server.owner?.username ?? 'No Owner Found'}"${
v > 1 ? ` guid="${server.guid ?? 'No GUID'}"]` : ']'
}`
: ''
}`;
const getReadableServerDetails = (reportObject: ReportObject, v: number): string => {
if (!reportObject.servers) {
return '';
}
if (reportObject.api.status === 'stopped') {
return '\nSERVERS: API is offline';
}
const invalid =
v > 0 && reportObject.servers.invalid.length > 0
? `
INVALID: ${reportObject.servers.invalid.map(serverToString(v)).join(',')}`
: '';
return `
SERVERS:
ONLINE: ${reportObject.servers.online.map(serverToString(v)).join(',')}
OFFLINE: ${reportObject.servers.offline.map(serverToString(v)).join(',')}${invalid}`;
};
const getReadableAllowedOrigins = (reportObject: ReportObject): string => {
const { cloud } = reportObject;
if (cloud?.allowedOrigins) {
return `
ALLOWED_ORIGINS: ${cloud.allowedOrigins.join(', ').trim()}`;
}
return '';
};
interface ReportOptions {
raw: boolean;
json: boolean;
verbose: number;
}
@Command({ name: 'report' })
export class ReportCommand extends CommandRunner {
constructor(private readonly logger: LogService) {
super();
}
@Option({
flags: '-r, --raw',
description: 'whether to enable raw command output',
})
parseRaw(): boolean {
return true;
}
@Option({
flags: '-j, --json',
description: 'Display JSON output for this command',
})
parseJson(): boolean {
return true;
}
@Option({
flags: '-v, --verbose',
description: 'Verbosity level (-v -vv -vvv)',
})
handleVerbose(value: string | boolean, previous: number): number {
if (typeof value === 'boolean') {
// Single `-v` or `--verbose` flag increments verbosity
return (previous ?? 0) + 1;
} else if (value === undefined) {
return (previous ?? 0) + 1; // Increment if flag is used without value
} else {
// If `-vvv` is passed as one flag, count the number of `v`s
return (previous ?? 0) + value.length;
}
}
async report(options?: ReportOptions): Promise<string | ReportObject | void> {
const rawOutput = options?.raw ?? false;
// Check if we have a tty attached to stdout
// If we don't then this is being piped to a log file, etc.
const hasTty = process.stdout.isTTY;
// Check if we should show interactive logs
// If this has a tty it's interactive
// AND
// If they don't have --raw
const isInteractive = hasTty && !rawOutput;
try {
// Show loading message
if (isInteractive) {
this.logger.info('Generating report please wait…');
}
const jsonReport = options?.json ?? false;
const v = options?.verbose ?? 0;
// Find all processes called "unraid-api" which aren't this process
const unraidApiRunning = await isUnraidApiRunning();
// Load my servers config file into store
await store.dispatch(loadConfigFile());
await store.dispatch(loadStateFiles());
const { config, emhttp } = store.getState();
if (!config.upc.apikey) throw new Error('Missing UPC API key');
const client = getApiApolloClient({ localApiKey: config.remote.localApiKey || '' });
// Fetch the cloud endpoint
const cloud = await getCloudData(client)
.then((data) => {
this.logger.debug('Cloud Data', data);
return data;
})
.catch((error) => {
this.logger.debug(
'Failed fetching cloud from local graphql with "%s"',
error instanceof Error ? error.message : 'Unknown Error'
);
return null;
});
// Query local graphql using upc's API key
// Get the servers array
const servers = await getServersData({ client, v });
// Check if the API key is valid
const isApiKeyValid = cloud?.apiKey.valid ?? false;
const reportObject: ReportObject = {
os: {
serverName: emhttp.var.name,
version: emhttp.var.version,
},
api: {
version: API_VERSION,
status: unraidApiRunning ? 'running' : 'stopped',
environment: process.env.ENVIRONMENT ?? 'THIS_WILL_BE_REPLACED_WHEN_BUILT',
nodeVersion: process.version,
},
apiKey: isApiKeyValid ? 'valid' : (cloud?.apiKey.error ?? 'invalid'),
...(servers ? { servers } : {}),
myServers: {
status: config?.remote?.username ? 'authenticated' : 'signed out',
...(config?.remote?.username
? {
myServersUsername: config?.remote?.username?.includes('@')
? 'REDACTED'
: config?.remote.username,
}
: {}),
},
minigraph: {
status: cloud?.minigraphql.status ?? MinigraphStatus.PRE_INIT,
timeout: cloud?.minigraphql.timeout ?? null,
error:
(cloud?.minigraphql.error ?? !cloud?.minigraphql.status)
? 'API Disconnected'
: null,
},
cloud: {
status: cloud?.cloud.status ?? 'error',
...(cloud?.cloud.error ? { error: cloud.cloud.error } : {}),
...(cloud?.cloud.status === 'ok' ? { ip: cloud.cloud.ip ?? 'NO_IP' } : {}),
...(getAllowedOrigins(cloud, v)
? { allowedOrigins: getAllowedOrigins(cloud, v) }
: {}),
},
};
if (jsonReport) {
this.logger.clear();
this.logger.info(JSON.stringify(reportObject) + '\n');
return reportObject;
} else {
// Generate the actual report
const report = `
<-----UNRAID-API-REPORT----->
SERVER_NAME: ${reportObject.os.serverName}
ENVIRONMENT: ${reportObject.api.environment}
UNRAID_VERSION: ${reportObject.os.version}
UNRAID_API_VERSION: ${reportObject.api.version}
UNRAID_API_STATUS: ${reportObject.api.status}
API_KEY: ${reportObject.apiKey}
MY_SERVERS: ${reportObject.myServers.status}${
reportObject.myServers.myServersUsername
? `\nMY_SERVERS_USERNAME: ${reportObject.myServers.myServersUsername}`
: ''
}
CLOUD: ${getReadableCloudDetails(reportObject, v)}
MINI-GRAPH: ${getReadableMinigraphDetails(reportObject)}${getReadableServerDetails(
reportObject,
v
)}${getReadableAllowedOrigins(reportObject)}
</----UNRAID-API-REPORT----->
`;
this.logger.clear();
this.logger.info(report);
return report;
}
} catch (error: unknown) {
console.log({ error });
if (error instanceof Error) {
this.logger.debug(error);
this.logger.error(`\nFailed generating report with "${error.message}"\n`);
return;
}
}
}
async run(_: string[], options?: ReportOptions): Promise<void> {
await this.report(options);
}
}

View File

@@ -0,0 +1,18 @@
import { beforeAll, expect, test } from 'vitest';
import { store } from '@app/store';
import { loadConfigFile } from '@app/store/modules/config';
import { anonymiseOrigins } from '@app/unraid-api/cli/report.command';
beforeAll(async () => {
// Load cfg into store
await store.dispatch(loadConfigFile());
});
test('anonymise origins removes .sock origins', async () => {
expect(anonymiseOrigins(['/var/run/test.sock'])).toEqual([]);
});
test('anonymise origins hides WAN port', async () => {
expect(anonymiseOrigins(['https://domain.tld:8443'])).toEqual(['https://domain.tld:WANPORT']);
});

View File

@@ -0,0 +1,22 @@
import { PM2_PATH } from '@app/consts';
import { execSync } from 'child_process';
import { Command, CommandRunner } from 'nest-commander';
import { join } from 'path';
/**
* Stop a running API process and then start it again.
*/
@Command({ name: 'restart', description: 'Restart / Start the Unraid API'})
export class RestartCommand extends CommandRunner {
async run(_): Promise<void> {
execSync(
`${PM2_PATH} restart ${join(import.meta.dirname, '../../', 'ecosystem.config.json')} --update-env`,
{
env: process.env,
stdio: 'inherit',
cwd: process.cwd(),
}
);
}
}

View File

@@ -0,0 +1,49 @@
import { execSync } from 'child_process';
import { join } from 'path';
import { Command, CommandRunner, Option } from 'nest-commander';
import { PM2_PATH } from '@app/consts';
import { levels } from '@app/core/log';
import { LogService } from '@app/unraid-api/cli/log.service';
interface StartCommandOptions {
debug?: boolean;
port?: string;
'log-level'?: string;
environment?: string;
}
@Command({ name: 'start' })
export class StartCommand extends CommandRunner {
constructor(private readonly logger: LogService) {
super();
}
async run(_, options: StartCommandOptions): Promise<void> {
this.logger.debug(options);
this.logger.log(
`Starting unraid-api with command:
${PM2_PATH} start ${join(import.meta.dirname, 'ecosystem.config.json')} --update-env`
);
execSync(
`${PM2_PATH} start ${join(import.meta.dirname, '../../', 'ecosystem.config.json')} --update-env`,
{
env: process.env,
stdio: 'inherit',
cwd: process.cwd(),
}
);
}
@Option({
flags: '--log-level [string]',
description: 'log level to use',
})
parseLogLevel(val: unknown): typeof levels {
return (levels.includes(val as (typeof levels)[number])
? (val as (typeof levels)[number])
: 'info') as unknown as typeof levels;
}
}

View File

@@ -0,0 +1,13 @@
import { execSync } from 'child_process';
import { Command, CommandRunner } from 'nest-commander';
import { PM2_PATH } from '@app/consts';
@Command({ name: 'status', description: 'Check status of unraid-api service' })
export class StatusCommand extends CommandRunner {
async run(): Promise<void> {
execSync(`${PM2_PATH} status unraid-api`, { stdio: 'inherit' });
process.exit(0);
}
}

View File

@@ -0,0 +1,14 @@
import { execSync } from 'child_process';
import { Command, CommandRunner, SubCommand } from 'nest-commander';
import { PM2_PATH } from '@app/consts';
@Command({
name: 'stop',
})
export class StopCommand extends CommandRunner {
async run() {
execSync(`${PM2_PATH} stop unraid-api`, { stdio: 'inherit' });
}
}

View File

@@ -0,0 +1,89 @@
import { copyFile, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { Command, CommandRunner, Option } from 'nest-commander';
import { cliLogger } from '@app/core/log';
import { getters } from '@app/store';
import { LogService } from '@app/unraid-api/cli/log.service';
import { StartCommand } from '@app/unraid-api/cli/start.command';
import { StopCommand } from '@app/unraid-api/cli/stop.command';
interface SwitchEnvOptions {
environment?: 'staging' | 'production';
}
@Command({
name: 'switch-env',
})
export class SwitchEnvCommand extends CommandRunner {
private parseStringToEnv(environment: string): 'production' | 'staging' {
return ['production', 'staging'].includes(environment)
? (environment as 'production' | 'staging')
: 'production';
}
@Option({ flags: '-e, --environment <environment>' })
getEnvOption(environment: string): 'production' | 'staging' {
return this.parseStringToEnv(environment);
}
constructor(
private readonly logger: LogService,
private readonly stopCommand: StopCommand,
private readonly startCommand: StartCommand
) {
super();
}
private async getEnvironmentFromFile(path: string): Promise<'production' | 'staging'> {
const envFile = await readFile(path, 'utf-8').catch(() => '');
this.logger.debug(`Checking ${path} for current ENV, found ${envFile}`);
// Match the env file env="production" which would be [0] = env="production", [1] = env and [2] = production
const matchArray = /([a-zA-Z]+)=["]*([a-zA-Z]+)["]*/.exec(envFile);
// Get item from index 2 of the regex match or return production
const [, , currentEnvInFile] = matchArray && matchArray.length === 3 ? matchArray : [];
return this.parseStringToEnv(currentEnvInFile);
}
private switchToOtherEnv(environment: 'production' | 'staging'): 'production' | 'staging' {
if (environment === 'production') {
return 'staging';
}
return 'production';
}
async run(_, options: SwitchEnvOptions): Promise<void> {
const paths = getters.paths();
const basePath = paths['unraid-api-base'];
const envFlashFilePath = paths['myservers-env'];
this.logger.warn('Stopping the Unraid API');
try {
await this.stopCommand.run();
} catch (err) {
this.logger.warn('Failed to stop the Unraid API (maybe already stopped?)');
}
const newEnv =
options.environment ??
this.switchToOtherEnv(await this.getEnvironmentFromFile(envFlashFilePath));
this.logger.info(`Setting environment to ${newEnv}`);
// Write new env to flash
const newEnvLine = `env="${newEnv}"`;
this.logger.debug('Writing %s to %s', newEnvLine, envFlashFilePath);
await writeFile(envFlashFilePath, newEnvLine);
// Copy the new env over to live location before restarting
const source = join(basePath, `.env.${newEnv}`);
const destination = join(basePath, '.env');
cliLogger.debug('Copying %s to %s', source, destination);
await copyFile(source, destination);
cliLogger.info('Now using %s', newEnv);
await this.startCommand.run(null, {});
}
}

View File

@@ -0,0 +1,79 @@
import type { JWTPayload } from 'jose';
import { createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
import { Command, CommandRunner } from 'nest-commander';
import { JWKS_LOCAL_PAYLOAD, JWKS_REMOTE_LINK } from '@app/consts';
import { store } from '@app/store';
import { loadConfigFile } from '@app/store/modules/config';
import { LogService } from '@app/unraid-api/cli/log.service';
const createJsonErrorString = (errorMessage: string) =>
JSON.stringify({
error: errorMessage,
valid: false,
});
@Command({
name: 'validate-token',
description: 'Returns JSON: { error: string | null, valid: boolean }',
arguments: '<token>',
})
export class ValidateTokenCommand extends CommandRunner {
JWKSOffline: ReturnType<typeof createLocalJWKSet>;
JWKSOnline: ReturnType<typeof createRemoteJWKSet>;
constructor(private readonly logger: LogService) {
super();
this.JWKSOffline = createLocalJWKSet(JWKS_LOCAL_PAYLOAD);
this.JWKSOnline = createRemoteJWKSet(new URL(JWKS_REMOTE_LINK));
}
async run(passedParams: string[]): Promise<void> {
if (passedParams.length !== 1) {
this.logger.error('Please pass token argument only');
}
const token = passedParams[0];
let caughtError: null | unknown = null;
let tokenPayload: null | JWTPayload = null;
try {
this.logger.debug('Attempting to validate token with local key');
tokenPayload = (await jwtVerify(token, this.JWKSOffline)).payload;
} catch (error: unknown) {
try {
this.logger.debug('Local validation failed for key, trying remote validation');
tokenPayload = (await jwtVerify(token, this.JWKSOnline)).payload;
} catch (error: unknown) {
caughtError = error;
}
}
if (caughtError) {
if (caughtError instanceof Error) {
this.logger.error(
createJsonErrorString(`Caught error validating jwt token: ${caughtError.message}`)
);
} else {
this.logger.error(createJsonErrorString('Caught error validating jwt token'));
}
}
if (tokenPayload === null) {
this.logger.error(createJsonErrorString('No data in JWT to use for user validation'));
}
const username = tokenPayload!.username ?? tokenPayload!['cognito:username'];
const configFile = await store.dispatch(loadConfigFile()).unwrap();
if (!configFile.remote?.accesstoken) {
this.logger.error(createJsonErrorString('No local user token set to compare to'));
}
const existingUserPayload = decodeJwt(configFile.remote?.accesstoken);
if (username === existingUserPayload.username) {
this.logger.info(JSON.stringify({ error: null, valid: true }));
} else {
this.logger.error(
createJsonErrorString('Username on token does not match logged in user name')
);
}
}
}

View File

@@ -0,0 +1,14 @@
import { Command, CommandRunner } from 'nest-commander';
import { API_VERSION } from '@app/environment';
import { LogService } from '@app/unraid-api/cli/log.service';
@Command({ name: 'version' })
export class VersionCommand extends CommandRunner {
constructor(private readonly logger: LogService) {
super();
}
async run(): Promise<void> {
this.logger.info(`Unraid API v${API_VERSION}`);
}
}

View File

@@ -1,42 +1,37 @@
import { report } from '@app/cli/commands/report';
import { getBannerPathIfPresent, getCasePathIfPresent } from '@app/core/utils/images/image-file-helpers';
import { getters } from '@app/store/index';
import { Injectable, Logger } from '@nestjs/common';
import { execa } from 'execa';
import { type ReadStream, createReadStream } from 'node:fs';
import type { ReadStream } from 'node:fs';
import { createReadStream } from 'node:fs';
import { stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { execa } from 'execa';
import { getBannerPathIfPresent, getCasePathIfPresent } from '@app/core/utils/images/image-file-helpers';
import { getters } from '@app/store/index';
import { ReportCommand } from '@app/unraid-api/cli/report.command';
@Injectable()
export class RestService {
protected logger = new Logger(RestService.name);
async saveApiReport (pathToReport: string) {
async saveApiReport(pathToReport: string) {
try {
const apiReport = await report('-vv', '--json');
const reportCommand = new ReportCommand();
const apiReport = await reportCommand.report({ json: true, verbose: 2, raw: false });
this.logger.debug('Report object %o', apiReport);
await writeFile(
pathToReport,
JSON.stringify(apiReport, null, 2),
'utf-8'
);
await writeFile(pathToReport, JSON.stringify(apiReport, null, 2), 'utf-8');
} catch (error) {
this.logger.warn(
'Could not generate report for zip with error %o',
error
);
this.logger.warn('Could not generate report for zip with error %o', error);
}
}
async getLogs(): Promise<ReadStream> {
const logPath = getters.paths()['log-base'];
try {
await this.saveApiReport(join(logPath, 'report.json'));
} catch (error) {
this.logger.warn(
'Could not generate report for zip with error %o',
error
);
this.logger.warn('Could not generate report for zip with error %o', error);
}
const zipToWrite = join(logPath, '../unraid-api.tar.gz');
@@ -67,7 +62,7 @@ export class RestService {
return getCasePathIfPresent();
}
}
async getCustomizationStream(type: 'banner' | 'case'): Promise<ReadStream> {
const path = await this.getCustomizationPath(type);
if (!path) {