import WebSocket from 'ws'; import type { Logger } from '../../lib/logger.js'; import { BaseConsoleHub, mapPowerState, type LiveStats } from './console-hub.js'; type WebsocketCredentials = { token: string; socket: string }; type WingsMessage = { event?: string; args?: unknown[] }; const CREDENTIAL_TIMEOUT_MS = 10_000; const RECONNECT_BASE_MS = 1_000; const RECONNECT_MAX_MS = 30_000; /** Wings tokens last 10 minutes; refresh well before the warning arrives. */ const TOKEN_REFRESH_MS = 8 * 60_000; /** * Keeps one authenticated Wings websocket open for the server and republishes * everything it emits: console output (including the SteamCMD/mod-download * phase that never reaches the game's own log file), install output, power * state transitions, and resource frames. * * One upstream connection is shared by every panel viewer, and the base class * keeps a line backlog so a browser attaching mid-session immediately sees * context instead of an empty pane. */ export class WingsConsoleHub extends BaseConsoleHub { private socket: WebSocket | null = null; private stopped = true; private reconnectAttempt = 0; private reconnectTimer: ReturnType | null = null; private refreshTimer: ReturnType | null = null; private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; constructor( private readonly options: { baseUrl: string; apiKey: string; serverId: string; logger: Logger; fetchImpl?: typeof fetch; }, ) { super(); this.baseUrl = options.baseUrl.replace(/\/$/, ''); this.fetchImpl = options.fetchImpl ?? fetch; } start(): void { if (!this.stopped) return; this.stopped = false; this.refreshTimer = setInterval(() => void this.reauthenticate(), TOKEN_REFRESH_MS); this.refreshTimer.unref?.(); void this.connect(); } async stop(): Promise { this.stopped = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.refreshTimer) clearInterval(this.refreshTimer); this.reconnectTimer = null; this.refreshTimer = null; const socket = this.socket; this.socket = null; this.setConnected(false); socket?.close(); } /** Fetches a fresh websocket token from the Pterodactyl client API. */ private async fetchCredentials(): Promise { const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent( this.options.serverId, )}/websocket`; const response = await this.fetchImpl(url, { headers: { Authorization: `Bearer ${this.options.apiKey}`, Accept: 'application/json', }, signal: AbortSignal.timeout(CREDENTIAL_TIMEOUT_MS), }); if (!response.ok) { throw new Error(`websocket credentials returned HTTP ${response.status}`); } const body = (await response.json()) as { data?: { token?: string; socket?: string } }; const token = body.data?.token; const socket = body.data?.socket; if (!token || !socket) throw new Error('websocket credentials response was incomplete'); return { token, socket }; } private scheduleReconnect(): void { if (this.stopped || this.reconnectTimer) return; const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempt); this.reconnectAttempt = Math.min(this.reconnectAttempt + 1, 5); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; void this.connect(); }, delay); this.reconnectTimer.unref?.(); } private async connect(): Promise { if (this.stopped || this.socket) return; let credentials: WebsocketCredentials; try { credentials = await this.fetchCredentials(); } catch (error) { this.options.logger.warn( { err: error instanceof Error ? error.message : String(error) }, 'wings websocket credentials failed', ); this.scheduleReconnect(); return; } if (this.stopped) return; // Wings only upgrades connections whose Origin matches the panel URL // (see wings router/websocket GetHandler.CheckOrigin), which is why this // uses `ws` rather than Node's built-in WebSocket. const socket = new WebSocket(credentials.socket, { origin: this.baseUrl }); this.socket = socket; socket.on('open', () => { this.reconnectAttempt = 0; this.setConnected(true); this.send(socket, 'auth', credentials.token); }); socket.on('message', (raw: WebSocket.RawData) => this.handleMessage(String(raw))); socket.on('error', (error: Error) => { this.options.logger.debug({ err: error.message }, 'wings websocket error'); }); socket.on('close', () => { if (this.socket === socket) this.socket = null; this.setConnected(false); this.scheduleReconnect(); }); } private send(socket: WebSocket, event: string, ...args: string[]): void { if (socket.readyState !== WebSocket.OPEN) return; socket.send(JSON.stringify({ event, args })); } /** Re-auths the live socket with a fresh token before the old one expires. */ private async reauthenticate(): Promise { const socket = this.socket; if (!socket || socket.readyState !== WebSocket.OPEN) return; try { const { token } = await this.fetchCredentials(); this.send(socket, 'auth', token); } catch (error) { this.options.logger.debug( { err: error instanceof Error ? error.message : String(error) }, 'wings token refresh failed', ); } } private handleMessage(raw: string): void { let message: WingsMessage; try { message = JSON.parse(raw) as WingsMessage; } catch { return; } const arg = typeof message.args?.[0] === 'string' ? (message.args[0] as string) : ''; switch (message.event) { case 'auth success': // Ask for the recent backlog and an immediate resource frame so the // panel is populated the moment it attaches. if (this.socket) { this.send(this.socket, 'send logs'); this.send(this.socket, 'send stats'); } break; case 'console output': this.pushOutput('console', arg); break; case 'install output': this.pushOutput('install', arg); break; case 'install started': this.pushOutput('daemon', 'Installation started.'); break; case 'install completed': this.pushOutput('daemon', 'Installation completed.'); break; case 'daemon message': this.pushOutput('daemon', arg); break; case 'daemon error': case 'jwt error': this.pushOutput('daemon', arg); void this.reauthenticate(); break; case 'token expiring': case 'token expired': void this.reauthenticate(); break; case 'status': this.setStatus(mapPowerState(arg)); break; case 'stats': this.handleStats(arg); break; default: break; } } private handleStats(payload: string): void { let parsed: { state?: string; cpu_absolute?: number; memory_bytes?: number; disk_bytes?: number; uptime?: number; network?: { rx_bytes?: number; tx_bytes?: number }; }; try { parsed = JSON.parse(payload) as typeof parsed; } catch { return; } const stats: LiveStats = { status: mapPowerState(parsed.state), cpuPercent: parsed.cpu_absolute ?? 0, memoryBytes: parsed.memory_bytes ?? 0, diskBytes: parsed.disk_bytes ?? 0, networkRxBytes: parsed.network?.rx_bytes ?? 0, networkTxBytes: parsed.network?.tx_bytes ?? 0, uptimeMs: parsed.uptime ?? 0, at: Date.now(), }; this.setStats(stats); } }