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( label: string, path: string, init: { method?: string; body?: unknown; timeoutMs?: number; raw?: boolean } = {}, ): Promise { 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 { const resources = await this.getServerResources(serverId); return resources.status; } async getServerResources(serverId: string): Promise { 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 { await this.sendPowerSignal(serverId, 'start'); } async stopServer(serverId: string): Promise { await this.sendPowerSignal(serverId, 'stop'); } async restartServer(serverId: string): Promise { await this.sendPowerSignal(serverId, 'restart'); } async listFiles(serverId: string, directory: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { const created = await this.request( '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 { const updated = await this.request( '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 { 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; } } }