Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
export const PM2_HOME = process.env.PM2_HOME ?? join(homedir(), '.pm2');
export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2');
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
export const LOGS_DIR = process.env.LOGS_DIR ?? '/var/log/unraid-api';

export const PATHS_CONFIG_MODULES =
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';
11 changes: 9 additions & 2 deletions api/src/unraid-api/cli/pm2.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common';
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';

import type { Options, Result, ResultPromise } from 'execa';
import { execa, ExecaError } from 'execa';

import { PM2_HOME, PM2_PATH } from '@app/environment.js';
import { LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';

type CmdContext = Options & {
Expand Down Expand Up @@ -97,4 +97,11 @@ export class PM2Service {
this.logger.trace('PM2 home directory does not exist.');
}
}

/**
* Ensures that the dependencies necessary for PM2 to start and operate are present.
*/
async ensurePm2Dependencies() {
await mkdir(LOGS_DIR, { recursive: true });
}
}
1 change: 1 addition & 0 deletions api/src/unraid-api/cli/start.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class StartCommand extends CommandRunner {
}

async cleanupPM2State() {
await this.pm2.ensurePm2Dependencies();
await this.pm2.run({ tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH);
await this.pm2.run({ tag: 'PM2 Update' }, 'update');
await this.pm2.deleteDump();
Expand Down
12 changes: 12 additions & 0 deletions api/src/unraid-api/nginx/nginx.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';

import { NginxService } from '@app/unraid-api/nginx/nginx.service.js';

/**
*
*/
@Module({
providers: [NginxService],
exports: [NginxService],
})
export class NginxModule {}
10 changes: 9 additions & 1 deletion api/src/unraid-api/plugin/global-deps.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@ import { GRAPHQL_PUBSUB_TOKEN } from '@unraid/shared/pubsub/graphql.pubsub.js';
import {
API_KEY_SERVICE_TOKEN,
LIFECYCLE_SERVICE_TOKEN,
NGINX_SERVICE_TOKEN,
UPNP_CLIENT_TOKEN,
} from '@unraid/shared/tokens.js';

import { pubsub } from '@app/core/pubsub.js';
import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { ApiKeyModule } from '@app/unraid-api/graph/resolvers/api-key/api-key.module.js';
import { NginxModule } from '@app/unraid-api/nginx/nginx.module.js';
import { NginxService } from '@app/unraid-api/nginx/nginx.service.js';
import { upnpClient } from '@app/upnp/helpers.js';

// This is the actual module that provides the global dependencies
@Global()
@Module({
imports: [ApiKeyModule],
imports: [ApiKeyModule, NginxModule],
providers: [
{
provide: UPNP_CLIENT_TOKEN,
Expand All @@ -31,6 +34,10 @@ import { upnpClient } from '@app/upnp/helpers.js';
provide: API_KEY_SERVICE_TOKEN,
useClass: ApiKeyService,
},
{
provide: NGINX_SERVICE_TOKEN,
useClass: NginxService,
},
PrefixedID,
LifecycleService,
{
Expand All @@ -42,6 +49,7 @@ import { upnpClient } from '@app/upnp/helpers.js';
UPNP_CLIENT_TOKEN,
GRAPHQL_PUBSUB_TOKEN,
API_KEY_SERVICE_TOKEN,
NGINX_SERVICE_TOKEN,
PrefixedID,
LIFECYCLE_SERVICE_TOKEN,
LifecycleService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';

import { NginxService } from '@app/unraid-api/nginx/nginx.service.js';
import { ModificationEffect } from '@app/unraid-api/unraid-file-modifier/file-modification.js';

@Injectable()
export class FileModificationEffectService {
constructor(private readonly nginxService: NginxService) {}
async runEffect(effect: ModificationEffect): Promise<void> {
switch (effect) {
case 'nginx:reload':
await this.nginxService.reload();
break;
}
}
}
10 changes: 9 additions & 1 deletion api/src/unraid-api/unraid-file-modifier/file-modification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ import { coerce, compare, gte } from 'semver';

import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js';

export type ModificationEffect = 'nginx:reload';

export interface ShouldApplyWithReason {
shouldApply: boolean;
reason: string;
/**
* Effectful actions that should be performed after the modification is applied.
*
* This field helps ensure that an effect requested by multiple modifications is only performed once.
*/
effects?: ModificationEffect[];
}

// Convert interface to abstract class with default implementations
Expand Down Expand Up @@ -98,7 +106,7 @@ export abstract class FileModification {
const currentContent = await readFile(this.filePath, 'utf8').catch(() => '');
const parsedPatch = parsePatch(patchContents)[0];
if (!parsedPatch?.hunks.length) {
throw new Error('Invalid Patch Format: No hunks found');
throw new Error(`Invalid Patch Format: No hunks found for ${this.filePath}`);
}

const results = applyPatch(currentContent, parsedPatch);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { constants } from 'fs';
import { access } from 'fs/promises';
import { readFile } from 'node:fs/promises';

import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';

export default class HostsModification extends FileModification {
id: string = 'hosts';
public readonly filePath: string = '/etc/hosts' as const;

protected async generatePatch(overridePath?: string): Promise<string> {
const originalContent = await readFile(this.filePath, 'utf8');

// Use a case-insensitive word-boundary regex so the hostname must appear as an independent token
// prevents partial string & look-alike conflicts such as "keys.lime-technology.com.example.com"
const hostPattern = /\bkeys\.lime-technology\.com\b/i;

const newContent = originalContent
.split('\n')
.filter((line) => !hostPattern.test(line))
.join('\n');

return this.createPatchWithDiff(overridePath ?? this.filePath, originalContent, newContent);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises';

import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';

export default class NginxConfModification extends FileModification {
id: string = 'nginx-conf';
public readonly filePath: string = '/etc/nginx/nginx.conf' as const;

protected async generatePatch(overridePath?: string): Promise<string> {
const originalContent = await readFile(this.filePath, 'utf8');
const newContent = originalContent.replace(
"add_header X-Frame-Options 'SAMEORIGIN';",
'add_header Content-Security-Policy "frame-ancestors \'self\' https://connect.myunraid.net/";'
);
return this.createPatchWithDiff(overridePath ?? this.filePath, originalContent, newContent);
}

async shouldApply(): Promise<ShouldApplyWithReason> {
const superShouldApply = await super.shouldApply();
if (!superShouldApply.shouldApply) {
return superShouldApply;
}
const content = await readFile(this.filePath, 'utf8');
const hasSameOrigin = content.includes("add_header X-Frame-Options 'SAMEORIGIN';");
if (!hasSameOrigin) {
return {
shouldApply: false,
reason: 'X-Frame-Options SAMEORIGIN header not found in nginx.conf',
};
}
return {
shouldApply: true,
reason: 'X-Frame-Options SAMEORIGIN found and needs to be replaced with Content-Security-Policy',
effects: ['nginx:reload'],
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ check_remote_access(){
'for NET in "${!NET_FQDN[@]}"; do'
);

// Add robots.txt Access-Control-Allow-Origin header if not already present
if (!newContent.includes('#robots.txt any origin')) {
newContent = newContent.replace(
'location = /robots.txt {',
// prettier-ignore
`location = /robots.txt {
\t add_header Access-Control-Allow-Origin *; #robots.txt any origin`
);
}

return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}

Expand All @@ -91,6 +101,7 @@ check_remote_access(){
return {
shouldApply: shouldApply,
reason: shouldApply ? 'Unraid version is less than 7.2.0, applying the patch.' : reason,
effects: ['nginx:reload'],
};
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';

import { NginxModule } from '@app/unraid-api/nginx/nginx.module.js';
import { FileModificationEffectService } from '@app/unraid-api/unraid-file-modifier/file-modification-effect.service.js';
import { UnraidFileModificationService } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service.js';

@Module({
providers: [UnraidFileModificationService],
imports: [NginxModule],
providers: [UnraidFileModificationService, FileModificationEffectService],
})
export class UnraidFileModifierModule {}
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';

import type { ModificationEffect } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
import { FileModificationEffectService } from '@app/unraid-api/unraid-file-modifier/file-modification-effect.service.js';
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';

@Injectable()
export class UnraidFileModificationService implements OnModuleInit, OnModuleDestroy {
export class UnraidFileModificationService
implements OnModuleInit, OnModuleDestroy, OnApplicationBootstrap
{
private readonly logger = new Logger(UnraidFileModificationService.name);
private appliedModifications: FileModification[] = [];
private effects: Set<ModificationEffect> = new Set();

constructor(private readonly effectService: FileModificationEffectService) {}

/**
* Load and apply all modifications on module init
Expand All @@ -22,6 +35,17 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest
}
}

async onApplicationBootstrap() {
for (const effect of this.effects) {
try {
await this.effectService.runEffect(effect);
this.logger.log(`Applied effect: ${effect}`);
} catch (err) {
this.logger.error(err, `Failed to apply effect: ${effect}`);
}
}
}

/**
* Rollback all applied modifications on module destroy
*/
Expand Down Expand Up @@ -93,6 +117,7 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest
);
await modification.apply();
this.appliedModifications.push(modification);
shouldApplyWithReason.effects?.forEach((effect) => this.effects.add(effect));
this.logger.log(`Modification applied successfully: ${modification.id}`);
} else {
this.logger.log(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
import { FileLoadStatus } from '@app/store/types.js';
import { NginxModule } from '@app/unraid-api/nginx/nginx.module.js';
import { FileModificationEffectService } from '@app/unraid-api/unraid-file-modifier/file-modification-effect.service.js';
import {
FileModification,
ShouldApplyWithReason,
Expand Down Expand Up @@ -69,7 +71,8 @@ describe.sequential('FileModificationService', () => {
logger = new Logger('test');

const module: TestingModule = await Test.createTestingModule({
providers: [UnraidFileModificationService],
imports: [NginxModule],
providers: [UnraidFileModificationService, FileModificationEffectService],
}).compile();

service = module.get<UnraidFileModificationService>(UnraidFileModificationService);
Expand Down Expand Up @@ -128,13 +131,15 @@ describe.sequential('FileModificationService', () => {
expect(rolledBackContent).toBe(ORIGINAL_CONTENT);

expect(mockLogger.warn).toHaveBeenCalledWith('Could not load pregenerated patch for: test');
expect(mockLogger.log.mock.calls).toEqual([
['RootTestModule dependencies initialized'],
['Applying modification: test - Always Apply this mod'],
['Modification applied successfully: test'],
['Rolling back modification: test'],
['Successfully rolled back modification: test'],
]);
expect(mockLogger.log.mock.calls).toEqual(
expect.arrayContaining([
['RootTestModule dependencies initialized'],
['Applying modification: test - Always Apply this mod'],
['Modification applied successfully: test'],
['Rolling back modification: test'],
['Successfully rolled back modification: test'],
])
);
});

it('should handle errors during dual application', async () => {
Expand All @@ -146,11 +151,13 @@ describe.sequential('FileModificationService', () => {

await service.applyModification(mod);

expect(mockLogger.log.mock.calls).toEqual([
['RootTestModule dependencies initialized'],
['Applying modification: test - Always Apply this mod'],
['Modification applied successfully: test'],
]);
expect(mockLogger.log.mock.calls).toEqual(
expect.arrayContaining([
['RootTestModule dependencies initialized'],
['Applying modification: test - Always Apply this mod'],
['Modification applied successfully: test'],
])
);

// Now apply again and ensure the contents don't change
await service.applyModification(mod);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { NetworkResolver } from '../resolver/network.resolver.js';
import { ConnectConfigService } from '../service/connect-config.service.js';
import { DnsService } from '../service/dns.service.js';
import { NetworkService } from '../service/network.service.js';
import { NginxService } from '../service/nginx.service.js';
import { UpnpService } from '../service/upnp.service.js';
import { UrlResolverService } from '../service/url-resolver.service.js';

Expand All @@ -17,7 +16,6 @@ import { UrlResolverService } from '../service/url-resolver.service.js';
UpnpService,
UrlResolverService,
DnsService,
NginxService,
ConnectConfigService,
],
exports: [
Expand All @@ -26,7 +24,6 @@ import { UrlResolverService } from '../service/url-resolver.service.js';
UpnpService,
UrlResolverService,
DnsService,
NginxService,
ConnectConfigService,
],
})
Expand Down
Loading
Loading