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
7 changes: 7 additions & 0 deletions apps/core/src/modules/process/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { txAdminCompat } from '../txadmin/compat';

const mockGetHistory = mock(() => []);
const mockBufferPush = mock(() => {});
const mockSettingsGet = mock(() => undefined);

const mockRegenerateApiToken = mock(() => {});
const mockGetSystemValues = mock(() => ({
Expand All @@ -39,6 +40,12 @@ const mockGetFxServerValues = mock(() => ({
serverConfigFile: 'server.cfg',
}));

mock.module('@fxmanager/database', () => ({
repo: {
settings: { get: mockSettingsGet },
},
}));

// Use spyOn to safely intercept instances without corrupting Bun's global module cache
const getInstanceSpy = spyOn(ConfigManager, 'getInstance').mockReturnValue({
regenerateApiToken: mockRegenerateApiToken,
Expand Down
5 changes: 5 additions & 0 deletions apps/core/src/modules/process/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { disconnectManager } from '../disconnect/manager';
import { sessionManager } from '../session/manager';
import { gameManager } from '../game/manager';
import { txAdminCompat } from '../txadmin/compat';
import { repo } from '@fxmanager/database';

const STARTUP_STALL_MS = 90_000;
const KILL_GRACE_MS = 5_000;
Expand Down Expand Up @@ -79,6 +80,10 @@ export class ProcessManager {
'+add_convar_permission', 'fxManager', 'read', 'api-port',
];

const configuredArgs =
repo.settings.get('fxserver.startupArguments')?.split(' ') ?? [];
args.push(...configuredArgs);

console.log(`[core] Starting fxServer`);

this.injectConsoleLine({
Expand Down
22 changes: 21 additions & 1 deletion apps/core/src/routes/api/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import type {
SettingsKey,
SettingsScope,
} from '@fxmanager/shared/types';
import { PermissionManager, canWriteSetting } from '@fxmanager/shared/utils';
import {
PermissionManager,
canWriteSetting,
validateStartupArguments,
} from '@fxmanager/shared/utils';
import {
SETTINGS_KEYS,
SETTINGS_SCOPES,
Expand All @@ -18,6 +22,17 @@ import {
import { repo } from '@fxmanager/database';
import { restartScheduler } from '../../../modules/schedule/manager';

interface HookResult {
valid: boolean;
[key: string]: unknown;
}

const SETTINGS_HOOKS: Partial<
Record<SettingsKey, (value: string) => HookResult>
> = {
'fxserver.startupArguments': validateStartupArguments,
};

const SettingsEndpoints: RouteModule['handler'] = async (
fastify,
{ pm, gm },
Expand Down Expand Up @@ -80,6 +95,11 @@ const SettingsEndpoints: RouteModule['handler'] = async (
throw new Error('Unauthorized');
}

const hook = SETTINGS_HOOKS[key as SettingsKey];
if (hook && !hook(value).valid) {
throw new Error('Invalid value');
}

repo.settings.set(key, value);

const logValue = SETTINGS_SENSITIVE_KEYS.includes(key as SettingsKey)
Expand Down
62 changes: 57 additions & 5 deletions apps/webpanel/src/pages/settings/tabs/fxserver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,50 @@ import { SETTINGS_DEFAULTS } from '@fxmanager/shared/constants';
import type { SettingsTabProps } from '@/types/settings';
import { Separator } from '@fxmanager/ui/components/separator';
import { Input } from '@fxmanager/ui/components/input';
import { Field, FieldDescription } from '@fxmanager/ui/components/field';
import { validateStartupArguments } from '@fxmanager/shared/utils';
import { useState } from 'react';

function blur(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== 'Enter') return;
event.currentTarget.blur();
}

function StartupArgumentsField({
value: defaultValue,
disabled,
onChange,
}: {
value: string;
disabled: boolean;
onChange: (value: string) => void;
}) {
const [error, setError] = useState('');

return (
<Field data-invalid={error !== ''} className="gap-0.5">
<Input
defaultValue={defaultValue}
disabled={disabled}
placeholder={'+exec server.cfg'}
onBlur={(event) => {
const value = event.currentTarget.value.trim();
const validation = validateStartupArguments(value);

if (validation.valid !== true) {
setError(`"${validation.argument}" is not allowed here.`);
return;
}

setError('');
onChange(value);
}}
onKeyDown={blur}
/>
<FieldDescription>{error}</FieldDescription>
</Field>
);
}

export default function FXServerTab({
data,
Expand All @@ -18,6 +62,7 @@ export default function FXServerTab({
}: SettingsTabProps<'fxserver'>) {
const onesync =
data['fxserver.onesync'] ?? SETTINGS_DEFAULTS['fxserver.onesync'];
const startupArguments = data['fxserver.startupArguments'] ?? '';
const serverDataPath =
data['fxserver.serverDataPath'] ??
SETTINGS_DEFAULTS['fxserver.serverDataPath'];
Expand All @@ -28,11 +73,6 @@ export default function FXServerTab({
data['fxserver.serverConfigPath'] ??
SETTINGS_DEFAULTS['fxserver.serverConfigPath'];

function blur(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== 'Enter') return;
event.currentTarget.blur();
}

return (
<div className="space-y-4">
<SettingRow label="OneSync">
Expand All @@ -52,6 +92,18 @@ export default function FXServerTab({
</Select>
</SettingRow>

<SettingRow label="Startup Arguments">
<StartupArgumentsField
disabled={disabled}
value={startupArguments}
onChange={(value) => {
if (value === startupArguments) return;

onChange('fxserver.startupArguments', value);
}}
/>
</SettingRow>

<Separator />

<SettingRow label="Server Executable Path">
Expand Down
22 changes: 21 additions & 1 deletion packages/shared/src/constants/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import type { SettingsKey, SettingsKeysByScope } from '../types';

export const SETTINGS_SCOPES = {
general: [],
fxserver: ['onesync', 'executablePath', 'serverDataPath', 'serverConfigPath'],
fxserver: [
'onesync',
'startupArguments',
'executablePath',
'serverDataPath',
'serverConfigPath',
],
whitelist: ['mode', 'discordBotToken', 'discordGuildId', 'discordRoleIds'],
restarts: ['enabled', 'times'],
} as const;
Expand All @@ -29,7 +35,21 @@ export const SETTINGS_SENSITIVE_KEYS: SettingsKey[] = [
];

export const SETTINGS_MASTER_ONLY_KEYS: SettingsKey[] = [
'fxserver.startupArguments',
'fxserver.executablePath',
'fxserver.serverDataPath',
'fxserver.serverConfigPath',
];

export const RESTRICTED_STARTUP_ARGUMENTS: RegExp[] = [
/^\+set\s+onesync\b/i,
/^\+set\s+resource-api-token\b/i,
/^\+set\s+api-port\b/i,
/^\+set\s+citizen-dir\b/i,
/^\+(ensure|start|stop|restart)\s+fxManager\b/i,

/^\+add_convar_permission\s+\S+\s+\S+\s+resource-api-token\b/i,
/^\+add_convar_permission\s+\S+\s+\S+\s+api-port\b/i,

/^--library-path\s+\S+/i,
];
1 change: 1 addition & 0 deletions packages/shared/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './group-slug';
export * from './permissions';
export * from './resource-name';
export * from './settings-access';
export * from './startup-arguments';
28 changes: 28 additions & 0 deletions packages/shared/src/utils/startup-arguments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'bun:test';
import { validateStartupArguments } from './startup-arguments';

describe('validateStartupArguments()', () => {
it('should return true for valid arguments', () => {
expect(validateStartupArguments('+svgui --trace-warnings')).toEqual({
valid: true,
});
expect(validateStartupArguments('+exec somecfg.cfg')).toEqual({
valid: true,
});
});

it('should return the first restricted value for invalid arguments', () => {
expect(
validateStartupArguments('+exec somecfg.cfg +set onesync on'),
).toEqual({ valid: false, argument: '+set onesync on' });
expect(
validateStartupArguments('+stop fxManager +set api-port 40120'),
).toEqual({ valid: false, argument: '+stop fxManager' });
expect(
validateStartupArguments('--library-path /home/user/somefolder'),
).toEqual({
valid: false,
argument: '--library-path /home/user/somefolder',
});
});
});
23 changes: 23 additions & 0 deletions packages/shared/src/utils/startup-arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { RESTRICTED_STARTUP_ARGUMENTS } from '../constants';

export type ValidationResult =
| { valid: true }
| { valid: false; argument: string };

/**
* Validates the given string for restricted startup arguments and, if invalid, returns the first invalid argument.
* @param value String of arguments to validate.
* @returns `true` if valid, otherwise the first invalid argument.
*/
export function validateStartupArguments(value: string): ValidationResult {
if (!value) return { valid: true };

const args = value.match(/(?:\+|--)\S+(?:\s+(?!(?:\+|--))\S+)*/g) ?? [];
const restricted = args.find((arg) =>
RESTRICTED_STARTUP_ARGUMENTS.some((patt) => patt.test(arg)),
);

if (!restricted) return { valid: true };

return { valid: false, argument: restricted };
}
Loading