mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: initial version of modification service
This commit is contained in:
@@ -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.`);
|
||||
}
|
||||
};
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
@@ -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.');
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FileModificationService } from './unraid-file-modifier.service';
|
||||
|
||||
@Module({
|
||||
providers: [FileModificationService]
|
||||
})
|
||||
export class UnraidFileModifierModule {}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user