initial commit
This commit is contained in:
106 files changed
+24584
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user