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,342 @@
import type {
RestartScheduleInput,
ServerScheduleSummary,
ServerStatus,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type {
DownloadableFile,
GameServerProvider,
ProviderServerResources,
ServerFileEntry,
} from './types.js';
const START_DELAY_MS = 4_000;
const STOP_DELAY_MS = 2_500;
function pad(n: number, width = 2): string {
return String(n).padStart(width, '0');
}
function timeOfDay(date: Date): string {
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(
date.getUTCMilliseconds(),
3,
)}`;
}
function dateStamp(date: Date): string {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
}
/**
* Builds a plausible Reforger console.log covering the last ~50 minutes:
* server start, four connects, one disconnect. Line shapes mirror the real
* Enfusion/BattlEye output the parser targets (see parser/patterns.ts).
*/
export function buildMockConsoleLog(now: Date = new Date()): string {
const at = (minutesAgo: number, driftSeconds = 0) =>
new Date(now.getTime() - minutesAgo * 60_000 + driftSeconds * 1000);
const started = at(50);
const lines = [
`------------------------------------------------------------------------------------------------`,
`Log started ${dateStamp(started)} ${timeOfDay(started).slice(0, 8)}`,
`${timeOfDay(started)} ENGINE : Enfusion engine build: 1.3.0.42 (mock)`,
`${timeOfDay(at(50, 4))} DEFAULT : Loading world.`,
`${timeOfDay(at(49))} DEFAULT : Game successfully created.`,
`${timeOfDay(at(48))} NETWORK : Server is ready to accept connections`,
`${timeOfDay(at(44))} DEFAULT : BattlEye Server: 'Player #1 Braeden (10.66.4.21:50241) connected'`,
`${timeOfDay(at(44, 2))} DEFAULT : BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'`,
`${timeOfDay(at(38))} DEFAULT : BattlEye Server: 'Player #2 Sable (10.66.4.30:61022) connected'`,
`${timeOfDay(at(38, 1))} DEFAULT : BattlEye Server: 'Player #2 Sable - GUID: 41c7de9a5b02f311'`,
`${timeOfDay(at(31))} DEFAULT : BattlEye Server: 'Player #3 Kestrel (10.66.4.87:49155) connected'`,
`${timeOfDay(at(27))} SCRIPT : SCR_BaseGameMode: match state changed`,
`${timeOfDay(at(22))} DEFAULT : BattlEye Server: 'Player #4 Moss (10.66.4.44:51811) connected'`,
`${timeOfDay(at(22, 1))} DEFAULT : BattlEye Server: 'Player #4 Moss - GUID: c31009e2ab77d514'`,
`${timeOfDay(at(9))} DEFAULT : BattlEye Server: 'Player #3 Kestrel disconnected'`,
`${timeOfDay(at(2))} NETWORK : ### Connection stats`,
'',
];
return lines.join('\n');
}
/**
* In-process stand-in for Pterodactyl so the whole panel runs without
* credentials. Power actions transition through starting/stopping states, and
* the mock file system serves a generated console.log fixture.
*/
export class MockGameServerProvider implements GameServerProvider {
private status: ServerStatus = 'online';
private startedAt = Date.now() - 50 * 60_000;
private transitionTimer: ReturnType<typeof setTimeout> | null = null;
private readonly logContent: string;
private readonly logPath: string;
private readonly configPath: string;
private configContent: string;
private nextScheduleId = 2;
private schedules: ServerScheduleSummary[] = [
{
id: '1',
name: 'Daily restart',
isActive: true,
onlyWhenOnline: true,
minute: '0',
hour: '9',
dayOfMonth: '*',
month: '*',
dayOfWeek: '*',
nextRunAt: null,
lastRunAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tasks: [
{
id: '1',
action: 'power',
payload: 'restart',
timeOffsetSeconds: 0,
continueOnFailure: false,
},
],
},
];
/** Files written via writeTextFile (e.g. config.json backups). */
readonly writtenFiles = new Map<string, string>();
constructor(options: { logPath?: string; configPath?: string; now?: Date } = {}) {
this.logPath = options.logPath ?? '/profile/logs/console.log';
this.logContent = buildMockConsoleLog(options.now ?? new Date());
this.configPath = options.configPath ?? '/config.json';
// Shape mirrors a real Reforger dedicated-server config.json.
this.configContent = JSON.stringify(
{
bindAddress: '0.0.0.0',
bindPort: 2001,
game: {
name: 'Mock Reforger Server',
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
maxPlayers: 16,
crossPlatform: true,
gameProperties: {
serverMaxViewDistance: 2500,
networkViewDistance: 1500,
disableThirdPerson: false,
},
mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }],
},
operating: { aiLimit: 40 },
},
null,
2,
);
}
dispose() {
if (this.transitionTimer) clearTimeout(this.transitionTimer);
}
private transition(to: ServerStatus, after: number, thenTo: ServerStatus) {
this.status = to;
if (this.transitionTimer) clearTimeout(this.transitionTimer);
this.transitionTimer = setTimeout(() => {
this.status = thenTo;
if (thenTo === 'online') this.startedAt = Date.now();
this.transitionTimer = null;
}, after);
this.transitionTimer.unref?.();
}
async getServerStatus(): Promise<ServerStatus> {
return this.status;
}
async getServerResources(): Promise<ProviderServerResources> {
const online = this.status === 'online';
const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread;
return {
status: this.status,
cpuPercent: online ? Math.max(2, wobble(38, 14)) : 0,
cpuLimitPercent: 400,
memoryBytes: online ? Math.round(wobble(5.1, 0.6) * 1024 ** 3) : 0,
memoryLimitBytes: 8 * 1024 ** 3,
diskBytes: Math.round(22.4 * 1024 ** 3),
diskLimitBytes: 40 * 1024 ** 3,
networkRxBytes: online ? Math.round(wobble(9.2, 1.5) * 1024 ** 2) : 0,
networkTxBytes: online ? Math.round(wobble(26.8, 4) * 1024 ** 2) : 0,
uptimeMs: online ? Date.now() - this.startedAt : 0,
};
}
async startServer(): Promise<void> {
if (this.status === 'online') return;
this.transition('starting', START_DELAY_MS, 'online');
}
async stopServer(): Promise<void> {
if (this.status === 'offline') return;
this.transition('stopping', STOP_DELAY_MS, 'offline');
}
async restartServer(): Promise<void> {
this.transition('stopping', STOP_DELAY_MS, 'starting');
setTimeout(() => {
if (this.status === 'starting') {
this.status = 'online';
this.startedAt = Date.now();
}
}, STOP_DELAY_MS + START_DELAY_MS).unref?.();
}
async listFiles(_serverId: string, directory: string): Promise<ServerFileEntry[]> {
const dir = directory.replace(/\/$/, '') || '/';
const logDir = this.logPath.slice(0, this.logPath.lastIndexOf('/')) || '/';
if (dir !== logDir) return [];
return [
{
name: this.logPath.slice(this.logPath.lastIndexOf('/') + 1),
isFile: true,
sizeBytes: Buffer.byteLength(this.logContent),
modifiedAt: new Date(),
},
];
}
async getFileDownloadUrl(): Promise<string> {
throw ApiError.notConfigured('Direct downloads are not available in mock mode.');
}
async writeTextFile(_serverId: string, path: string, content: string): Promise<void> {
this.writtenFiles.set(path, content);
if (path === this.configPath) {
this.configContent = content;
}
}
private startupVariables = [
{
name: 'Server Password',
description: 'Password required to join the server.',
envVariable: 'SERVER_PASSWORD',
serverValue: '',
defaultValue: '',
isEditable: true,
},
{
name: 'Admin Password',
description: 'Password for in-game admin access.',
envVariable: 'ADMIN_PASSWORD',
serverValue: 'mock-admin-pass',
defaultValue: '',
isEditable: true,
},
{
name: 'App ID',
description: 'Steam application id (managed by the egg).',
envVariable: 'SRCDS_APPID',
serverValue: '1874900',
defaultValue: '1874900',
isEditable: false,
},
];
async listStartupVariables() {
return this.startupVariables.map((v) => ({ ...v }));
}
async updateStartupVariable(_serverId: string, envVariable: string, value: string) {
const variable = this.startupVariables.find((v) => v.envVariable === envVariable);
if (!variable || !variable.isEditable) {
throw ApiError.validation('This startup variable cannot be edited.');
}
variable.serverValue = value;
}
async listSchedules(): Promise<ServerScheduleSummary[]> {
return this.schedules.map((schedule) => ({
...schedule,
tasks: schedule.tasks.map((task) => ({ ...task })),
}));
}
async createRestartSchedule(
_serverId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary> {
const now = new Date().toISOString();
const schedule: ServerScheduleSummary = {
id: String(this.nextScheduleId++),
name: input.name,
isActive: input.isActive,
onlyWhenOnline: input.onlyWhenOnline,
minute: String(input.minute),
hour: String(input.hour),
dayOfMonth: '*',
month: '*',
dayOfWeek: input.dayOfWeek,
nextRunAt: null,
lastRunAt: null,
createdAt: now,
updatedAt: now,
tasks: [
{
id: String(this.nextScheduleId++),
action: 'power',
payload: 'restart',
timeOffsetSeconds: 0,
continueOnFailure: false,
},
],
};
this.schedules.unshift(schedule);
return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) };
}
async updateRestartSchedule(
_serverId: string,
scheduleId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary> {
const schedule = this.schedules.find((s) => s.id === scheduleId);
if (!schedule) throw ApiError.notFound('Schedule not found.');
schedule.name = input.name;
schedule.isActive = input.isActive;
schedule.onlyWhenOnline = input.onlyWhenOnline;
schedule.minute = String(input.minute);
schedule.hour = String(input.hour);
schedule.dayOfWeek = input.dayOfWeek;
schedule.updatedAt = new Date().toISOString();
return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) };
}
async deleteSchedule(_serverId: string, scheduleId: string): Promise<void> {
this.schedules = this.schedules.filter((schedule) => schedule.id !== scheduleId);
}
async downloadTextFile(
_serverId: string,
path: string,
maxBytes = 2 * 1024 * 1024,
): Promise<DownloadableFile> {
const content =
path === this.logPath
? this.logContent
: path === this.configPath
? this.configContent
: null;
if (content === null) {
throw ApiError.notFound(`Mock file not found: ${path}`);
}
const buffer = Buffer.from(content, 'utf8');
const trimmed =
buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer;
return {
path,
content: trimmed.toString('utf8'),
totalSizeBytes: buffer.byteLength,
contentStartOffset: buffer.byteLength - trimmed.byteLength,
truncated: trimmed.byteLength < buffer.byteLength,
};
}
}
@@ -0,0 +1,483 @@
import type {
RestartScheduleInput,
ServerScheduleSummary,
ServerScheduleTask,
ServerStatus,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type {
DownloadableFile,
GameServerProvider,
ProviderServerResources,
ServerFileEntry,
} from './types.js';
const DEFAULT_TIMEOUT_MS = 10_000;
const DOWNLOAD_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
type PterodactylOptions = {
baseUrl: string;
apiKey: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
};
type PterodactylScheduleResponse = {
data?: {
attributes?: PterodactylScheduleAttributes;
};
};
type PterodactylScheduleAttributes = {
id?: number | string;
name?: string;
cron?: {
minute?: string;
hour?: string;
day_of_month?: string;
month?: string;
day_of_week?: string;
};
is_active?: boolean;
only_when_online?: boolean;
last_run_at?: string | null;
next_run_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
relationships?: {
tasks?: {
data?: {
attributes?: {
id?: number | string;
action?: string;
payload?: string;
time_offset?: number;
continue_on_failure?: boolean;
};
}[];
};
};
};
function mapState(state: string): ServerStatus {
switch (state) {
case 'running':
return 'online';
case 'offline':
return 'offline';
case 'starting':
return 'starting';
case 'stopping':
return 'stopping';
default:
return 'unknown';
}
}
/**
* Pterodactyl Client API provider. Uses only client-scoped endpoints (status,
* resources, power, read-only file access). Errors are sanitized: they carry
* the endpoint category and HTTP status, never the API key or full URL.
*/
export class PterodactylProvider implements GameServerProvider {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
private limitsCache = new Map<
string,
{
cpuLimitPercent: number | null;
memoryLimitBytes: number | null;
diskLimitBytes: number | null;
fetchedAt: number;
}
>();
constructor(options: PterodactylOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.fetchImpl = options.fetchImpl ?? fetch;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}
private async request<T = unknown>(
label: string,
path: string,
init: { method?: string; body?: unknown; timeoutMs?: number; raw?: boolean } = {},
): Promise<T> {
const url = `${this.baseUrl}/api/client${path}`;
let response: Response;
try {
response = await this.fetchImpl(url, {
method: init.method ?? 'GET',
headers: {
Authorization: `Bearer ${this.apiKey}`,
Accept: 'application/json',
...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
signal: AbortSignal.timeout(init.timeoutMs ?? this.timeoutMs),
});
} catch (error) {
const reason =
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
throw ApiError.upstream(`Pterodactyl request (${label}) ${reason}.`);
}
if (!response.ok) {
throw ApiError.upstream(`Pterodactyl request (${label}) returned HTTP ${response.status}.`);
}
if (init.raw) {
return (await response.text()) as T;
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
throw ApiError.upstream(`Pterodactyl request (${label}) returned invalid JSON.`);
}
}
private async getLimits(serverId: string) {
const cached = this.limitsCache.get(serverId);
if (cached && Date.now() - cached.fetchedAt < 5 * 60_000) return cached;
const data = await this.request<{
attributes?: { limits?: { cpu?: number; memory?: number; disk?: number } };
}>('server details', `/servers/${encodeURIComponent(serverId)}`);
const limits = data.attributes?.limits;
const entry = {
cpuLimitPercent: limits?.cpu && limits.cpu > 0 ? limits.cpu : null,
memoryLimitBytes: limits?.memory ? limits.memory * 1024 * 1024 : null,
diskLimitBytes: limits?.disk ? limits.disk * 1024 * 1024 : null,
fetchedAt: Date.now(),
};
this.limitsCache.set(serverId, entry);
return entry;
}
async getServerStatus(serverId: string): Promise<ServerStatus> {
const resources = await this.getServerResources(serverId);
return resources.status;
}
async getServerResources(serverId: string): Promise<ProviderServerResources> {
const data = await this.request<{
attributes?: {
current_state?: string;
resources?: {
memory_bytes?: number;
cpu_absolute?: number;
disk_bytes?: number;
network_rx_bytes?: number;
network_tx_bytes?: number;
uptime?: number;
};
};
}>('resources', `/servers/${encodeURIComponent(serverId)}/resources`);
const attrs = data.attributes ?? {};
const res = attrs.resources ?? {};
const limits = await this.getLimits(serverId).catch(() => ({
cpuLimitPercent: null,
memoryLimitBytes: null,
diskLimitBytes: null,
}));
return {
status: mapState(attrs.current_state ?? 'unknown'),
cpuPercent: res.cpu_absolute ?? 0,
cpuLimitPercent: limits.cpuLimitPercent,
memoryBytes: res.memory_bytes ?? 0,
memoryLimitBytes: limits.memoryLimitBytes,
diskBytes: res.disk_bytes ?? 0,
diskLimitBytes: limits.diskLimitBytes,
networkRxBytes: res.network_rx_bytes ?? 0,
networkTxBytes: res.network_tx_bytes ?? 0,
uptimeMs: res.uptime ?? 0,
};
}
private async sendPowerSignal(serverId: string, signal: 'start' | 'stop' | 'restart') {
await this.request(`power ${signal}`, `/servers/${encodeURIComponent(serverId)}/power`, {
method: 'POST',
body: { signal },
});
}
async startServer(serverId: string): Promise<void> {
await this.sendPowerSignal(serverId, 'start');
}
async stopServer(serverId: string): Promise<void> {
await this.sendPowerSignal(serverId, 'stop');
}
async restartServer(serverId: string): Promise<void> {
await this.sendPowerSignal(serverId, 'restart');
}
async listFiles(serverId: string, directory: string): Promise<ServerFileEntry[]> {
const data = await this.request<{
data?: {
attributes?: {
name?: string;
is_file?: boolean;
size?: number;
modified_at?: string;
};
}[];
}>(
'file list',
`/servers/${encodeURIComponent(serverId)}/files/list?directory=${encodeURIComponent(directory)}`,
);
return (data.data ?? []).map((entry) => ({
name: entry.attributes?.name ?? '',
isFile: entry.attributes?.is_file ?? false,
sizeBytes: entry.attributes?.size ?? 0,
modifiedAt: entry.attributes?.modified_at ? new Date(entry.attributes.modified_at) : null,
}));
}
async getFileDownloadUrl(serverId: string, path: string): Promise<string> {
const data = await this.request<{ attributes?: { url?: string } }>(
'file download url',
`/servers/${encodeURIComponent(serverId)}/files/download?file=${encodeURIComponent(path)}`,
);
const url = data.attributes?.url;
if (!url) {
throw ApiError.upstream('Pterodactyl did not return a download URL.');
}
return url;
}
/**
* Downloads a text file via the signed one-time download URL (streams and
* caps size, unlike files/contents which buffers whole files). When the file
* exceeds maxBytes the TAIL is kept — this method exists for log retrieval.
*/
async downloadTextFile(
serverId: string,
path: string,
maxBytes: number = DEFAULT_MAX_DOWNLOAD_BYTES,
): Promise<DownloadableFile> {
const stat = await this.statFile(serverId, path);
const url = await this.getFileDownloadUrl(serverId, path);
let response: Response;
try {
response = await this.fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
} catch (error) {
const reason =
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
throw ApiError.upstream(`Pterodactyl log download ${reason}.`);
}
if (!response.ok || !response.body) {
throw ApiError.upstream(`Pterodactyl log download returned HTTP ${response.status}.`);
}
// Stream and keep a rolling tail of at most maxBytes.
const chunks: Uint8Array[] = [];
let buffered = 0;
let discarded = 0;
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
buffered += value.byteLength;
while (buffered - (chunks[0]?.byteLength ?? 0) >= maxBytes && chunks.length > 1) {
const dropped = chunks.shift()!;
buffered -= dropped.byteLength;
discarded += dropped.byteLength;
}
}
let combined = Buffer.concat(chunks);
if (combined.byteLength > maxBytes) {
const trim = combined.byteLength - maxBytes;
combined = combined.subarray(trim);
discarded += trim;
}
return {
path,
content: combined.toString('utf8'),
totalSizeBytes: stat?.sizeBytes ?? discarded + combined.byteLength,
contentStartOffset: discarded,
truncated: discarded > 0,
};
}
async writeTextFile(serverId: string, path: string, content: string): Promise<void> {
const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent(serverId)}/files/write?file=${encodeURIComponent(path)}`;
let response: Response;
try {
response = await this.fetchImpl(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
Accept: 'application/json',
'Content-Type': 'text/plain',
},
body: content,
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (error) {
const reason =
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
throw ApiError.upstream(`Pterodactyl request (file write) ${reason}.`);
}
if (!response.ok) {
throw ApiError.upstream(`Pterodactyl request (file write) returned HTTP ${response.status}.`);
}
}
async listStartupVariables(serverId: string) {
const data = await this.request<{
data?: {
attributes?: {
name?: string;
description?: string;
env_variable?: string;
server_value?: string | null;
default_value?: string | null;
is_editable?: boolean;
};
}[];
}>('startup variables', `/servers/${encodeURIComponent(serverId)}/startup`);
return (data.data ?? []).map((entry) => ({
name: entry.attributes?.name ?? '',
description: entry.attributes?.description ?? '',
envVariable: entry.attributes?.env_variable ?? '',
serverValue: entry.attributes?.server_value ?? '',
defaultValue: entry.attributes?.default_value ?? '',
isEditable: entry.attributes?.is_editable ?? false,
}));
}
async updateStartupVariable(serverId: string, envVariable: string, value: string): Promise<void> {
await this.request(
'startup variable update',
`/servers/${encodeURIComponent(serverId)}/startup/variable`,
{ method: 'PUT', body: { key: envVariable, value } },
);
}
private mapSchedule(attributes: PterodactylScheduleAttributes): ServerScheduleSummary {
const cron = attributes.cron ?? {};
const tasks: ServerScheduleTask[] = (attributes.relationships?.tasks?.data ?? []).map(
(task) => ({
id: String(task.attributes?.id ?? ''),
action: task.attributes?.action ?? '',
payload: task.attributes?.payload ?? '',
timeOffsetSeconds: task.attributes?.time_offset ?? 0,
continueOnFailure: task.attributes?.continue_on_failure ?? false,
}),
);
return {
id: String(attributes.id ?? ''),
name: attributes.name ?? 'Untitled schedule',
isActive: attributes.is_active ?? false,
onlyWhenOnline: attributes.only_when_online ?? false,
minute: cron.minute ?? '*',
hour: cron.hour ?? '*',
dayOfMonth: cron.day_of_month ?? '*',
month: cron.month ?? '*',
dayOfWeek: cron.day_of_week ?? '*',
nextRunAt: attributes.next_run_at ?? null,
lastRunAt: attributes.last_run_at ?? null,
createdAt: attributes.created_at ?? null,
updatedAt: attributes.updated_at ?? null,
tasks,
};
}
private scheduleBody(input: RestartScheduleInput) {
return {
name: input.name,
is_active: input.isActive,
minute: String(input.minute),
hour: String(input.hour),
day_of_month: '*',
month: '*',
day_of_week: input.dayOfWeek,
only_when_online: input.onlyWhenOnline,
};
}
async listSchedules(serverId: string): Promise<ServerScheduleSummary[]> {
const data = await this.request<{
data?: { attributes?: PterodactylScheduleAttributes }[];
}>('schedules', `/servers/${encodeURIComponent(serverId)}/schedules?include=tasks`);
return (data.data ?? []).map((entry) => this.mapSchedule(entry.attributes ?? {}));
}
async createRestartSchedule(
serverId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary> {
const created = await this.request<PterodactylScheduleResponse>(
'schedule create',
`/servers/${encodeURIComponent(serverId)}/schedules`,
{ method: 'POST', body: this.scheduleBody(input) },
);
const schedule = this.mapSchedule(created.data?.attributes ?? {});
if (!schedule.id) {
throw ApiError.upstream('Pterodactyl did not return the created schedule id.');
}
await this.request(
'schedule task create',
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(schedule.id)}/tasks`,
{
method: 'POST',
body: {
action: 'power',
payload: 'restart',
time_offset: 0,
continue_on_failure: false,
},
},
);
const [withTasks] = (await this.listSchedules(serverId)).filter((s) => s.id === schedule.id);
return withTasks ?? schedule;
}
async updateRestartSchedule(
serverId: string,
scheduleId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary> {
const updated = await this.request<PterodactylScheduleResponse>(
'schedule update',
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(scheduleId)}`,
{ method: 'PATCH', body: this.scheduleBody(input) },
);
return this.mapSchedule(updated.data?.attributes ?? {});
}
async deleteSchedule(serverId: string, scheduleId: string): Promise<void> {
await this.request(
'schedule delete',
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(scheduleId)}`,
{ method: 'DELETE' },
);
}
private async statFile(
serverId: string,
path: string,
): Promise<{ sizeBytes: number; modifiedAt: Date | null } | null> {
const directory = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : '/';
const fileName = path.slice(path.lastIndexOf('/') + 1);
try {
const entries = await this.listFiles(serverId, directory);
const match = entries.find((entry) => entry.isFile && entry.name === fileName);
return match ? { sizeBytes: match.sizeBytes, modifiedAt: match.modifiedAt } : null;
} catch {
return null;
}
}
}
+86
View File
@@ -0,0 +1,86 @@
import type {
RestartScheduleInput,
ServerScheduleSummary,
ServerStatus,
} from '@reforger-panel/shared';
export type ProviderServerResources = {
status: ServerStatus;
cpuPercent: number;
cpuLimitPercent: number | null;
memoryBytes: number;
memoryLimitBytes: number | null;
diskBytes: number;
diskLimitBytes: number | null;
networkRxBytes: number;
networkTxBytes: number;
uptimeMs: number;
};
export type ServerFileEntry = {
name: string;
isFile: boolean;
sizeBytes: number;
modifiedAt: Date | null;
};
export type DownloadableFile = {
path: string;
content: string;
/** Size of the file on the remote, if known (may exceed content length when capped). */
totalSizeBytes: number | null;
/** Byte offset of content[0] within the remote file. Non-zero when the head was trimmed. */
contentStartOffset: number;
truncated: boolean;
};
/**
* Abstraction over the game-server backend (Pterodactyl Client API in
* production, an in-process mock for local development). Deliberately narrow:
* no arbitrary writes, no console execution.
*/
export interface GameServerProvider {
getServerStatus(serverId: string): Promise<ServerStatus>;
getServerResources(serverId: string): Promise<ProviderServerResources>;
startServer(serverId: string): Promise<void>;
stopServer(serverId: string): Promise<void>;
restartServer(serverId: string): Promise<void>;
listFiles(serverId: string, directory: string): Promise<ServerFileEntry[]>;
getFileDownloadUrl(serverId: string, path: string): Promise<string>;
downloadTextFile(serverId: string, path: string, maxBytes?: number): Promise<DownloadableFile>;
/**
* Writes a text file. NOT exposed as a generic panel endpoint: the only
* callers write server-generated content to paths from server configuration
* (config.json updates and their backups), never user-supplied paths.
*/
writeTextFile(serverId: string, path: string, content: string): Promise<void>;
/** Egg startup variables (Pterodactyl "Startup" tab). May contain secrets. */
listStartupVariables(serverId: string): Promise<StartupVariableEntry[]>;
updateStartupVariable(serverId: string, envVariable: string, value: string): Promise<void>;
/** Native Pterodactyl schedules, scoped here to restart schedule management. */
listSchedules(serverId: string): Promise<ServerScheduleSummary[]>;
createRestartSchedule(
serverId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary>;
updateRestartSchedule(
serverId: string,
scheduleId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary>;
deleteSchedule(serverId: string, scheduleId: string): Promise<void>;
}
export type StartupVariableEntry = {
name: string;
description: string;
envVariable: string;
serverValue: string;
defaultValue: string;
isEditable: boolean;
};