From 5927bd1bb2e23ed3a55a5732a03fa77cd5778587 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 11:32:15 -0500 Subject: [PATCH 01/14] feat: initial version of modification service --- api/src/core/sso/auth-request-setup.ts | 46 ----- api/src/core/sso/sso-remove.ts | 20 --- api/src/core/sso/sso-setup.ts | 69 -------- api/src/index.ts | 18 +- api/src/unraid-api/app/app.module.ts | 2 + .../auth-request.modification.ts | 64 +++++++ .../modifications/sso.modification.ts | 91 ++++++++++ .../unraid-file-modifier.module.ts | 7 + .../unraid-file-modifier.service.ts | 157 ++++++++++++++++++ .../unraid-file-modifier.spec.ts | 19 +++ 10 files changed, 342 insertions(+), 151 deletions(-) delete mode 100644 api/src/core/sso/auth-request-setup.ts delete mode 100644 api/src/core/sso/sso-remove.ts delete mode 100755 api/src/core/sso/sso-setup.ts create mode 100644 api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts create mode 100644 api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts create mode 100644 api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts create mode 100644 api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts create mode 100644 api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts diff --git a/api/src/core/sso/auth-request-setup.ts b/api/src/core/sso/auth-request-setup.ts deleted file mode 100644 index 9b3bb0c125..0000000000 --- a/api/src/core/sso/auth-request-setup.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { existsSync } from 'fs'; -import { readFile, writeFile } from 'fs/promises'; - -import { glob } from 'glob'; - -import { logger } from '@app/core/log'; - -// Define constants -const AUTH_REQUEST_FILE = '/usr/local/emhttp/auth-request.php'; -const WEB_COMPS_DIR = '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/'; - -const getJsFiles = async (dir: string) => { - const files = await glob(`${dir}/**/*.js`); - return files.map((file) => file.replace('/usr/local/emhttp', '')); -}; - -export const setupAuthRequest = async () => { - const JS_FILES = await getJsFiles(WEB_COMPS_DIR); - logger.debug(`Found ${JS_FILES.length} .js files in ${WEB_COMPS_DIR}`); - - const FILES_TO_ADD = ['/webGui/images/partner-logo.svg', ...JS_FILES]; - - if (existsSync(AUTH_REQUEST_FILE)) { - const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8'); - - if (fileContent.includes('$arrWhitelist')) { - const backupFile = `${AUTH_REQUEST_FILE}.bak`; - await writeFile(backupFile, fileContent); - logger.debug(`Backup of ${AUTH_REQUEST_FILE} created at ${backupFile}`); - - const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); - - const updatedContent = fileContent.replace( - /(\$arrWhitelist\s*=\s*\[)/, - `$1\n${filesToAddString}` - ); - - await writeFile(AUTH_REQUEST_FILE, updatedContent); - logger.debug(`Default values and .js files from ${WEB_COMPS_DIR} added to $arrWhitelist.`); - } else { - logger.debug(`$arrWhitelist array not found in the file.`); - } - } else { - logger.debug(`File ${AUTH_REQUEST_FILE} not found.`); - } -}; diff --git a/api/src/core/sso/sso-remove.ts b/api/src/core/sso/sso-remove.ts deleted file mode 100644 index e1bc59a7c4..0000000000 --- a/api/src/core/sso/sso-remove.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { existsSync, renameSync, unlinkSync } from 'node:fs'; - -import { logger } from '@app/core/log'; - -export const removeSso = () => { - const path = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; - const backupPath = path + '.bak'; - - // Move the backup file to the original location - if (existsSync(backupPath)) { - // Remove the SSO login inject file if it exists - if (existsSync(path)) { - unlinkSync(path); - } - renameSync(backupPath, path); - logger.debug('SSO login file restored.'); - } else { - logger.debug('No SSO login file backup found.'); - } -}; diff --git a/api/src/core/sso/sso-setup.ts b/api/src/core/sso/sso-setup.ts deleted file mode 100755 index f01b0222f2..0000000000 --- a/api/src/core/sso/sso-setup.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { existsSync } from 'node:fs'; -import { copyFile, readFile, rename, unlink, writeFile } from 'node:fs/promises'; - - - - - -export const setupSso = async () => { - const path = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; - - // Define the new PHP function to insert - const newFunction = ` -function verifyUsernamePasswordAndSSO(string $username, string $password): bool { - if ($username != "root") return false; - - $output = exec("/usr/bin/getent shadow $username"); - if ($output === false) return false; - $credentials = explode(":", $output); - $valid = password_verify($password, $credentials[1]); - if ($valid) { - return true; - } - // We may have an SSO token, attempt validation - if (strlen($password) > 800) { - $safePassword = escapeshellarg($password); - if (!preg_match('/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/', $password)) { - my_logger("SSO Login Attempt Failed: Invalid token format"); - } - $response = exec("/usr/local/bin/unraid-api sso validate-token $safePassword", $output, $code); - my_logger("SSO Login Attempt: $response"); - if ($code === 0 && $response && strpos($response, '"valid":true') !== false) { - return true; - } - } - return false; -}`; - - const tagToInject = ''; - - // Backup the original file if exists - if (existsSync(path + '.bak')) { - await copyFile(path + '.bak', path); - await unlink(path + '.bak'); - } - - // Read the file content - let fileContent = await readFile(path, 'utf-8'); - - // Backup the original content - await writeFile(path + '.bak', fileContent); - - // Add new function after the opening PHP tag ( tag - fileContent = fileContent.replace(/<\/form>/i, `\n${tagToInject}`); - - // Write the updated content back to the file - await writeFile(path, fileContent); - - console.log('Function replaced successfully.'); -}; \ No newline at end of file diff --git a/api/src/index.ts b/api/src/index.ts index 91edaf6655..3d6e708753 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -15,8 +15,6 @@ import { WebSocket } from 'ws'; import { logger } from '@app/core/log'; import { setupLogRotation } from '@app/core/logrotate/setup-logrotate'; import { setupAuthRequest } from '@app/core/sso/auth-request-setup'; -import { removeSso } from '@app/core/sso/sso-remove'; -import { setupSso } from '@app/core/sso/sso-setup'; import { fileExistsSync } from '@app/core/utils/files/file-exists'; import { environment, PORT } from '@app/environment'; import * as envVars from '@app/environment'; @@ -100,22 +98,10 @@ try { startMiddlewareListeners(); - // If the config contains SSO IDs, enable SSO - try { - if (store.getState().config.remote.ssoSubIds) { - await setupAuthRequest(); - await setupSso(); - logger.info('SSO setup complete'); - } else { - await removeSso(); - } - } catch (err) { - logger.error('Failed to setup SSO with error: %o', err); - } // On process exit stop HTTP server - exitHook((signal) => { + exitHook(async (signal) => { console.log('exithook', signal); - server?.close?.(); + await server?.close?.(); // If port is unix socket, delete socket before exiting unlinkUnixPort(); diff --git a/api/src/unraid-api/app/app.module.ts b/api/src/unraid-api/app/app.module.ts index 6e86716f58..a9df97fa40 100644 --- a/api/src/unraid-api/app/app.module.ts +++ b/api/src/unraid-api/app/app.module.ts @@ -11,6 +11,7 @@ import { AuthModule } from '@app/unraid-api/auth/auth.module'; import { CronModule } from '@app/unraid-api/cron/cron.module'; import { GraphModule } from '@app/unraid-api/graph/graph.module'; import { RestModule } from '@app/unraid-api/rest/rest.module'; +import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.module'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { RestModule } from '@app/unraid-api/rest/rest.module'; limit: 100, // 100 requests per 10 seconds }, ]), + UnraidFileModifierModule, ], controllers: [], providers: [ diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts new file mode 100644 index 0000000000..30b5b1de8d --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts @@ -0,0 +1,64 @@ +import { Logger } from '@nestjs/common'; +import { existsSync } from 'fs'; +import { readFile, writeFile } from 'fs/promises'; + +import { + FileModification, + FileModificationService, + ShouldApplyWithReason, +} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; + +const AUTH_REQUEST_FILE = '/usr/local/emhttp/auth-request.php' as const; +const WEB_COMPS_DIR = '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/' as const; + +const getJsFiles = async (dir: string) => { + const { glob } = await import('glob'); + const files = await glob(`${dir}/**/*.js`); + return files.map((file) => file.replace('/usr/local/emhttp', '')); +}; + +export default class AuthRequestModification implements FileModification { + id: string = 'auth-request'; + + constructor(private readonly logger: Logger) { + this.logger = logger; + } + + async apply(): Promise { + const JS_FILES = await getJsFiles(WEB_COMPS_DIR); + this.logger.debug(`Found ${JS_FILES.length} .js files in ${WEB_COMPS_DIR}`); + + const FILES_TO_ADD = ['/webGui/images/partner-logo.svg', ...JS_FILES]; + + if (existsSync(AUTH_REQUEST_FILE)) { + const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8'); + + if (fileContent.includes('$arrWhitelist')) { + FileModificationService.backupFile(AUTH_REQUEST_FILE, true); + this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`); + + const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); + + const updatedContent = fileContent.replace( + /(\$arrWhitelist\s*=\s*\[)/, + `$1\n${filesToAddString}` + ); + + await writeFile(AUTH_REQUEST_FILE, updatedContent); + this.logger.debug( + `Default values and .js files from ${WEB_COMPS_DIR} added to $arrWhitelist.` + ); + } else { + this.logger.debug(`$arrWhitelist array not found in the file.`); + } + } else { + this.logger.debug(`File ${AUTH_REQUEST_FILE} not found.`); + } + } + async rollback(): Promise { + // No rollback needed, this is safe to preserve + } + async shouldApply(): Promise { + return { shouldApply: true, reason: 'Always apply the allowed file changes to ensure compatibility.' }; + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts new file mode 100644 index 0000000000..0de6063d0b --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts @@ -0,0 +1,91 @@ +import type { Logger } from '@nestjs/common'; +import { readFile, writeFile } from 'node:fs/promises'; + +import { + FileModification, + FileModificationService, + ShouldApplyWithReason, +} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; + +export default class SSOFileModification implements FileModification { + id: string = 'sso'; + logger: Logger; + loginFilePath: string = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; + constructor(logger: Logger) { + this.logger = logger; + } + + async apply(): Promise { + // Define the new PHP function to insert + const newFunction = ` +function verifyUsernamePasswordAndSSO(string $username, string $password): bool { + if ($username != "root") return false; + + $output = exec("/usr/bin/getent shadow $username"); + if ($output === false) return false; + $credentials = explode(":", $output); + $valid = password_verify($password, $credentials[1]); + if ($valid) { + return true; + } + // We may have an SSO token, attempt validation + if (strlen($password) > 800) { + $safePassword = escapeshellarg($password); + if (!preg_match('/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/', $password)) { + my_logger("SSO Login Attempt Failed: Invalid token format"); + } + $response = exec("/usr/local/bin/unraid-api sso validate-token $safePassword", $output, $code); + my_logger("SSO Login Attempt: $response"); + if ($code === 0 && $response && strpos($response, '"valid":true') !== false) { + return true; + } + } + return false; +}`; + + const tagToInject = + ''; + + // Restore the original file if exists + await FileModificationService.restoreFile(this.loginFilePath, false); + // Backup the original content + await FileModificationService.backupFile(this.loginFilePath, true); + + // Read the file content + let fileContent = await readFile(this.loginFilePath, 'utf-8'); + + // Add new function after the opening PHP tag ( tag + fileContent = fileContent.replace(/<\/form>/i, `\n${tagToInject}`); + + // Write the updated content back to the file + await writeFile(this.loginFilePath, fileContent); + + this.logger.log('Login Function replaced successfully.'); + } + async rollback(): Promise { + const restored = await FileModificationService.restoreFile(this.loginFilePath, false); + if (restored) { + this.logger.debug('SSO login file restored.'); + } else { + this.logger.debug('No SSO login file backup found.'); + } + } + + async shouldApply(): Promise { + const { getters } = await import('@app/store/index'); + const hasConfiguredSso = getters.config().remote.ssoSubIds.length > 0; + return hasConfiguredSso + ? { shouldApply: true, reason: 'SSO is configured - enabling support in .login.php' } + : { shouldApply: false, reason: 'SSO is not configured' }; + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts new file mode 100644 index 0000000000..6434b68eb7 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { FileModificationService } from './unraid-file-modifier.service'; + +@Module({ + providers: [FileModificationService] +}) +export class UnraidFileModifierModule {} diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts new file mode 100644 index 0000000000..7778f6bb95 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts @@ -0,0 +1,157 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { copyFile, unlink } from 'node:fs/promises'; + +import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification'; +import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification'; + +export interface ShouldApplyWithReason { + shouldApply: boolean; + reason: string; +} + +// Step 1: Define the interface +export interface FileModification { + id: string; // Unique identifier for the operation + apply(): Promise; // Method to apply the modification + rollback(): Promise; // Method to roll back the modification + shouldApply(): Promise; // Method to determine if the modification should be applied +} + +// Step 2: Create a FileModificationService +@Injectable() +export class FileModificationService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(FileModificationService.name); + private history: FileModification[] = []; // Keeps track of applied modifications + + async onModuleInit() { + try { + this.logger.log('Loading file modifications...'); + const mods = await this.loadModifications(); + await this.applyModifications(mods); + } catch (err) { + this.logger.error(`Failed to apply modifications: ${err}`); + } + } + async onModuleDestroy() { + try { + this.logger.log('Rolling back all modifications...'); + await this.rollbackAll(); + } catch (err) { + this.logger.error(`Failed to roll back modifications: ${err}`); + } + } + + /** + * Dynamically load all file modifications from the specified folder. + */ + async loadModifications(): Promise { + const modifications: FileModification[] = []; + const modificationClasses: Array FileModification> = [ + AuthRequestModification, + SSOFileModification, + ]; + for (const ModificationClass of modificationClasses) { + const instance = new ModificationClass(this.logger); + modifications.push(instance); + } + return modifications; + } + + async applyModifications(modifications: FileModification[]): Promise { + for (const modification of modifications) { + await this.applyModification(modification); + } + } + + /** + * Apply a file modification. + * @param modification - The file modification to apply + */ + async applyModification(modification: FileModification): Promise { + try { + const shouldApplyWithReason = await modification.shouldApply(); + if (shouldApplyWithReason.shouldApply) { + this.logger.log(`Applying modification: ${modification.id} - ${shouldApplyWithReason.reason}`); + await modification.apply(); + this.history.push(modification); // Store modification in history + this.logger.log(`Modification applied successfully: ${modification.id}`); + } else { + this.logger.log(`Skipping modification: ${modification.id} - ${shouldApplyWithReason.reason}`); + } + } catch (error) { + if (error instanceof Error) { + this.logger.error( + `Failed to apply modification: ${modification.id}: ${error.message}`, + error.stack + ); + } else { + this.logger.error(`Failed to apply modification: ${modification.id}: ${error}`); + } + throw error; + } + } + + /** + * Roll back all applied modifications in reverse order. + */ + async rollbackAll(): Promise { + while (this.history.length > 0) { + const modification = this.history.pop(); // Get the last applied modification + if (modification) { + try { + this.logger.log(`Rolling back modification: ${modification.id}`); + await modification.rollback(); + this.logger.log(`Modification rolled back successfully: ${modification.id}`); + } catch (error) { + if (error instanceof Error) { + this.logger.error( + `Failed to roll back modification: ${modification.id}: ${error.message}`, + error.stack + ); + } else { + this.logger.error( + `Failed to roll back modification: ${modification.id}: ${error}` + ); + } + } + } + } + } + + /** + * Helper method to allow backing up a single file to a .bak file. + * @param path the file to backup, creates a .bak file in the same directory + * @throws Error if the file cannot be copied + */ + public static backupFile = async (path: string, throwOnMissing = true): Promise => { + try { + const backupPath = path + '.bak'; + await copyFile(path, backupPath); + } catch (err) { + if (throwOnMissing) { + throw new Error(`File does not exist: ${path}`); + } + } + }; + + /** + * + * @param path Path to original (not .bak) file + * @param throwOnMissing Whether to throw an error if the backup file does not exist + * @throws Error if the backup file does not exist and throwOnMissing is true + * @returns boolean indicating whether the restore was successful + */ + public static restoreFile = async (path: string, throwOnMissing = true): Promise => { + const backupPath = path + '.bak'; + try { + await copyFile(backupPath, path); + await unlink(backupPath); + return true; + } catch { + if (throwOnMissing) { + throw new Error(`Backup file does not exist: ${backupPath}`); + } + return false; + } + }; +} diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts new file mode 100644 index 0000000000..2cea07f8a3 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { UnraidFileModifierService } from './unraid-file-modifier.service'; + +describe('FileModificationService', () => { + let service: UnraidFileModifierService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UnraidFileModifierService], + }).compile(); + + service = module.get(UnraidFileModifierService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); From e423559b073acad5b49d7c7c519c85df011702ff Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 11:47:44 -0500 Subject: [PATCH 02/14] fix: basic test fixed --- .../unraid-file-modifier.module.ts | 5 +++-- .../unraid-file-modifier.service.ts | 12 ++++++++---- .../unraid-file-modifier.spec.ts | 8 ++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts index 6434b68eb7..869b4f6253 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common'; -import { FileModificationService } from './unraid-file-modifier.service'; + +import { UnraidFileModificationService } from './unraid-file-modifier.service'; @Module({ - providers: [FileModificationService] + providers: [UnraidFileModificationService], }) export class UnraidFileModifierModule {} diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts index 7778f6bb95..77dd45341f 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts @@ -19,8 +19,8 @@ export interface FileModification { // Step 2: Create a FileModificationService @Injectable() -export class FileModificationService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(FileModificationService.name); +export class UnraidFileModificationService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(UnraidFileModificationService.name); private history: FileModification[] = []; // Keeps track of applied modifications async onModuleInit() { @@ -71,12 +71,16 @@ export class FileModificationService implements OnModuleInit, OnModuleDestroy { try { const shouldApplyWithReason = await modification.shouldApply(); if (shouldApplyWithReason.shouldApply) { - this.logger.log(`Applying modification: ${modification.id} - ${shouldApplyWithReason.reason}`); + this.logger.log( + `Applying modification: ${modification.id} - ${shouldApplyWithReason.reason}` + ); await modification.apply(); this.history.push(modification); // Store modification in history this.logger.log(`Modification applied successfully: ${modification.id}`); } else { - this.logger.log(`Skipping modification: ${modification.id} - ${shouldApplyWithReason.reason}`); + this.logger.log( + `Skipping modification: ${modification.id} - ${shouldApplyWithReason.reason}` + ); } } catch (error) { if (error instanceof Error) { diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts index 2cea07f8a3..ee3f7719c7 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts @@ -1,16 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { UnraidFileModifierService } from './unraid-file-modifier.service'; +import { UnraidFileModificationService } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; describe('FileModificationService', () => { - let service: UnraidFileModifierService; + let service: UnraidFileModificationService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UnraidFileModifierService], + providers: [UnraidFileModificationService], }).compile(); - service = module.get(UnraidFileModifierService); + service = module.get(UnraidFileModificationService); }); it('should be defined', () => { From c2ec12f151a32c165a004945e9f28e182cff05f3 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 12:20:10 -0500 Subject: [PATCH 03/14] feat: configure PM2 on startup --- api/ecosystem.config.json | 7 +-- api/src/core/logrotate/setup-logrotate.ts | 22 -------- api/src/index.ts | 2 - api/src/unraid-api/cli/log.service.ts | 31 +++++++++-- api/src/unraid-api/cli/start.command.ts | 53 +++++++++++++++---- api/src/unraid-api/cron/cron.module.ts | 3 +- .../unraid-api/cron/log-cleanup.service.ts | 18 ------- .../auth-request.modification.ts | 4 +- .../modifications/sso.modification.ts | 8 +-- .../unraid-file-modifier.service.ts | 36 ------------- .../unraid-file-modifier.spec.ts | 5 ++ api/src/utils.ts | 40 +++++++++++++- 12 files changed, 123 insertions(+), 106 deletions(-) delete mode 100644 api/src/core/logrotate/setup-logrotate.ts delete mode 100644 api/src/unraid-api/cron/log-cleanup.service.ts diff --git a/api/ecosystem.config.json b/api/ecosystem.config.json index ee7ab00f08..8cb5960c3d 100644 --- a/api/ecosystem.config.json +++ b/api/ecosystem.config.json @@ -1,13 +1,13 @@ { + "$schema": "https://json.schemastore.org/pm2-ecosystem", "apps": [ { "name": "unraid-api", "script": "./dist/main.js", "cwd": "/usr/local/unraid-api", - "log": "/var/log/unraid-api/unraid-api.log", "exec_mode": "fork", "wait_ready": true, - "listen_timeout": 30000, + "listen_timeout": 15000, "max_restarts": 10, "min_uptime": 10000, "ignore_watch": [ @@ -15,7 +15,8 @@ "src", ".env.*", "myservers.cfg" - ] + ], + "log": "/var/log/unraid-api/unraid-api.log" } ] } \ No newline at end of file diff --git a/api/src/core/logrotate/setup-logrotate.ts b/api/src/core/logrotate/setup-logrotate.ts deleted file mode 100644 index 577030cdf1..0000000000 --- a/api/src/core/logrotate/setup-logrotate.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { writeFile } from 'fs/promises'; - -import { fileExists } from '@app/core/utils/files/file-exists'; - -export const setupLogRotation = async () => { - if (await fileExists('/etc/logrotate.d/unraid-api')) { - return; - } else { - await writeFile( - '/etc/logrotate.d/unraid-api', - ` -/var/log/unraid-api/*.log { - rotate 1 - missingok - size 5M - su root root -} -`, - { mode: '644' } - ); - } -}; diff --git a/api/src/index.ts b/api/src/index.ts index 3d6e708753..2e4f55710e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -59,8 +59,6 @@ try { // Must occur before config is loaded to ensure that the handler can fix broken configs await startStoreSync(); - await setupLogRotation(); - // Load my servers config file into store await store.dispatch(loadConfigFile()); diff --git a/api/src/unraid-api/cli/log.service.ts b/api/src/unraid-api/cli/log.service.ts index e725aeab33..ad1f5424ec 100644 --- a/api/src/unraid-api/cli/log.service.ts +++ b/api/src/unraid-api/cli/log.service.ts @@ -1,5 +1,8 @@ import { Injectable } from '@nestjs/common'; +import { levels, LogLevel } from '@app/core/log'; +import { LOG_LEVEL } from '@app/environment'; + @Injectable() export class LogService { private logger = console; @@ -8,23 +11,41 @@ export class LogService { this.logger.clear(); } + shouldLog(level: LogLevel): boolean { + const logLevelsLowToHigh = levels; + return ( + logLevelsLowToHigh.indexOf(level) >= + logLevelsLowToHigh.indexOf(LOG_LEVEL.toLowerCase() as LogLevel) + ); + } + log(message: string): void { - this.logger.log(message); + if (this.shouldLog('info')) { + this.logger.log(message); + } } info(message: string): void { - this.logger.info(message); + if (this.shouldLog('info')) { + this.logger.info(message); + } } warn(message: string): void { - this.logger.warn(message); + if (this.shouldLog('warn')) { + this.logger.warn(message); + } } error(message: string, trace: string = ''): void { - this.logger.error(message, trace); + if (this.shouldLog('error')) { + this.logger.error(message, trace); + } } debug(message: any, ...optionalParams: any[]): void { - this.logger.debug(message, ...optionalParams); + if (this.shouldLog('debug')) { + this.logger.debug(message, ...optionalParams); + } } } diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 037f6ed20f..7f9975a9bd 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -1,8 +1,12 @@ +import { existsSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; + import { execa } from 'execa'; import { Command, CommandRunner, Option } from 'nest-commander'; +import type { LogLevel } from '@app/core/log'; import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts'; -import { levels, type LogLevel } from '@app/core/log'; +import { levels } from '@app/core/log'; import { LogService } from '@app/unraid-api/cli/log.service'; interface StartCommandOptions { @@ -15,18 +19,47 @@ export class StartCommand extends CommandRunner { super(); } + async configurePm2(): Promise { + if (existsSync('/tmp/pm2-configured')) { + return; + } + // Write a temp file when first started to prevent needing to run this again + // Install PM2 Logrotate + await execa(PM2_PATH, ['install', 'pm2-logrotate']) + .then(({ stdout }) => { + this.logger.debug(stdout); + }) + .catch(({ stderr }) => { + this.logger.error('PM2 Logrotate Error: ' + stderr); + }); + // Now set logrotate options + await execa(PM2_PATH, ['set', 'pm2-logrotate:retain', '2']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Logrotate Set Error: ' + stderr)); + await execa(PM2_PATH, ['set', 'pm2-logrotate:compress', 'true']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Logrotate Compress Error: ' + stderr)); + await execa(PM2_PATH, ['set', 'pm2-logrotate:max_size', '1M']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Logrotate Max Size Error: ' + stderr)); + + // PM2 Save Settings + await execa(PM2_PATH, ['set', 'pm2:autodump', 'true']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Autodump Error: ' + stderr)); + + // Update PM2 + await execa(PM2_PATH, ['update']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Update Error: ' + stderr)); + + await writeFile('/tmp/pm2-configured', 'true'); + } + async run(_: string[], options: StartCommandOptions): Promise { this.logger.info('Starting the Unraid API'); - // Update PM2 first if necessary - const { stderr: updateErr, stdout: updateOut } = await execa(PM2_PATH, ['update']); - if (updateOut) { - this.logger.log(updateOut); - } - if (updateErr) { - this.logger.error('PM2 Update Error: ' + updateErr); - process.exit(1); - } + await this.configurePm2(); const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : ''; const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [ diff --git a/api/src/unraid-api/cron/cron.module.ts b/api/src/unraid-api/cron/cron.module.ts index 4d02c334d7..e1b2ed9849 100644 --- a/api/src/unraid-api/cron/cron.module.ts +++ b/api/src/unraid-api/cron/cron.module.ts @@ -1,10 +1,9 @@ -import { LogCleanupService } from '@app/unraid-api/cron/log-cleanup.service'; import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service'; import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ScheduleModule.forRoot()], - providers: [LogCleanupService, WriteFlashFileService], + providers: [WriteFlashFileService], }) export class CronModule {} diff --git a/api/src/unraid-api/cron/log-cleanup.service.ts b/api/src/unraid-api/cron/log-cleanup.service.ts deleted file mode 100644 index ce54315b65..0000000000 --- a/api/src/unraid-api/cron/log-cleanup.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { execa } from 'execa'; - -@Injectable() -export class LogCleanupService { - private readonly logger = new Logger(LogCleanupService.name); - - @Cron('0 * * * *') - async handleCron() { - try { - this.logger.debug('Running logrotate'); - await execa(`/usr/sbin/logrotate`, ['/etc/logrotate.conf']); - } catch (error) { - this.logger.error(error); - } - } -} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts index 30b5b1de8d..4dc24b71ec 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts @@ -4,9 +4,9 @@ import { readFile, writeFile } from 'fs/promises'; import { FileModification, - FileModificationService, ShouldApplyWithReason, } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; +import { backupFile } from '@app/utils'; const AUTH_REQUEST_FILE = '/usr/local/emhttp/auth-request.php' as const; const WEB_COMPS_DIR = '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/' as const; @@ -34,7 +34,7 @@ export default class AuthRequestModification implements FileModification { const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8'); if (fileContent.includes('$arrWhitelist')) { - FileModificationService.backupFile(AUTH_REQUEST_FILE, true); + backupFile(AUTH_REQUEST_FILE, true); this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`); const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts index 0de6063d0b..bf4b605922 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts @@ -3,9 +3,9 @@ import { readFile, writeFile } from 'node:fs/promises'; import { FileModification, - FileModificationService, ShouldApplyWithReason, } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; +import { backupFile, restoreFile } from '@app/utils'; export default class SSOFileModification implements FileModification { id: string = 'sso'; @@ -47,9 +47,9 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool ''; // Restore the original file if exists - await FileModificationService.restoreFile(this.loginFilePath, false); + await restoreFile(this.loginFilePath, false); // Backup the original content - await FileModificationService.backupFile(this.loginFilePath, true); + await backupFile(this.loginFilePath, true); // Read the file content let fileContent = await readFile(this.loginFilePath, 'utf-8'); @@ -73,7 +73,7 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool this.logger.log('Login Function replaced successfully.'); } async rollback(): Promise { - const restored = await FileModificationService.restoreFile(this.loginFilePath, false); + const restored = await restoreFile(this.loginFilePath, false); if (restored) { this.logger.debug('SSO login file restored.'); } else { diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts index 77dd45341f..9efa404757 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { copyFile, unlink } from 'node:fs/promises'; import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification'; import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification'; @@ -122,40 +121,5 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest } } - /** - * Helper method to allow backing up a single file to a .bak file. - * @param path the file to backup, creates a .bak file in the same directory - * @throws Error if the file cannot be copied - */ - public static backupFile = async (path: string, throwOnMissing = true): Promise => { - try { - const backupPath = path + '.bak'; - await copyFile(path, backupPath); - } catch (err) { - if (throwOnMissing) { - throw new Error(`File does not exist: ${path}`); - } - } - }; - /** - * - * @param path Path to original (not .bak) file - * @param throwOnMissing Whether to throw an error if the backup file does not exist - * @throws Error if the backup file does not exist and throwOnMissing is true - * @returns boolean indicating whether the restore was successful - */ - public static restoreFile = async (path: string, throwOnMissing = true): Promise => { - const backupPath = path + '.bak'; - try { - await copyFile(backupPath, path); - await unlink(backupPath); - return true; - } catch { - if (throwOnMissing) { - throw new Error(`Backup file does not exist: ${backupPath}`); - } - return false; - } - }; } diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts index ee3f7719c7..5b9d9e66af 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts @@ -16,4 +16,9 @@ describe('FileModificationService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('should load modifications', async () => { + const mods = await service.loadModifications(); + expect(mods).toHaveLength(2); + }) }); diff --git a/api/src/utils.ts b/api/src/utils.ts index 989e1b8c3b..bd95520b8d 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,10 +1,9 @@ import { BadRequestException, ExecutionContext, Logger, UnauthorizedException } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; +import { copyFile, unlink } from 'node:fs/promises'; import strftime from 'strftime'; -import { UserSchema } from '@app/graphql/generated/api/operations'; - import { UserAccount } from './graphql/generated/api/types'; import { FastifyRequest } from './types/fastify'; @@ -245,3 +244,40 @@ export function handleAuthError( throw new UnauthorizedException(`${operation}: ${errorMessage}`); } + +/** + * Helper method to allow backing up a single file to a .bak file. + * @param path the file to backup, creates a .bak file in the same directory + * @throws Error if the file cannot be copied + */ +export const backupFile = async (path: string, throwOnMissing = true): Promise => { + try { + const backupPath = path + '.bak'; + await copyFile(path, backupPath); + } catch (err) { + if (throwOnMissing) { + throw new Error(`File does not exist: ${path}`); + } + } +}; + +/** + * + * @param path Path to original (not .bak) file + * @param throwOnMissing Whether to throw an error if the backup file does not exist + * @throws Error if the backup file does not exist and throwOnMissing is true + * @returns boolean indicating whether the restore was successful + */ +export const restoreFile = async (path: string, throwOnMissing = true): Promise => { + const backupPath = path + '.bak'; + try { + await copyFile(backupPath, path); + await unlink(backupPath); + return true; + } catch { + if (throwOnMissing) { + throw new Error(`Backup file does not exist: ${backupPath}`); + } + return false; + } +}; From a8afee219f16fe78dcd96c897252ef4da368d223 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 12:46:51 -0500 Subject: [PATCH 04/14] feat: properly read log level from environment --- api/src/dotenv.ts | 9 ++++++-- api/src/environment.ts | 29 +++++++++++++------------ api/src/index.ts | 2 -- api/src/unraid-api/cli/log.service.ts | 3 ++- api/src/unraid-api/cli/start.command.ts | 3 ++- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/api/src/dotenv.ts b/api/src/dotenv.ts index 08fec0d35e..c772e5b7a9 100644 --- a/api/src/dotenv.ts +++ b/api/src/dotenv.ts @@ -2,10 +2,15 @@ import { config } from 'dotenv'; const env = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' - ? config({ debug: true, path: `./.env.${process.env.NODE_ENV}`, encoding: 'utf-8' }) + ? config({ + debug: true, + path: `./.env.${process.env.NODE_ENV}`, + encoding: 'utf-8', + override: true, + }) : config({ path: '/usr/local/unraid-api/.env', encoding: 'utf-8', }); -export default env; \ No newline at end of file +export default env; diff --git a/api/src/environment.ts b/api/src/environment.ts index a66ee8d41a..39652e552c 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -3,7 +3,8 @@ import { version } from 'package.json'; export const API_VERSION = process.env.npm_package_version ?? version ?? new Error('API_VERSION not set'); -export const NODE_ENV = process.env.NODE_ENV as 'development' | 'test' | 'staging' | 'production' ?? 'production'; +export const NODE_ENV = + (process.env.NODE_ENV as 'development' | 'test' | 'staging' | 'production') ?? 'production'; export const environment = { IS_MAIN_PROCESS: false, }; @@ -11,7 +12,9 @@ export const CHOKIDAR_USEPOLLING = process.env.CHOKIDAR_USEPOLLING === 'true'; export const IS_DOCKER = process.env.IS_DOCKER === 'true'; export const DEBUG = process.env.DEBUG === 'true'; export const INTROSPECTION = process.env.INTROSPECTION === 'true'; -export const ENVIRONMENT = process.env.ENVIRONMENT as 'production' | 'staging' | 'development' ?? 'production'; +export const ENVIRONMENT = process.env.ENVIRONMENT + ? (process.env.ENVIRONMENT as 'production' | 'staging' | 'development') + : 'production'; export const GRAPHQL_INTROSPECTION = Boolean(INTROSPECTION ?? DEBUG ?? ENVIRONMENT !== 'production'); export const PORT = process.env.PORT ?? '/var/run/unraid-api.sock'; export const DRY_RUN = process.env.DRY_RUN === 'true'; @@ -19,15 +22,13 @@ export const BYPASS_PERMISSION_CHECKS = process.env.BYPASS_PERMISSION_CHECKS === export const BYPASS_CORS_CHECKS = process.env.BYPASS_CORS_CHECKS === 'true'; export const LOG_CORS = process.env.LOG_CORS === 'true'; export const LOG_TYPE = (process.env.LOG_TYPE as 'pretty' | 'raw') ?? 'pretty'; -export const LOG_LEVEL = process.env.LOG_LEVEL?.toUpperCase() as - | 'TRACE' - | 'DEBUG' - | 'INFO' - | 'WARN' - | 'ERROR' - | 'FATAL' ?? process.env.ENVIRONMENT === 'production' ? 'INFO' : 'TRACE'; -export const MOTHERSHIP_GRAPHQL_LINK = - process.env.MOTHERSHIP_GRAPHQL_LINK ?? - (process.env.ENVIRONMENT === 'staging' - ? 'https://staging.mothership.unraid.net/ws' - : 'https://mothership.unraid.net/ws'); \ No newline at end of file +export const LOG_LEVEL = process.env.LOG_LEVEL + ? (process.env.LOG_LEVEL.toUpperCase() as 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL') + : process.env.ENVIRONMENT === 'production' + ? 'INFO' + : 'TRACE'; +export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK + ? process.env.MOTHERSHIP_GRAPHQL_LINK + : ENVIRONMENT === 'staging' + ? 'https://staging.mothership.unraid.net/ws' + : 'https://mothership.unraid.net/ws'; diff --git a/api/src/index.ts b/api/src/index.ts index 2e4f55710e..68dc9c1349 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -13,8 +13,6 @@ import exitHook from 'exit-hook'; import { WebSocket } from 'ws'; import { logger } from '@app/core/log'; -import { setupLogRotation } from '@app/core/logrotate/setup-logrotate'; -import { setupAuthRequest } from '@app/core/sso/auth-request-setup'; import { fileExistsSync } from '@app/core/utils/files/file-exists'; import { environment, PORT } from '@app/environment'; import * as envVars from '@app/environment'; diff --git a/api/src/unraid-api/cli/log.service.ts b/api/src/unraid-api/cli/log.service.ts index ad1f5424ec..e3389f687b 100644 --- a/api/src/unraid-api/cli/log.service.ts +++ b/api/src/unraid-api/cli/log.service.ts @@ -13,10 +13,11 @@ export class LogService { shouldLog(level: LogLevel): boolean { const logLevelsLowToHigh = levels; - return ( + const shouldLog = ( logLevelsLowToHigh.indexOf(level) >= logLevelsLowToHigh.indexOf(LOG_LEVEL.toLowerCase() as LogLevel) ); + return shouldLog; } log(message: string): void { diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 7f9975a9bd..a604fdfb51 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -8,6 +8,7 @@ import type { LogLevel } from '@app/core/log'; import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts'; import { levels } from '@app/core/log'; import { LogService } from '@app/unraid-api/cli/log.service'; +import { LOG_LEVEL, NODE_ENV } from '@app/environment'; interface StartCommandOptions { 'log-level'?: string; @@ -21,6 +22,7 @@ export class StartCommand extends CommandRunner { async configurePm2(): Promise { if (existsSync('/tmp/pm2-configured')) { + this.logger.debug('PM2 already configured'); return; } // Write a temp file when first started to prevent needing to run this again @@ -58,7 +60,6 @@ export class StartCommand extends CommandRunner { async run(_: string[], options: StartCommandOptions): Promise { this.logger.info('Starting the Unraid API'); - await this.configurePm2(); const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : ''; From 8c1741cb2584e6edc4b002ce2725e1b43384f35f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 12:57:56 -0500 Subject: [PATCH 05/14] feat: service tests for modifier service --- .../unraid-file-modifier.spec.ts.snap | 3 + .../unraid-file-modifier.spec.ts | 84 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 api/src/unraid-api/unraid-file-modifier/__snapshots__/unraid-file-modifier.spec.ts.snap diff --git a/api/src/unraid-api/unraid-file-modifier/__snapshots__/unraid-file-modifier.spec.ts.snap b/api/src/unraid-api/unraid-file-modifier/__snapshots__/unraid-file-modifier.spec.ts.snap new file mode 100644 index 0000000000..6fc335f7f6 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/__snapshots__/unraid-file-modifier.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FileModificationService > should apply modifications 1`] = `[Error: Application not implemented.]`; diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts index 5b9d9e66af..f8c1096ad1 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts @@ -1,11 +1,59 @@ +import { Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { UnraidFileModificationService } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + FileModification, + UnraidFileModificationService, +} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; + +class TestFileModification implements FileModification { + constructor( + public applyImplementation?: () => Promise, + public rollbackImplementation?: () => Promise + ) {} + id = 'test'; + async apply() { + if (this.applyImplementation) { + return this.applyImplementation(); + } + throw new Error('Application not implemented.'); + } + async rollback() { + if (this.rollbackImplementation) { + return this.rollbackImplementation(); + } + throw new Error('Rollback not implemented.'); + } + async shouldApply() { + return { shouldApply: true, reason: 'Always Apply this mod' }; + } +} describe('FileModificationService', () => { + let mockLogger: { + log: ReturnType; + error: ReturnType; + warn: ReturnType; + debug: ReturnType; + verbose: ReturnType; + }; let service: UnraidFileModificationService; - beforeEach(async () => { + mockLogger = { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + verbose: vi.fn(), + }; + // Mock the Logger constructor + vi.spyOn(Logger.prototype, 'log').mockImplementation(mockLogger.log); + vi.spyOn(Logger.prototype, 'error').mockImplementation(mockLogger.error); + vi.spyOn(Logger.prototype, 'warn').mockImplementation(mockLogger.warn); + vi.spyOn(Logger.prototype, 'debug').mockImplementation(mockLogger.debug); + vi.spyOn(Logger.prototype, 'verbose').mockImplementation(mockLogger.verbose); const module: TestingModule = await Test.createTestingModule({ providers: [UnraidFileModificationService], }).compile(); @@ -20,5 +68,35 @@ describe('FileModificationService', () => { it('should load modifications', async () => { const mods = await service.loadModifications(); expect(mods).toHaveLength(2); - }) + }); + + it('should apply modifications', async () => { + await expect( + service.applyModification(new TestFileModification()) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('should not rollback any mods without loaded', async () => { + await expect(service.rollbackAll()).resolves.toBe(undefined); + }); + + it('should rollback all mods', async () => { + await service.loadModifications(); + const applyFn = vi.fn(); + const rollbackFn = vi.fn(); + await service.applyModification(new TestFileModification(applyFn, rollbackFn)); + await expect(service.rollbackAll()).resolves.toBe(undefined); + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(mockLogger.log).toHaveBeenCalledTimes(5); + expect(applyFn).toHaveBeenCalled(); + expect(rollbackFn).toHaveBeenCalled(); + expect(mockLogger.log).toHaveBeenNthCalledWith(1, 'RootTestModule dependencies initialized'); + expect(mockLogger.log).toHaveBeenNthCalledWith( + 2, + 'Applying modification: test - Always Apply this mod' + ); + expect(mockLogger.log).toHaveBeenNthCalledWith(3, 'Modification applied successfully: test'); + expect(mockLogger.log).toHaveBeenNthCalledWith(4, 'Rolling back modification: test'); + expect(mockLogger.log).toHaveBeenNthCalledWith(5, 'Modification rolled back successfully: test'); + }); }); From 2dd06cdd0ca6d4f41210b76dae14c696b2767efb Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 13:56:00 -0500 Subject: [PATCH 06/14] Update api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../unraid-file-modifier/modifications/sso.modification.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts index bf4b605922..436333ae6a 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts @@ -33,7 +33,9 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool $safePassword = escapeshellarg($password); if (!preg_match('/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/', $password)) { my_logger("SSO Login Attempt Failed: Invalid token format"); + return false; } + $safePassword = escapeshellarg($password); $response = exec("/usr/local/bin/unraid-api sso validate-token $safePassword", $output, $code); my_logger("SSO Login Attempt: $response"); if ($code === 0 && $response && strpos($response, '"valid":true') !== false) { From 2156443aab4b8a64caf2fc84cbec091b010b768d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 13:58:47 -0500 Subject: [PATCH 07/14] fix: file modification service fixes --- api/src/unraid-api/auth/auth.service.spec.ts | 1 + .../unraid-file-modifier.spec.ts | 28 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/api/src/unraid-api/auth/auth.service.spec.ts b/api/src/unraid-api/auth/auth.service.spec.ts index 96102ea1d8..f29fdd01b9 100644 --- a/api/src/unraid-api/auth/auth.service.spec.ts +++ b/api/src/unraid-api/auth/auth.service.spec.ts @@ -24,6 +24,7 @@ describe('AuthService', () => { description: 'Test API Key Description', roles: [Role.GUEST, Role.CONNECT], createdAt: new Date().toISOString(), + permissions: [], }; const mockApiKeyWithSecret: ApiKeyWithSecret = { diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts index f8c1096ad1..fd32849487 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts @@ -1,7 +1,7 @@ import { Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileModification, @@ -99,4 +99,30 @@ describe('FileModificationService', () => { expect(mockLogger.log).toHaveBeenNthCalledWith(4, 'Rolling back modification: test'); expect(mockLogger.log).toHaveBeenNthCalledWith(5, 'Modification rolled back successfully: test'); }); + + it('should handle errors during rollback', async () => { + const errorMod = new TestFileModification(vi.fn(), () => + Promise.reject(new Error('Rollback failed')) + ); + await service.applyModification(errorMod); + await service.rollbackAll(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('should handle concurrent modifications', async () => { + const mods = [ + new TestFileModification(vi.fn(), vi.fn()), + new TestFileModification(vi.fn(), vi.fn()), + ]; + await Promise.all(mods.map((mod) => service.applyModification(mod))); + await service.rollbackAll(); + mods.forEach((mod) => { + expect(mod.rollbackImplementation).toHaveBeenCalled(); + }); + }); + + afterEach(async () => { + await service.rollbackAll(); + vi.clearAllMocks(); + }); }); From adb854c9141906f8c90b47adda1d549f9c3aff8b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 14:59:14 -0500 Subject: [PATCH 08/14] Update api/src/utils.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- api/src/utils.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/api/src/utils.ts b/api/src/utils.ts index bd95520b8d..293e0c293d 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -269,14 +269,31 @@ export const backupFile = async (path: string, throwOnMissing = true): Promise => { + if (!path) { + throw new Error('File path cannot be empty'); + } + const backupPath = path + '.bak'; try { + // Check if backup file exists and is readable + await access(backupPath, constants.R_OK); + + // Check if target directory is writable + await access(dirname(path), constants.W_OK); + await copyFile(backupPath, path); await unlink(backupPath); return true; - } catch { + } catch (err) { + const error = err as NodeJS.ErrnoException; if (throwOnMissing) { - throw new Error(`Backup file does not exist: ${backupPath}`); + throw new Error( + error.code === 'ENOENT' + ? `Backup file does not exist: ${backupPath}` + : error.code === 'EACCES' + ? `Permission denied: ${path}` + : `Failed to restore file: ${error.message}` + ); } return false; } From 36fb8546379190c240c5f4d778933717a18b8744 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 15:00:19 -0500 Subject: [PATCH 09/14] Update api/src/utils.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- api/src/utils.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/api/src/utils.ts b/api/src/utils.ts index 293e0c293d..ff9c32402e 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -245,6 +245,9 @@ export function handleAuthError( throw new UnauthorizedException(`${operation}: ${errorMessage}`); } +import { access, constants } from 'node:fs/promises'; +import { dirname } from 'node:path'; + /** * Helper method to allow backing up a single file to a .bak file. * @param path the file to backup, creates a .bak file in the same directory @@ -252,11 +255,29 @@ export function handleAuthError( */ export const backupFile = async (path: string, throwOnMissing = true): Promise => { try { + // Validate path + if (!path) { + throw new Error('File path cannot be empty'); + } + + // Check if source file exists and is readable + await access(path, constants.R_OK); + + // Check if backup directory is writable + await access(dirname(path), constants.W_OK); + const backupPath = path + '.bak'; await copyFile(path, backupPath); } catch (err) { + const error = err as NodeJS.ErrnoException; if (throwOnMissing) { - throw new Error(`File does not exist: ${path}`); + throw new Error( + error.code === 'ENOENT' + ? `File does not exist: ${path}` + : error.code === 'EACCES' + ? `Permission denied: ${path}` + : `Failed to backup file: ${error.message}` + ); } } }; From f8c28471ee7473ba5e54e1f53b46a37147e8c565 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 15:01:01 -0500 Subject: [PATCH 10/14] fix: backup restore formatting --- api/src/utils.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/api/src/utils.ts b/api/src/utils.ts index ff9c32402e..9d834cb4f8 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,6 +1,7 @@ import { BadRequestException, ExecutionContext, Logger, UnauthorizedException } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; -import { copyFile, unlink } from 'node:fs/promises'; +import { access, constants, copyFile, unlink } from 'node:fs/promises'; +import { dirname } from 'node:path'; import strftime from 'strftime'; @@ -245,9 +246,6 @@ export function handleAuthError( throw new UnauthorizedException(`${operation}: ${errorMessage}`); } -import { access, constants } from 'node:fs/promises'; -import { dirname } from 'node:path'; - /** * Helper method to allow backing up a single file to a .bak file. * @param path the file to backup, creates a .bak file in the same directory @@ -259,13 +257,13 @@ export const backupFile = async (path: string, throwOnMissing = true): Promise Date: Tue, 28 Jan 2025 15:16:04 -0500 Subject: [PATCH 11/14] fix: remove line from or in button --- web/components/SsoButton.ce.vue | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/web/components/SsoButton.ce.vue b/web/components/SsoButton.ce.vue index 4ba6a1e5f5..badf1dfcd2 100644 --- a/web/components/SsoButton.ce.vue +++ b/web/components/SsoButton.ce.vue @@ -107,7 +107,6 @@ onMounted(async () => { currentState.value = 'error'; error.value = 'Error fetching token'; reEnableFormOnError(); - } finally { } }); @@ -137,12 +136,11 @@ const navigateToExternalSSOUrl = () => {