mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: switch to nest-commander
This commit is contained in:
639
api/package-lock.json
generated
639
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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()
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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' });
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -20,7 +20,6 @@ const level =
|
||||
] ?? 'info';
|
||||
|
||||
export const logDestination = pino.destination({
|
||||
minLength: 1_024,
|
||||
sync: true,
|
||||
});
|
||||
|
||||
|
||||
28
api/src/unraid-api/cli/cli.module.ts
Normal file
28
api/src/unraid-api/cli/cli.module.ts
Normal 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 {}
|
||||
102
api/src/unraid-api/cli/key.command.ts
Normal file
102
api/src/unraid-api/cli/key.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
api/src/unraid-api/cli/log.service.ts
Normal file
30
api/src/unraid-api/cli/log.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
376
api/src/unraid-api/cli/report.command.ts
Normal file
376
api/src/unraid-api/cli/report.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
api/src/unraid-api/cli/report.spec.ts
Normal file
18
api/src/unraid-api/cli/report.spec.ts
Normal 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']);
|
||||
});
|
||||
22
api/src/unraid-api/cli/restart.command.ts
Normal file
22
api/src/unraid-api/cli/restart.command.ts
Normal 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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
49
api/src/unraid-api/cli/start.command.ts
Normal file
49
api/src/unraid-api/cli/start.command.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
api/src/unraid-api/cli/status.command.ts
Normal file
13
api/src/unraid-api/cli/status.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
api/src/unraid-api/cli/stop.command.ts
Normal file
14
api/src/unraid-api/cli/stop.command.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
89
api/src/unraid-api/cli/switch-env.command.ts
Normal file
89
api/src/unraid-api/cli/switch-env.command.ts
Normal 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, {});
|
||||
}
|
||||
}
|
||||
79
api/src/unraid-api/cli/validate-token.command.ts
Normal file
79
api/src/unraid-api/cli/validate-token.command.ts
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
api/src/unraid-api/cli/version.command.ts
Normal file
14
api/src/unraid-api/cli/version.command.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user