initial commit

This commit is contained in:
SowinskiBraeden committed 2026-07-05 16:54:59 -07:00
commit ce8f719a05
106 files changed
+24584

No files matched your search

@@ -0,0 +1,66 @@
import { ApiError } from '../../lib/errors.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
const CONFIG_MAX_BYTES = 256 * 1024;
export function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Shared read-modify-write access to the server's config.json: size-guarded
* download + parse, and a write path that backs the previous content up to
* `<config>.bak` and verifies the upload by downloading it again. Callers
* mutate only their own keys on the parsed document so everything else in the
* file passes through untouched.
*/
export class ConfigFileGateway {
constructor(
private readonly provider: GameServerProvider,
readonly configPath: string,
) {}
async download(
providerServerId: string,
): Promise<{ raw: string; root: Record<string, unknown> }> {
const file = await this.provider.downloadTextFile(
providerServerId,
this.configPath,
CONFIG_MAX_BYTES,
);
if (file.truncated) {
throw ApiError.upstream('Server config.json is unexpectedly large; refusing to modify it.');
}
let parsed: unknown;
try {
parsed = JSON.parse(file.content.replace(/^\uFEFF/, ''));
} catch {
throw ApiError.upstream('Server config.json is not valid JSON.');
}
const root = asRecord(parsed);
if (!root || !asRecord(root.game)) {
throw ApiError.upstream('Server config.json has no "game" section; refusing to modify it.');
}
return { raw: file.content, root };
}
/**
* Backs up `previousRaw`, writes the mutated document, downloads it again
* and hands the verified parsed result to `verify` (throw there to fail).
*/
async write(
providerServerId: string,
root: Record<string, unknown>,
previousRaw: string,
verify: (readBack: Record<string, unknown>) => void,
): Promise<Record<string, unknown>> {
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, previousRaw);
const serialized = `${JSON.stringify(root, null, 4)}\n`;
await this.provider.writeTextFile(providerServerId, this.configPath, serialized);
const readBack = await this.download(providerServerId);
verify(readBack.root);
return readBack.root;
}
}
@@ -0,0 +1,68 @@
import type { ReforgerServerConfig } from '@reforger-panel/shared';
import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerRecord, ServerService } from '../servers/server-service.js';
import { parseReforgerConfigJson } from './reforger-config-file.js';
const CONFIG_MAX_BYTES = 256 * 1024;
export type ConfigSyncResult = {
serverName: string;
maxPlayers: number;
config: ReforgerServerConfig;
};
/**
* Reads the server's real config.json (via the provider, read-only) and keeps
* the server row's name/maxPlayers in line with what the server actually
* runs. Configuration is always served live; no revision history is kept.
*/
export class ConfigSyncService {
constructor(
private readonly provider: GameServerProvider,
private readonly servers: ServerService,
private readonly logger: Logger,
private readonly configPath: string,
) {}
async getLiveConfig(server: ServerRecord): Promise<ReforgerServerConfig> {
const providerServerId = server.pterodactylServerId ?? server.slug;
const file = await this.provider.downloadTextFile(
providerServerId,
this.configPath,
CONFIG_MAX_BYTES,
);
return parseReforgerConfigJson(file.content);
}
async sync(server: ServerRecord): Promise<ConfigSyncResult> {
const config = await this.getLiveConfig(server);
const maxPlayers = config.maxPlayers > 0 ? config.maxPlayers : null;
if (server.name !== config.serverName || server.maxPlayers !== maxPlayers) {
await this.servers.updateServerInfo(server.id, {
name: config.serverName,
maxPlayers,
});
this.logger.info(
{ serverId: server.id, serverName: config.serverName, maxPlayers },
'server info updated from config.json',
);
}
return { serverName: config.serverName, maxPlayers: config.maxPlayers, config };
}
/** Sync all servers, logging failures instead of throwing (for the poll loop). */
async syncAllQuietly(): Promise<void> {
const servers = await this.servers.listServers();
for (const server of servers) {
try {
await this.sync(server);
} catch (error) {
this.logger.warn(
{ serverId: server.id, error: sanitizeErrorMessage(error) },
'config sync failed',
);
}
}
}
}
@@ -0,0 +1,96 @@
import type {
ReforgerConfigMod,
ServerModsResponse,
UpdateModsResult,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type { Logger } from '../../lib/logger.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
function readMods(root: Record<string, unknown>): ReforgerConfigMod[] {
const game = asRecord(root.game);
if (!game || !Array.isArray(game.mods)) return [];
return game.mods
.map((entry): ReforgerConfigMod | null => {
const mod = asRecord(entry);
const modId = typeof mod?.modId === 'string' ? mod.modId : '';
if (!modId) return null;
const name = typeof mod?.name === 'string' && mod.name ? mod.name : undefined;
const version = typeof mod?.version === 'string' && mod.version ? mod.version : undefined;
return {
modId,
...(name ? { name } : {}),
...(version ? { version } : {}),
};
})
.filter((mod): mod is ReforgerConfigMod => mod !== null);
}
/**
* Manages the `game.mods` array of the server's real config.json through the
* shared ConfigFileGateway (backup + read-back verification; all other config
* fields pass through untouched). Changes apply on the next server restart.
*/
export class ServerModsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly logger: Logger,
) {}
private providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
async getMods(server: ServerRecord): Promise<ServerModsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { mods: readMods(root), fetchedAt: new Date().toISOString() };
}
async setMods(server: ServerRecord, mods: ReforgerConfigMod[]): Promise<UpdateModsResult> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const previous = readMods(root);
const previousIds = new Set(previous.map((mod) => mod.modId.toUpperCase()));
const nextIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
const added = [...nextIds].filter((id) => !previousIds.has(id)).length;
const removed = [...previousIds].filter((id) => !nextIds.has(id)).length;
const game = asRecord(root.game)!;
game.mods = mods.map((mod) => ({
modId: mod.modId.toUpperCase(),
...(mod.name ? { name: mod.name } : {}),
...(mod.version ? { version: mod.version } : {}),
}));
const verified = await this.gateway.write(providerId, root, raw, (readBack) => {
const verifyIds = readMods(readBack)
.map((mod) => mod.modId.toUpperCase())
.sort();
if (JSON.stringify(verifyIds) !== JSON.stringify([...nextIds].sort())) {
throw ApiError.upstream(
'Config write verification failed — the file on the server does not match. Check config.json.bak.',
);
}
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
this.logger.info({ serverId: server.id, added, removed }, 'server mods updated');
return {
mods: readMods(verified),
fetchedAt: new Date().toISOString(),
added,
removed,
requiresRestart: true,
};
}
}
@@ -0,0 +1,145 @@
import type {
PerformanceSettings,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
} from '@reforger-panel/shared';
import type { Logger } from '../../lib/logger.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
/** Where each performance field lives inside config.json. */
const FIELD_LOCATIONS: Record<
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string]
> = {
scenarioId: ['game', 'scenarioId'],
maxPlayers: ['game', 'maxPlayers'],
serverMaxViewDistance: ['gameProperties', 'serverMaxViewDistance'],
networkViewDistance: ['gameProperties', 'networkViewDistance'],
serverMinGrassDistance: ['gameProperties', 'serverMinGrassDistance'],
disableThirdPerson: ['gameProperties', 'disableThirdPerson'],
fastValidation: ['gameProperties', 'fastValidation'],
battlEye: ['gameProperties', 'battlEye'],
aiLimit: ['operating', 'aiLimit'],
playerSaveTime: ['operating', 'playerSaveTime'],
slotReservationTimeout: ['operating', 'slotReservationTimeout'],
lobbyPlayerSynchronise: ['operating', 'lobbyPlayerSynchronise'],
};
function sectionFor(
root: Record<string, unknown>,
section: 'game' | 'gameProperties' | 'operating',
createMissing: boolean,
): Record<string, unknown> | null {
const game = asRecord(root.game)!;
if (section === 'game') return game;
if (section === 'gameProperties') {
let props = asRecord(game.gameProperties);
if (!props && createMissing) {
props = {};
game.gameProperties = props;
}
return props;
}
let operating = asRecord(root.operating);
if (!operating && createMissing) {
operating = {};
root.operating = operating;
}
return operating;
}
export function readPerformanceSettings(root: Record<string, unknown>): PerformanceSettings {
const result = {} as Record<keyof PerformanceSettings, number | boolean | string | null>;
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
const container = sectionFor(root, section, false);
const value = container?.[key];
const validType =
field === 'scenarioId'
? typeof value === 'string'
: typeof value === 'number' || typeof value === 'boolean';
result[field] = validType ? (value as number | boolean | string) : null;
}
return result as PerformanceSettings;
}
/**
* Edits the performance-related keys of the live config.json. A `null` value
* removes the key from the file entirely so the game's own default applies —
* network/identity fields (bind address, ports, passwords, rcon…) are never
* touched by this service.
*/
export class PerformanceSettingsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly logger: Logger,
) {}
private providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
async get(server: ServerRecord): Promise<PerformanceSettingsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { settings: readPerformanceSettings(root), fetchedAt: new Date().toISOString() };
}
async update(
server: ServerRecord,
patch: PerformanceSettingsPatch,
): Promise<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: true }> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const before = readPerformanceSettings(root);
const changedFields: string[] = [];
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
if (!(field in patch)) continue; // untouched fields stay as-is
const next = patch[field] as number | boolean | string | null;
if (before[field] === next) continue;
changedFields.push(field);
if (next === null) {
const container = sectionFor(root, section, false);
if (container) delete container[key];
} else {
const container = sectionFor(root, section, true)!;
container[key] = next;
}
}
if (changedFields.length > 0) {
await this.gateway.write(providerId, root, raw, (readBack) => {
const after = readPerformanceSettings(readBack);
for (const field of changedFields) {
if (
after[field as keyof PerformanceSettings] !== patch[field as keyof PerformanceSettings]
) {
throw new Error('Config write verification failed. Check config.json.bak.');
}
}
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
}
return {
settings: { ...before, ...patch } as PerformanceSettings,
fetchedAt: new Date().toISOString(),
changedFields,
requiresRestart: true,
};
}
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { parseReforgerConfigJson } from './reforger-config-file.js';
import { ApiError } from '../../lib/errors.js';
// Shape from the Reforger dedicated-server docs / typical Pterodactyl egg output.
const REAL_SHAPE = {
bindAddress: '0.0.0.0',
bindPort: 2001,
publicAddress: '',
publicPort: 2001,
a2s: { address: '0.0.0.0', port: 17777 },
rcon: { address: '127.0.0.1', port: 19999, password: 'hunter2', permission: 'admin' },
game: {
name: 'DazzledCorp Training Grounds',
password: '',
passwordAdmin: 'secret',
admins: ['76561198000000000'],
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
maxPlayers: 16,
visible: true,
crossPlatform: true,
supportedPlatforms: ['PLATFORM_PC', 'PLATFORM_XBL'],
gameProperties: {
serverMaxViewDistance: 2500,
serverMinGrassDistance: 50,
networkViewDistance: 1000,
disableThirdPerson: true,
fastValidation: true,
battlEye: true,
VONDisableUI: false,
},
mods: [
{ modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5' },
],
},
operating: { lobbyPlayerSynchronise: true, aiLimit: -1, playerSaveTime: 120 },
};
describe('parseReforgerConfigJson', () => {
it('maps a real-shaped config.json into the panel model', () => {
const config = parseReforgerConfigJson(JSON.stringify(REAL_SHAPE));
expect(config).toEqual({
serverName: 'DazzledCorp Training Grounds',
maxPlayers: 16,
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
aiLimit: -1,
serverMaxViewDistance: 2500,
networkViewDistance: 1000,
crossPlatform: true,
disableThirdPerson: true,
mods: [
{ modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5' },
],
});
});
it('never includes credentials from the config file in the mapped model', () => {
const json = JSON.stringify(parseReforgerConfigJson(JSON.stringify(REAL_SHAPE)));
expect(json).not.toContain('hunter2');
expect(json).not.toContain('secret');
});
it('tolerates missing sections with neutral defaults', () => {
const config = parseReforgerConfigJson('{"game":{"name":"Bare"}}');
expect(config.serverName).toBe('Bare');
expect(config.maxPlayers).toBe(0);
expect(config.aiLimit).toBe(-1);
expect(config.mods).toEqual([]);
});
it('rejects invalid JSON with a sanitized upstream error', () => {
expect(() => parseReforgerConfigJson('not json {')).toThrow(ApiError);
});
});
@@ -0,0 +1,74 @@
import type { ReforgerServerConfig } from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
/**
* Maps a real Reforger server `config.json` (the file the dedicated server
* runs with, documented at
* https://community.bistudio.com/wiki/Arma_Reforger:Server_Config) into the
* panel's internal config model. Mapping is defensive: missing or oddly-typed
* fields fall back to neutral defaults instead of failing the sync.
*/
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function str(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function num(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function bool(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
export function mapReforgerConfig(raw: unknown): ReforgerServerConfig {
const root = record(raw);
const game = record(root.game);
const gameProperties = record(game.gameProperties);
const operating = record(root.operating);
const mods = Array.isArray(game.mods)
? game.mods
.map((entry) => {
const mod = record(entry);
const modId = str(mod.modId);
if (!modId) return null;
return {
modId,
name: str(mod.name) || undefined,
version: str(mod.version) || undefined,
};
})
.filter((mod): mod is NonNullable<typeof mod> => mod !== null)
: [];
return {
serverName: str(game.name, 'Unnamed server'),
maxPlayers: num(game.maxPlayers, 0),
scenarioId: str(game.scenarioId),
// -1 means "no limit" in Reforger's operating.aiLimit.
aiLimit: num(operating.aiLimit, -1),
serverMaxViewDistance: num(gameProperties.serverMaxViewDistance, 0),
networkViewDistance: num(gameProperties.networkViewDistance, 0),
crossPlatform: bool(game.crossPlatform, false),
disableThirdPerson: bool(gameProperties.disableThirdPerson, false),
mods,
};
}
export function parseReforgerConfigJson(content: string): ReforgerServerConfig {
const text = content.replace(/^\uFEFF/, '').trim();
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw ApiError.upstream('Server config.json is not valid JSON.');
}
return mapReforgerConfig(raw);
}