From bb37140d40034d3f3fceb2b387a329a053ad76af Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 11:32:15 -0500 Subject: [PATCH] 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 9b3bb0c12..000000000 --- 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 e1bc59a7c..000000000 --- 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 f01b0222f..000000000 --- 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 91edaf665..3d6e70875 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 6e86716f5..a9df97fa4 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 000000000..30b5b1de8 --- /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 000000000..0de6063d0 --- /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 000000000..6434b68eb --- /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 000000000..7778f6bb9 --- /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 000000000..2cea07f8a --- /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(); + }); +});