mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
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:
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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".
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
33
packages/unraid-shared/src/util/processing.ts
Normal file
33
packages/unraid-shared/src/util/processing.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user