initial commit
This commit is contained in:
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);
|
||||
}
|
||||
Reference in new issue
Block a user