fix: remote access lifecycle during boot & shutdown (#1422)

## Summary

- **New Features**
- Introduced a comprehensive Nginx control script for Unraid OS,
enabling advanced server management, SSL certificate handling, and
dynamic configuration based on system state.
https://github.com/unraid/webgui/pull/2269
- Added a utility function to safely execute code with error handling
support.

- **Improvements**
- Enhanced logging across remote access, WAN access, and settings
services for improved traceability.
- Added initialization and cleanup hooks to remote access and UPnP
services for better lifecycle management.
- Optimized configuration persistence by batching rapid changes for more
efficient updates.
- Refined URL resolution logic for improved configuration retrieval and
error handling.
  - Broadened pattern matching for domain keys in Nginx state parsing.
- Updated remote access settings to reload the network stack after
changes.
- Simplified remote access and WAN port configuration logic for clarity
and accuracy.
- Improved port mapping logic with explicit error handling in UPnP
service.
- Updated UI and form controls for remote access settings to reflect SSL
requirements and access type restrictions.

- **Configuration**
  - Updated the default path for module configuration files.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pujit Mehrotra
2025-06-24 15:10:07 -04:00
committed by GitHub
parent b8035c207a
commit 7bc583b186
14 changed files with 227 additions and 141 deletions

View File

@@ -101,4 +101,4 @@ export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json'); export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
export const PATHS_CONFIG_MODULES = export const PATHS_CONFIG_MODULES =
process.env.PATHS_CONFIG_MODULES ?? '/usr/local/unraid-api/config/modules'; process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';

View File

@@ -2,8 +2,8 @@ import type { IniStringBooleanOrAuto } from '@app/core/types/ini.js';
import type { StateFileToIniParserMap } from '@app/store/types.js'; import type { StateFileToIniParserMap } from '@app/store/types.js';
import { type FqdnEntry } from '@app/core/types/states/nginx.js'; import { type FqdnEntry } from '@app/core/types/states/nginx.js';
// Allow upper or lowercase FQDN6 // Allow upper or lowercase FQDN6, with optional separators
const fqdnRegex = /^nginx(.*?)fqdn6?$/i; const fqdnRegex = /^nginx[_-]?(.+?)fqdn6?$/i;
export type NginxIni = { export type NginxIni = {
nginxCertname: string; nginxCertname: string;

View File

@@ -89,8 +89,8 @@ export class UnifiedSettingsResolver {
): Promise<UpdateSettingsResponse> { ): Promise<UpdateSettingsResponse> {
this.logger.verbose('Updating Settings %O', input); this.logger.verbose('Updating Settings %O', input);
const { restartRequired, values } = await this.userSettings.updateNamespacedValues(input); const { restartRequired, values } = await this.userSettings.updateNamespacedValues(input);
this.logger.verbose('Updated Setting Values %O', values);
if (restartRequired) { if (restartRequired) {
this.logger.verbose('Will restart %O', values);
// hack: allow time for pending writes to flush // hack: allow time for pending writes to flush
this.lifecycleService.restartApi({ delayMs: 300 }); this.lifecycleService.restartApi({ delayMs: 300 });
} }

View File

@@ -1,31 +1,30 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { EVENTS } from '../helper/nest-tokens.js'; import { EVENTS } from '../helper/nest-tokens.js';
import { ConfigType } from '../model/connect-config.model.js'; import { ConfigType } from '../model/connect-config.model.js';
import { NetworkService } from '../service/network.service.js'; import { NetworkService } from '../service/network.service.js';
import { UrlResolverService } from '../service/url-resolver.service.js';
@Injectable() @Injectable()
export class WanAccessEventHandler implements OnModuleDestroy { export class WanAccessEventHandler {
private readonly logger = new Logger(WanAccessEventHandler.name);
constructor( constructor(
private readonly configService: ConfigService<ConfigType>, private readonly configService: ConfigService<ConfigType>,
private readonly networkService: NetworkService private readonly networkService: NetworkService
) {} ) {}
async onModuleDestroy() {
await this.disableWanAccess();
}
@OnEvent(EVENTS.ENABLE_WAN_ACCESS, { async: true }) @OnEvent(EVENTS.ENABLE_WAN_ACCESS, { async: true })
async enableWanAccess() { async enableWanAccess() {
this.logger.log('Enabling WAN Access');
this.configService.set('connect.config.wanaccess', true); this.configService.set('connect.config.wanaccess', true);
await this.networkService.reloadNetworkStack(); await this.networkService.reloadNetworkStack();
} }
@OnEvent(EVENTS.DISABLE_WAN_ACCESS, { async: true }) @OnEvent(EVENTS.DISABLE_WAN_ACCESS, { async: true })
async disableWanAccess() { async disableWanAccess() {
this.logger.log('Disabling WAN Access');
this.configService.set('connect.config.wanaccess', false); this.configService.set('connect.config.wanaccess', false);
await this.networkService.reloadNetworkStack(); await this.networkService.reloadNetworkStack();
} }

View File

@@ -9,14 +9,14 @@ import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator'; import { validateOrReject } from 'class-validator';
import { parse as parseIni } from 'ini'; import { parse as parseIni } from 'ini';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { debounceTime } from 'rxjs/operators'; import { bufferTime } from 'rxjs/operators';
import type { MyServersConfig as LegacyConfig } from '../model/my-servers-config.model.js'; import type { MyServersConfig as LegacyConfig } from '../model/my-servers-config.model.js';
import { ConfigType, MyServersConfig } from '../model/connect-config.model.js'; import { ConfigType, MyServersConfig } from '../model/connect-config.model.js';
@Injectable() @Injectable()
export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy { export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
constructor(private readonly configService: ConfigService<ConfigType>) {} constructor(private readonly configService: ConfigService<ConfigType, true>) {}
private logger = new Logger(ConnectConfigPersister.name); private logger = new Logger(ConnectConfigPersister.name);
get configPath() { get configPath() {
@@ -33,10 +33,10 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
this.logger.verbose(`Config path: ${this.configPath}`); this.logger.verbose(`Config path: ${this.configPath}`);
await this.loadOrMigrateConfig(); await this.loadOrMigrateConfig();
// Persist changes to the config. // Persist changes to the config.
this.configService.changes$.pipe(debounceTime(25)).subscribe({ this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async ({ newValue, oldValue, path }) => { next: async (changes) => {
if (path.startsWith('connect.config')) { const connectConfigChanged = changes.some(({ path }) => path.startsWith('connect.config'));
this.logger.verbose(`Config changed: ${path} from ${oldValue} to ${newValue}`); if (connectConfigChanged) {
await this.persist(); await this.persist();
} }
}, },
@@ -60,7 +60,7 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
return false; return false;
} }
} catch (error) { } catch (error) {
this.logger.error(`Error loading config (will overwrite file):`, error); this.logger.error(error, `Error loading config (will overwrite file)`);
} }
const data = JSON.stringify(config, null, 2); const data = JSON.stringify(config, null, 2);
this.logger.verbose(`Persisting config to ${this.configPath}: ${data}`); this.logger.verbose(`Persisting config to ${this.configPath}: ${data}`);
@@ -69,7 +69,7 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
this.logger.verbose(`Config persisted to ${this.configPath}`); this.logger.verbose(`Config persisted to ${this.configPath}`);
return true; return true;
} catch (error) { } catch (error) {
this.logger.error(`Error persisting config to '${this.configPath}':`, error); this.logger.error(error, `Error persisting config to '${this.configPath}'`);
return false; return false;
} }
} }

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import type { SchemaBasedCondition } from '@jsonforms/core'; import type { JsonSchema7, SchemaBasedCondition } from '@jsonforms/core';
import type { DataSlice, SettingSlice, UIElement } from '@unraid/shared/jsonforms/settings.js'; import type { DataSlice, SettingSlice, UIElement } from '@unraid/shared/jsonforms/settings.js';
import { RuleEffect } from '@jsonforms/core'; import { RuleEffect } from '@jsonforms/core';
import { createLabeledControl } from '@unraid/shared/jsonforms/control.js'; import { createLabeledControl } from '@unraid/shared/jsonforms/control.js';
@@ -26,6 +26,7 @@ import { ConfigType, MyServersConfig } from '../model/connect-config.model.js';
import { DynamicRemoteAccessType, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE } from '../model/connect.model.js'; import { DynamicRemoteAccessType, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE } from '../model/connect.model.js';
import { ConnectApiKeyService } from './connect-api-key.service.js'; import { ConnectApiKeyService } from './connect-api-key.service.js';
import { DynamicRemoteAccessService } from './dynamic-remote-access.service.js'; import { DynamicRemoteAccessService } from './dynamic-remote-access.service.js';
import { NetworkService } from './network.service.js';
declare module '@unraid/shared/services/user-settings.js' { declare module '@unraid/shared/services/user-settings.js' {
interface UserSettings { interface UserSettings {
@@ -40,7 +41,8 @@ export class ConnectSettingsService {
private readonly remoteAccess: DynamicRemoteAccessService, private readonly remoteAccess: DynamicRemoteAccessService,
private readonly apiKeyService: ConnectApiKeyService, private readonly apiKeyService: ConnectApiKeyService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly userSettings: UserSettingsService private readonly userSettings: UserSettingsService,
private readonly networkService: NetworkService
) { ) {
this.userSettings.register('remote-access', { this.userSettings.register('remote-access', {
buildSlice: async () => this.buildRemoteAccessSlice(), buildSlice: async () => this.buildRemoteAccessSlice(),
@@ -215,7 +217,7 @@ export class ConnectSettingsService {
forwardType?: WAN_FORWARD_TYPE | undefined | null forwardType?: WAN_FORWARD_TYPE | undefined | null
): DynamicRemoteAccessType { ): DynamicRemoteAccessType {
// If access is disabled or always, DRA is disabled // If access is disabled or always, DRA is disabled
if (accessType === WAN_ACCESS_TYPE.DISABLED || accessType === WAN_ACCESS_TYPE.ALWAYS) { if (accessType === WAN_ACCESS_TYPE.DISABLED) {
return DynamicRemoteAccessType.DISABLED; return DynamicRemoteAccessType.DISABLED;
} }
// if access is enabled and forward type is UPNP, DRA is UPNP, otherwise it is static // if access is enabled and forward type is UPNP, DRA is UPNP, otherwise it is static
@@ -231,10 +233,10 @@ export class ConnectSettingsService {
); );
this.configService.set('connect.config.wanaccess', input.accessType === WAN_ACCESS_TYPE.ALWAYS); this.configService.set('connect.config.wanaccess', input.accessType === WAN_ACCESS_TYPE.ALWAYS);
this.configService.set( if (input.forwardType === WAN_FORWARD_TYPE.STATIC) {
'connect.config.wanport', this.configService.set('connect.config.wanport', input.port);
input.forwardType === WAN_FORWARD_TYPE.STATIC ? input.port : null // when forwarding with upnp, the upnp service will clear & set the wanport as necessary
); }
this.configService.set( this.configService.set(
'connect.config.upnpEnabled', 'connect.config.upnpEnabled',
input.forwardType === WAN_FORWARD_TYPE.UPNP input.forwardType === WAN_FORWARD_TYPE.UPNP
@@ -251,17 +253,15 @@ export class ConnectSettingsService {
}, },
}); });
await this.networkService.reloadNetworkStack();
return true; return true;
} }
public async dynamicRemoteAccessSettings(): Promise<RemoteAccess> { public async dynamicRemoteAccessSettings(): Promise<RemoteAccess> {
const config = this.configService.getOrThrow<MyServersConfig>('connect.config'); const config = this.configService.getOrThrow<MyServersConfig>('connect.config');
return { return {
accessType: config.wanaccess accessType: config.wanaccess ? WAN_ACCESS_TYPE.ALWAYS : WAN_ACCESS_TYPE.DISABLED,
? config.dynamicRemoteAccessType !== DynamicRemoteAccessType.DISABLED
? WAN_ACCESS_TYPE.DYNAMIC
: WAN_ACCESS_TYPE.ALWAYS
: WAN_ACCESS_TYPE.DISABLED,
forwardType: config.upnpEnabled ? WAN_FORWARD_TYPE.UPNP : WAN_FORWARD_TYPE.STATIC, forwardType: config.upnpEnabled ? WAN_FORWARD_TYPE.UPNP : WAN_FORWARD_TYPE.STATIC,
port: config.wanport ? Number(config.wanport) : null, port: config.wanport ? Number(config.wanport) : null,
}; };
@@ -272,9 +272,48 @@ export class ConnectSettingsService {
*------------------------------------------------------------------------**/ *------------------------------------------------------------------------**/
async buildRemoteAccessSlice(): Promise<SettingSlice> { async buildRemoteAccessSlice(): Promise<SettingSlice> {
return mergeSettingSlices([await this.remoteAccessSlice()], { const slice = await this.remoteAccessSlice();
as: 'remote-access', /**------------------------------------------------------------------------
}); * UX: Only validate 'port' when relevant
*
* 'port' will be null when remote access is disabled, and it's irrelevant
* when using upnp (because it becomes read-only for the end-user).
*
* In these cases, we should omit type and range validation for 'port'
* to avoid confusing end-users.
*
* But, when using static port forwarding, 'port' is required, so we validate it.
*------------------------------------------------------------------------**/
return {
properties: {
'remote-access': {
type: 'object',
properties: slice.properties as JsonSchema7['properties'],
allOf: [
{
if: {
properties: {
forwardType: { const: WAN_FORWARD_TYPE.STATIC },
accessType: { const: WAN_ACCESS_TYPE.ALWAYS },
},
required: ['forwardType', 'accessType'],
},
then: {
required: ['port'],
properties: {
port: {
type: 'number',
minimum: 1,
maximum: 65535,
},
},
},
},
],
},
},
elements: slice.elements,
};
} }
buildFlashBackupSlice(): SettingSlice { buildFlashBackupSlice(): SettingSlice {
@@ -289,7 +328,8 @@ export class ConnectSettingsService {
async remoteAccessSlice(): Promise<SettingSlice> { async remoteAccessSlice(): Promise<SettingSlice> {
const isSignedIn = await this.isSignedIn(); const isSignedIn = await this.isSignedIn();
const isSSLCertProvisioned = await this.isSSLCertProvisioned(); const isSSLCertProvisioned = await this.isSSLCertProvisioned();
const precondition = isSignedIn && isSSLCertProvisioned; const { sslEnabled } = this.configService.getOrThrow('store.emhttp.nginx');
const precondition = isSignedIn && isSSLCertProvisioned && sslEnabled;
/** shown when preconditions are not met */ /** shown when preconditions are not met */
const requirements: UIElement[] = [ const requirements: UIElement[] = [
@@ -315,6 +355,10 @@ export class ConnectSettingsService {
text: 'You have provisioned a valid SSL certificate', text: 'You have provisioned a valid SSL certificate',
status: isSSLCertProvisioned, status: isSSLCertProvisioned,
}, },
{
text: 'SSL is enabled',
status: sslEnabled,
},
], ],
}, },
}, },
@@ -353,19 +397,30 @@ export class ConnectSettingsService {
}, },
}, },
rule: { rule: {
effect: RuleEffect.SHOW, effect: RuleEffect.DISABLE,
condition: { condition: {
scope: '#/properties/remote-access',
schema: { schema: {
properties: { anyOf: [
forwardType: { {
enum: [WAN_FORWARD_TYPE.STATIC], properties: {
accessType: {
const: WAN_ACCESS_TYPE.DISABLED,
},
},
required: ['accessType'],
}, },
accessType: { {
enum: [WAN_ACCESS_TYPE.DYNAMIC, WAN_ACCESS_TYPE.ALWAYS], properties: {
forwardType: {
const: WAN_FORWARD_TYPE.UPNP,
},
},
required: ['forwardType'],
}, },
}, ],
}, },
} as Omit<SchemaBasedCondition, 'scope'>, },
}, },
}), }),
]; ];
@@ -374,22 +429,22 @@ export class ConnectSettingsService {
const properties: DataSlice = { const properties: DataSlice = {
accessType: { accessType: {
type: 'string', type: 'string',
enum: Object.values(WAN_ACCESS_TYPE), enum: [WAN_ACCESS_TYPE.DISABLED, WAN_ACCESS_TYPE.ALWAYS],
title: 'Allow Remote Access', title: 'Allow Remote Access',
default: 'DISABLED', default: WAN_ACCESS_TYPE.DISABLED,
}, },
forwardType: { forwardType: {
type: 'string', type: 'string',
enum: Object.values(WAN_FORWARD_TYPE), enum: Object.values(WAN_FORWARD_TYPE),
title: 'Forward Type', title: 'Forward Type',
default: 'STATIC', default: WAN_FORWARD_TYPE.STATIC,
}, },
port: { port: {
type: 'number', // 'port' is null when remote access is disabled.
type: ['number', 'null'],
title: 'WAN Port', title: 'WAN Port',
minimum: 0, minimum: 0,
maximum: 65535, maximum: 65535,
default: 0,
}, },
}; };

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { URL_TYPE } from '@unraid/shared/network.model.js'; import { URL_TYPE } from '@unraid/shared/network.model.js';
@@ -15,15 +15,19 @@ import { StaticRemoteAccessService } from './static-remote-access.service.js';
import { UpnpRemoteAccessService } from './upnp-remote-access.service.js'; import { UpnpRemoteAccessService } from './upnp-remote-access.service.js';
@Injectable() @Injectable()
export class DynamicRemoteAccessService { export class DynamicRemoteAccessService implements OnApplicationBootstrap {
private readonly logger = new Logger(DynamicRemoteAccessService.name); private readonly logger = new Logger(DynamicRemoteAccessService.name);
constructor( constructor(
private readonly configService: ConfigService<ConfigType>, private readonly configService: ConfigService<ConfigType, true>,
private readonly staticRemoteAccessService: StaticRemoteAccessService, private readonly staticRemoteAccessService: StaticRemoteAccessService,
private readonly upnpRemoteAccessService: UpnpRemoteAccessService private readonly upnpRemoteAccessService: UpnpRemoteAccessService
) {} ) {}
async onApplicationBootstrap() {
await this.initRemoteAccess();
}
/** /**
* Get the current state of dynamic remote access * Get the current state of dynamic remote access
*/ */
@@ -60,6 +64,7 @@ export class DynamicRemoteAccessService {
type: url.type ?? URL_TYPE.WAN, type: url.type ?? URL_TYPE.WAN,
}; };
this.configService.set('connect.dynamicRemoteAccess.allowedUrl', newAllowed); this.configService.set('connect.dynamicRemoteAccess.allowedUrl', newAllowed);
this.logger.verbose(`setAllowedUrl: ${JSON.stringify(newAllowed, null, 2)}`);
} }
private setErrorMessage(error: string) { private setErrorMessage(error: string) {
@@ -75,6 +80,7 @@ export class DynamicRemoteAccessService {
type: DynamicRemoteAccessType; type: DynamicRemoteAccessType;
}) { }) {
try { try {
this.logger.verbose(`enableDynamicRemoteAccess ${JSON.stringify(input, null, 2)}`);
await this.stopRemoteAccess(); await this.stopRemoteAccess();
if (input.allowedUrl) { if (input.allowedUrl) {
this.setAllowedUrl({ this.setAllowedUrl({
@@ -98,6 +104,7 @@ export class DynamicRemoteAccessService {
* @param type The new dynamic remote access type to set * @param type The new dynamic remote access type to set
*/ */
private async setType(type: DynamicRemoteAccessType): Promise<void> { private async setType(type: DynamicRemoteAccessType): Promise<void> {
this.logger.verbose(`setType: ${type}`);
// Update the config first // Update the config first
this.configService.set('connect.config.dynamicRemoteAccessType', type); this.configService.set('connect.config.dynamicRemoteAccessType', type);
@@ -138,4 +145,22 @@ export class DynamicRemoteAccessService {
this.clearPing(); this.clearPing();
this.clearError(); this.clearError();
} }
private async initRemoteAccess() {
this.logger.verbose('Initializing Remote Access');
const { wanaccess, upnpEnabled } = this.configService.get('connect.config', { infer: true });
if (wanaccess && upnpEnabled) {
await this.enableDynamicRemoteAccess({
type: DynamicRemoteAccessType.UPNP,
allowedUrl: {
ipv4: null,
ipv6: null,
type: URL_TYPE.WAN,
name: 'WAN',
},
});
}
// if wanaccess is true and upnpEnabled is false, static remote access is already "enabled".
// if wanaccess is false, remote access is already "disabled".
}
} }

View File

@@ -21,13 +21,9 @@ export class StaticRemoteAccessService {
} }
async beginRemoteAccess(): Promise<AccessUrl | null> { async beginRemoteAccess(): Promise<AccessUrl | null> {
const { dynamicRemoteAccessType } = this.logger.log('Begin Static Remote Access');
this.configService.getOrThrow<MyServersConfig>('connect.config'); // enabling/disabling static remote access is a config-only change.
if (dynamicRemoteAccessType !== DynamicRemoteAccessType.STATIC) { // the actual forwarding must be configured on the router, outside of the API.
this.logger.error('Invalid Dynamic Remote Access Type: %s', dynamicRemoteAccessType);
return null;
}
this.logger.log('Enabling Static Remote Access');
this.eventEmitter.emit(EVENTS.ENABLE_WAN_ACCESS); this.eventEmitter.emit(EVENTS.ENABLE_WAN_ACCESS);
return this.urlResolverService.getRemoteAccessUrl(); return this.urlResolverService.getRemoteAccessUrl();
} }

View File

@@ -24,22 +24,19 @@ export class UpnpRemoteAccessService {
} }
async begin() { async begin() {
const sslPort = this.configService.get<string | undefined>('store.emhttp.var.portssl'); this.logger.log('Begin UPNP Remote Access');
if (!sslPort || isNaN(Number(sslPort))) { const { httpsPort, httpPort, sslMode } = this.configService.getOrThrow('store.emhttp.nginx');
throw new Error(`Invalid SSL port configuration: ${sslPort}`); const localPort = sslMode === 'no' ? Number(httpPort) : Number(httpsPort);
if (isNaN(localPort)) {
throw new Error(`Invalid local port configuration: ${localPort}`);
} }
try { try {
await this.upnpService.createOrRenewUpnpLease({ const mapping = await this.upnpService.createOrRenewUpnpLease({ localPort });
sslPort: Number(sslPort), this.configService.set('connect.config.wanport', mapping.publicPort);
});
this.eventEmitter.emit(EVENTS.ENABLE_WAN_ACCESS); this.eventEmitter.emit(EVENTS.ENABLE_WAN_ACCESS);
return this.urlResolverService.getRemoteAccessUrl(); return this.urlResolverService.getRemoteAccessUrl();
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(error, 'Failed to begin UPNP Remote Access');
'Failed to begin UPNP Remote Access using port %s: %O',
String(sslPort),
error
);
await this.stop(); await this.stop();
} }
} }

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Cron, SchedulerRegistry } from '@nestjs/schedule'; import { Cron, SchedulerRegistry } from '@nestjs/schedule';
@@ -11,7 +11,7 @@ import { UPNP_RENEWAL_JOB_TOKEN } from '../helper/nest-tokens.js';
import { ConfigType } from '../model/connect-config.model.js'; import { ConfigType } from '../model/connect-config.model.js';
@Injectable() @Injectable()
export class UpnpService { export class UpnpService implements OnModuleDestroy {
private readonly logger = new Logger(UpnpService.name); private readonly logger = new Logger(UpnpService.name);
#enabled = false; #enabled = false;
#wanPort: number | undefined; #wanPort: number | undefined;
@@ -39,6 +39,10 @@ export class UpnpService {
return this.scheduleRegistry.getCronJob(UPNP_RENEWAL_JOB_TOKEN); return this.scheduleRegistry.getCronJob(UPNP_RENEWAL_JOB_TOKEN);
} }
async onModuleDestroy() {
await this.disableUpnp();
}
private async removeUpnpMapping() { private async removeUpnpMapping() {
if (isDefined(this.#wanPort) && isDefined(this.#localPort)) { if (isDefined(this.#wanPort) && isDefined(this.#localPort)) {
const portMap = { const portMap = {
@@ -143,20 +147,18 @@ export class UpnpService {
return newWanPort; return newWanPort;
} }
async createOrRenewUpnpLease(args?: { sslPort?: number; wanPort?: number }) { async createOrRenewUpnpLease(args?: { localPort?: number; wanPort?: number }) {
const { sslPort, wanPort } = args ?? {}; const { localPort, wanPort } = args ?? {};
if (wanPort !== this.#wanPort || this.#localPort !== sslPort) { const newWanOrLocalPort = wanPort !== this.#wanPort || localPort !== this.#localPort;
const upnpWasInitialized = this.#wanPort && this.#localPort;
// remove old mapping when new ports are requested
if (upnpWasInitialized && newWanOrLocalPort) {
await this.removeUpnpMapping(); await this.removeUpnpMapping();
} }
// get new ports to use
const wanPortToUse = await this.getWanPortToUse(args); const wanPortToUse = await this.getWanPortToUse(args);
const localPortToUse = sslPort ?? this.#localPort; const localPortToUse = localPort ?? this.#localPort;
if (wanPortToUse && localPortToUse) { if (!wanPortToUse || !localPortToUse) {
this.#wanPort = wanPortToUse;
await this.createUpnpMapping({
publicPort: wanPortToUse,
privatePort: localPortToUse,
});
} else {
await this.disableUpnp(); await this.disableUpnp();
this.logger.error('No WAN port found %o. Disabled UPNP.', { this.logger.error('No WAN port found %o. Disabled UPNP.', {
wanPort: wanPortToUse, wanPort: wanPortToUse,
@@ -164,6 +166,17 @@ export class UpnpService {
}); });
throw new Error('No WAN port found. Disabled UPNP.'); throw new Error('No WAN port found. Disabled UPNP.');
} }
// create new mapping
const mapping = {
publicPort: wanPortToUse,
privatePort: localPortToUse,
};
const success = await this.createUpnpMapping(mapping);
if (success) {
return mapping;
} else {
throw new Error('Failed to create UPNP mapping');
}
} }
async disableUpnp() { async disableUpnp() {

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { URL_TYPE } from '@unraid/shared/network.model.js'; import { URL_TYPE } from '@unraid/shared/network.model.js';
import { makeSafeRunner } from '@unraid/shared/util/processing.js';
import { ConfigType } from '../model/connect-config.model.js'; import { ConfigType } from '../model/connect-config.model.js';
@@ -115,7 +116,7 @@ export interface AccessUrl {
export class UrlResolverService { export class UrlResolverService {
private readonly logger = new Logger(UrlResolverService.name); private readonly logger = new Logger(UrlResolverService.name);
constructor(private readonly configService: ConfigService<ConfigType>) {} constructor(private readonly configService: ConfigService<ConfigType, true>) {}
/** /**
* Constructs a URL from the given field parameters. * Constructs a URL from the given field parameters.
@@ -259,11 +260,7 @@ export class UrlResolverService {
} }
const { nginx } = store.emhttp; const { nginx } = store.emhttp;
const { const wanport = this.configService.getOrThrow('connect.config.wanport', { infer: true });
config: {
remote: { wanport },
},
} = store;
if (!nginx || Object.keys(nginx).length === 0) { if (!nginx || Object.keys(nginx).length === 0) {
return { urls: [], errors: [new Error('Nginx Not Loaded')] }; return { urls: [], errors: [new Error('Nginx Not Loaded')] };
@@ -272,8 +269,15 @@ export class UrlResolverService {
const errors: Error[] = []; const errors: Error[] = [];
const urls: AccessUrl[] = []; const urls: AccessUrl[] = [];
try { const doSafely = makeSafeRunner((error) => {
// Default URL if (error instanceof Error) {
errors.push(error);
} else {
this.logger.warn(error, 'Uncaught error in network resolver');
}
});
doSafely(() => {
const defaultUrl = new URL(nginx.defaultUrl); const defaultUrl = new URL(nginx.defaultUrl);
urls.push({ urls.push({
name: 'Default', name: 'Default',
@@ -281,15 +285,9 @@ export class UrlResolverService {
ipv4: defaultUrl, ipv4: defaultUrl,
ipv6: defaultUrl, ipv6: defaultUrl,
}); });
} catch (error: unknown) { });
if (error instanceof Error) {
errors.push(error);
} else {
this.logger.warn('Uncaught error in network resolver', error);
}
}
try { doSafely(() => {
// Lan IP URL // Lan IP URL
const lanIp4Url = this.getUrlForServer(nginx, 'lanIp'); const lanIp4Url = this.getUrlForServer(nginx, 'lanIp');
urls.push({ urls.push({
@@ -297,15 +295,9 @@ export class UrlResolverService {
type: URL_TYPE.LAN, type: URL_TYPE.LAN,
ipv4: lanIp4Url, ipv4: lanIp4Url,
}); });
} catch (error: unknown) { });
if (error instanceof Error) {
errors.push(error);
} else {
this.logger.warn('Uncaught error in network resolver', error);
}
}
try { doSafely(() => {
// Lan IP6 URL // Lan IP6 URL
const lanIp6Url = this.getUrlForServer(nginx, 'lanIp6'); const lanIp6Url = this.getUrlForServer(nginx, 'lanIp6');
urls.push({ urls.push({
@@ -313,15 +305,9 @@ export class UrlResolverService {
type: URL_TYPE.LAN, type: URL_TYPE.LAN,
ipv6: lanIp6Url, ipv6: lanIp6Url,
}); });
} catch (error: unknown) { });
if (error instanceof Error) {
errors.push(error);
} else {
this.logger.warn('Uncaught error in network resolver', error);
}
}
try { doSafely(() => {
// Lan Name URL // Lan Name URL
const lanNameUrl = this.getUrlForServer(nginx, 'lanName'); const lanNameUrl = this.getUrlForServer(nginx, 'lanName');
urls.push({ urls.push({
@@ -329,15 +315,9 @@ export class UrlResolverService {
type: URL_TYPE.MDNS, type: URL_TYPE.MDNS,
ipv4: lanNameUrl, ipv4: lanNameUrl,
}); });
} catch (error: unknown) { });
if (error instanceof Error) {
errors.push(error);
} else {
this.logger.warn('Uncaught error in network resolver', error);
}
}
try { doSafely(() => {
// Lan MDNS URL // Lan MDNS URL
const lanMdnsUrl = this.getUrlForServer(nginx, 'lanMdns'); const lanMdnsUrl = this.getUrlForServer(nginx, 'lanMdns');
urls.push({ urls.push({
@@ -345,35 +325,23 @@ export class UrlResolverService {
type: URL_TYPE.MDNS, type: URL_TYPE.MDNS,
ipv4: lanMdnsUrl, ipv4: lanMdnsUrl,
}); });
} catch (error: unknown) { });
if (error instanceof Error) {
errors.push(error);
} else {
this.logger.warn('Uncaught error in network resolver', error);
}
}
// Now Process the FQDN Urls // Now Process the FQDN Urls
nginx.fqdnUrls.forEach((fqdnUrl: FqdnEntry) => { nginx.fqdnUrls.forEach((fqdnUrl: FqdnEntry) => {
try { doSafely(() => {
const urlType = this.getUrlTypeFromFqdn(fqdnUrl.interface); const urlType = this.getUrlTypeFromFqdn(fqdnUrl.interface);
const fqdnUrlToUse = this.getUrlForField({ const fqdnUrlToUse = this.getUrlForField({
url: fqdnUrl.fqdn, url: fqdnUrl.fqdn,
portSsl: urlType === URL_TYPE.WAN ? Number(wanport) : nginx.httpsPort, portSsl: Number(wanport || nginx.httpsPort),
}); });
urls.push({ urls.push({
name: `FQDN ${fqdnUrl.interface}${fqdnUrl.id !== null ? ` ${fqdnUrl.id}` : ''}`, name: `FQDN ${fqdnUrl.interface}${fqdnUrl.id !== null ? ` ${fqdnUrl.id}` : ''}`,
type: this.getUrlTypeFromFqdn(fqdnUrl.interface), type: urlType,
ipv4: fqdnUrlToUse, ipv4: fqdnUrlToUse,
}); });
} catch (error: unknown) { });
if (error instanceof Error) {
errors.push(error);
} else {
this.logger.warn('Uncaught error in network resolver', error);
}
}
}); });
return { urls, errors }; return { urls, errors };

View File

@@ -14,12 +14,12 @@ interface PortTestParams {
describe('UrlResolverService', () => { describe('UrlResolverService', () => {
let service: UrlResolverService; let service: UrlResolverService;
let mockConfigService: ConfigService<ConfigType>; let mockConfigService: ConfigService<ConfigType, true>;
beforeEach(() => { beforeEach(() => {
mockConfigService = { mockConfigService = {
get: vi.fn(), get: vi.fn(),
} as unknown as ConfigService<ConfigType>; } as unknown as ConfigService<ConfigType, true>;
service = new UrlResolverService(mockConfigService); service = new UrlResolverService(mockConfigService);
}); });

View File

@@ -0,0 +1,33 @@
// Utils related to processing operations
// e.g. parallel processing, safe processing, etc.
/**
* Creates a function that runs a given function and catches any errors.
* If an error is caught, it is passed to the `onError` function.
*
* @param onError - The function to call if an error is caught.
* @returns A function that runs the given function and catches any errors.
* @example
* const errors: Error[] = [];
* const doSafely = makeSafeRunner((error) => {
* if (error instanceof Error) {
* errors.push(error);
* } else {
* this.logger.warn(error, 'Uncaught error in network resolver');
* }
* });
*
* doSafely(() => {
* JSON.parse(aFile);
* throw new Error('test');
* });
*/
export function makeSafeRunner(onError: (error: unknown) => void) {
return <T>(fn: () => T) => {
try {
return fn();
} catch (error) {
onError(error);
}
};
}