feat: initial version of modification service

This commit is contained in:
Eli Bosley
2025-01-28 11:32:15 -05:00
parent 4d8f2ddac6
commit bb37140d40
10 changed files with 342 additions and 151 deletions

View File

@@ -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.`);
}
};

View File

@@ -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.');
}
};

View File

@@ -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 = '<?php include "$docroot/plugins/dynamix.my.servers/include/sso-login.php"; ?>';
// 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 (<?php)
fileContent = fileContent.replace(/<\?php\s*(\r?\n|\r)*/, `<?php\n\n${newFunction}\n`);
// Replace the old function call with the new function name
const functionCallPattern = /!verifyUsernamePassword\(\$username, \$password\)/g;
fileContent = fileContent.replace(
functionCallPattern,
'!verifyUsernamePasswordAndSSO($username, $password)'
);
// Inject the PHP include tag after the closing </form> tag
fileContent = fileContent.replace(/<\/form>/i, `</form>\n${tagToInject}`);
// Write the updated content back to the file
await writeFile(path, fileContent);
console.log('Function replaced successfully.');
};

View File

@@ -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();

View File

@@ -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: [

View File

@@ -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<void> {
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<void> {
// No rollback needed, this is safe to preserve
}
async shouldApply(): Promise<ShouldApplyWithReason> {
return { shouldApply: true, reason: 'Always apply the allowed file changes to ensure compatibility.' };
}
}

View File

@@ -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<void> {
// 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 =
'<?php include "$docroot/plugins/dynamix.my.servers/include/sso-login.php"; ?>';
// 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 (<?php)
fileContent = fileContent.replace(/<\?php\s*(\r?\n|\r)*/, `<?php\n\n${newFunction}\n`);
// Replace the old function call with the new function name
const functionCallPattern = /!verifyUsernamePassword\(\$username, \$password\)/g;
fileContent = fileContent.replace(
functionCallPattern,
'!verifyUsernamePasswordAndSSO($username, $password)'
);
// Inject the PHP include tag after the closing </form> tag
fileContent = fileContent.replace(/<\/form>/i, `</form>\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<void> {
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<ShouldApplyWithReason> {
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' };
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { FileModificationService } from './unraid-file-modifier.service';
@Module({
providers: [FileModificationService]
})
export class UnraidFileModifierModule {}

View File

@@ -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<void>; // Method to apply the modification
rollback(): Promise<void>; // Method to roll back the modification
shouldApply(): Promise<ShouldApplyWithReason>; // 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<FileModification[]> {
const modifications: FileModification[] = [];
const modificationClasses: Array<new (logger: Logger) => FileModification> = [
AuthRequestModification,
SSOFileModification,
];
for (const ModificationClass of modificationClasses) {
const instance = new ModificationClass(this.logger);
modifications.push(instance);
}
return modifications;
}
async applyModifications(modifications: FileModification[]): Promise<void> {
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<void> {
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<void> {
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<void> => {
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<boolean> => {
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;
}
};
}

View File

@@ -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>(UnraidFileModifierService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});