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/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/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 91edaf6655..68dc9c1349 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -13,10 +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 { 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'; @@ -61,8 +57,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()); @@ -100,22 +94,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/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index 215015bdfe..872f697563 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -47,7 +47,7 @@ export class ApiKeyService implements OnModuleInit { } private setupWatch() { - watch(this.basePath, { ignoreInitial: false }).on('change', async (path) => { + watch(this.basePath, { ignoreInitial: false }).on('all', async (path) => { this.logger.debug(`API key changed: ${path}`); this.memoryApiKeys = []; this.memoryApiKeys = await this.loadAllFromDisk(); 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/cli/log.service.ts b/api/src/unraid-api/cli/log.service.ts index e725aeab33..e3389f687b 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,42 @@ export class LogService { this.logger.clear(); } + shouldLog(level: LogLevel): boolean { + const logLevelsLowToHigh = levels; + const shouldLog = ( + logLevelsLowToHigh.indexOf(level) >= + logLevelsLowToHigh.indexOf(LOG_LEVEL.toLowerCase() as LogLevel) + ); + return shouldLog; + } + 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..a604fdfb51 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -1,9 +1,14 @@ +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'; +import { LOG_LEVEL, NODE_ENV } from '@app/environment'; interface StartCommandOptions { 'log-level'?: string; @@ -15,18 +20,47 @@ export class StartCommand extends CommandRunner { super(); } + 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 + // 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/__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/modifications/auth-request.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts new file mode 100644 index 0000000000..4dc24b71ec --- /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, + 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; + +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')) { + 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..436333ae6a --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts @@ -0,0 +1,93 @@ +import type { Logger } from '@nestjs/common'; +import { readFile, writeFile } from 'node:fs/promises'; + +import { + FileModification, + 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'; + 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"); + 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) { + return true; + } + } + return false; +}`; + + const tagToInject = + ''; + + // Restore the original file if exists + await restoreFile(this.loginFilePath, false); + // Backup the original content + await 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 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..869b4f6253 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +import { UnraidFileModificationService } from './unraid-file-modifier.service'; + +@Module({ + 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 new file mode 100644 index 0000000000..232c108c77 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts @@ -0,0 +1,118 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; + +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 UnraidFileModificationService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(UnraidFileModificationService.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() { + this.logger.log('Rolling back all modifications...'); + await this.rollbackAll(); + } + + /** + * 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}`); + } + } + } + + /** + * 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}` + ); + } + } + } + } + } +} 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..8a6366e83c --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts @@ -0,0 +1,128 @@ +import { Logger } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { afterEach, 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(); + + service = module.get(UnraidFileModificationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + 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()) + ).resolves.toBe(undefined); + }); + + 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'); + }); + + 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(); + }); +}); diff --git a/api/src/utils.ts b/api/src/utils.ts index 989e1b8c3b..9d834cb4f8 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,10 +1,10 @@ import { BadRequestException, ExecutionContext, Logger, UnauthorizedException } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; +import { access, constants, copyFile, unlink } from 'node:fs/promises'; +import { dirname } from 'node:path'; 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 +245,75 @@ 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 { + // 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( + error.code === 'ENOENT' + ? `File does not exist: ${path}` + : error.code === 'EACCES' + ? `Permission denied: ${path}` + : `Failed to backup file: ${error.message}` + ); + } + } +}; + +/** + * + * @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 => { + 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 (err) { + const error = err as NodeJS.ErrnoException; + if (throwOnMissing) { + 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; + } +}; 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 = () => {