diff --git a/src/admin/game-servers/views/html/static-game-server-list.tsx b/src/admin/game-servers/views/html/static-game-server-list.tsx index ae983ab42..ae4885de7 100644 --- a/src/admin/game-servers/views/html/static-game-server-list.tsx +++ b/src/admin/game-servers/views/html/static-game-server-list.tsx @@ -18,7 +18,8 @@ export async function StaticGameServerList() { Internal IP address RCON password Online - Assigned to game + Game + Health @@ -32,42 +33,71 @@ export async function StaticGameServerList() { } function StaticGameServerItem(props: { gameServer: StaticGameServerModel }) { + const isFree = props.gameServer.isOnline && props.gameServer.game === undefined + const modalId = `healthcheck-modal-${props.gameServer.id}` + return ( - - - {props.gameServer.name} - - - {props.gameServer.address}:{props.gameServer.port} - - - {props.gameServer.internalIpAddress}:{props.gameServer.port} - - - {props.gameServer.rconPassword} - - - {props.gameServer.isOnline ? ( - - ) : ( - - )} - - - {props.gameServer.game ? ( -
- - #{props.gameServer.game} - - +
+ ) : ( + + )} + + + {isFree ? ( + + ) : ( + - - ) : ( - - )} - - + )} + + + + +
+ + + ) } diff --git a/src/routes/admin/game-servers/index.ts b/src/routes/admin/game-servers/index.ts deleted file mode 100644 index 15b203e91..000000000 --- a/src/routes/admin/game-servers/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { PlayerRole } from '../../../database/models/player.model' -import { GameServersPage } from '../../../admin/game-servers/views/html/game-servers.page' -import { routes } from '../../../utils/routes' - -// eslint-disable-next-line @typescript-eslint/require-await -export default routes(async app => { - app.get( - '/', - { - config: { - authorize: [PlayerRole.admin], - }, - }, - async (_request, reply) => { - await reply.status(200).html(GameServersPage()) - }, - ) -}) diff --git a/src/routes/admin/game-servers/index.tsx b/src/routes/admin/game-servers/index.tsx new file mode 100644 index 000000000..9f77442a6 --- /dev/null +++ b/src/routes/admin/game-servers/index.tsx @@ -0,0 +1,92 @@ +import { PlayerRole } from '../../../database/models/player.model' +import { GameServersPage } from '../../../admin/game-servers/views/html/game-servers.page' +import { HealthcheckModal } from '../../../static-game-servers/views/html/healthcheck-modal' +import { collections } from '../../../database/collections' +import { + createCheck, + getCheck, + getActiveCheckId, +} from '../../../static-game-servers/healthcheck-store' +import { runHealthcheck } from '../../../static-game-servers/run-healthcheck' +import { errors } from '../../../errors' +import { z } from 'zod' +import { routes } from '../../../utils/routes' + +// eslint-disable-next-line @typescript-eslint/require-await +export default routes(async app => { + app + .get( + '/', + { + config: { + authorize: [PlayerRole.admin], + }, + }, + async (_request, reply) => { + await reply.status(200).html(GameServersPage()) + }, + ) + .post( + '/:id/healthcheck', + { + config: { authorize: [PlayerRole.admin] }, + schema: { + params: z.object({ id: z.string() }), + }, + }, + async (request, reply) => { + const { id } = request.params + const server = await collections.staticGameServers.findOne({ id }) + if (server === null) { + throw errors.notFound(`game server not found: ${id}`) + } + if (!server.isOnline || server.game !== undefined) { + throw errors.badRequest('server is not available for healthcheck') + } + + // Return existing check if one is already running + const existingCheckId = getActiveCheckId(id) + if (existingCheckId !== undefined) { + const existingResult = getCheck(existingCheckId) + if (existingResult !== undefined) { + return reply.html( + HealthcheckModal({ result: existingResult, checkId: existingCheckId, server }), + ) + } + } + + const checkId = createCheck(id) + void runHealthcheck(server, checkId) + const result = getCheck(checkId)! + return reply.html(HealthcheckModal({ result, checkId, server })) + }, + ) + .get( + '/healthcheck/:checkId', + { + config: { authorize: [PlayerRole.admin] }, + schema: { + params: z.object({ checkId: z.string() }), + }, + }, + async (request, reply) => { + const { checkId } = request.params + const result = getCheck(checkId) + + if (result === undefined) { + return reply.html( +
+ Check expired — please try again. +
, + ) + } + + const server = await collections.staticGameServers.findOne({ id: result.serverId }) + if (server === null) { + throw errors.notFound(`game server not found: ${result.serverId}`) + } + + return reply.html(HealthcheckModal({ result, checkId, server })) + }, + ) +}) diff --git a/src/static-game-servers/healthcheck-store.test.ts b/src/static-game-servers/healthcheck-store.test.ts new file mode 100644 index 000000000..aea16b1bb --- /dev/null +++ b/src/static-game-servers/healthcheck-store.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { minutesToMilliseconds } from 'date-fns' +import { + createCheck, + getCheck, + getActiveCheckId, + updatePhase, + completeCheck, + _resetForTesting, +} from './healthcheck-store' + +beforeEach(() => { + vi.useFakeTimers() + _resetForTesting() +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('createCheck', () => { + it('returns a checkId string', () => { + const checkId = createCheck('server-1') + expect(typeof checkId).toBe('string') + expect(checkId.length).toBeGreaterThan(0) + }) + + it('inserts a pending result', () => { + const checkId = createCheck('server-1') + const result = getCheck(checkId) + expect(result).toEqual({ + serverId: 'server-1', + status: 'running', + phases: { + rconConnect: { status: 'pending' }, + rconCommand: { status: 'pending' }, + logRoundTrip: { status: 'pending' }, + }, + }) + }) + + it('records the active check for the server', () => { + const checkId = createCheck('server-1') + expect(getActiveCheckId('server-1')).toBe(checkId) + }) + + it('auto-deletes result after 5 minutes', () => { + const checkId = createCheck('server-1') + vi.advanceTimersByTime(minutesToMilliseconds(5)) + expect(getCheck(checkId)).toBeUndefined() + expect(getActiveCheckId('server-1')).toBeUndefined() + }) +}) + +describe('updatePhase', () => { + it('updates the specified phase status', () => { + const checkId = createCheck('server-1') + updatePhase(checkId, 'rconConnect', { status: 'running' }) + expect(getCheck(checkId)?.phases.rconConnect).toEqual({ status: 'running' }) + }) + + it('is a no-op for unknown checkId', () => { + expect(() => updatePhase('nonexistent', 'rconConnect', { status: 'ok' })).not.toThrow() + }) +}) + +describe('completeCheck', () => { + it('sets status to done', () => { + const checkId = createCheck('server-1') + completeCheck(checkId) + expect(getCheck(checkId)?.status).toBe('done') + }) + + it('removes the server from activeChecks', () => { + const checkId = createCheck('server-1') + completeCheck(checkId) + expect(getActiveCheckId('server-1')).toBeUndefined() + }) +}) diff --git a/src/static-game-servers/healthcheck-store.ts b/src/static-game-servers/healthcheck-store.ts new file mode 100644 index 000000000..abcd76d6e --- /dev/null +++ b/src/static-game-servers/healthcheck-store.ts @@ -0,0 +1,75 @@ +import { nanoid } from 'nanoid' +import { minutesToMilliseconds } from 'date-fns' + +export type PhaseStatus = + | { status: 'pending' } + | { status: 'running' } + | { status: 'ok'; message?: string } + | { status: 'fail'; message: string } + +export interface HealthcheckResult { + serverId: string + status: 'running' | 'done' + phases: { + rconConnect: PhaseStatus + rconCommand: PhaseStatus + logRoundTrip: PhaseStatus + } +} + +const results = new Map() +const activeChecks = new Map() + +export function createCheck(serverId: string): string { + const checkId = nanoid() + results.set(checkId, { + serverId, + status: 'running', + phases: { + rconConnect: { status: 'pending' }, + rconCommand: { status: 'pending' }, + logRoundTrip: { status: 'pending' }, + }, + }) + activeChecks.set(serverId, checkId) + setTimeout(() => { + results.delete(checkId) + if (activeChecks.get(serverId) === checkId) { + activeChecks.delete(serverId) + } + }, minutesToMilliseconds(5)) + return checkId +} + +export function getCheck(checkId: string): HealthcheckResult | undefined { + return results.get(checkId) +} + +export function getActiveCheckId(serverId: string): string | undefined { + return activeChecks.get(serverId) +} + +export function updatePhase( + checkId: string, + phase: keyof HealthcheckResult['phases'], + status: PhaseStatus, +): void { + const result = results.get(checkId) + if (result === undefined) return + result.phases[phase] = status +} + +export function completeCheck(checkId: string): void { + const result = results.get(checkId) + if (result === undefined) return + result.status = 'done' + if (activeChecks.get(result.serverId) === checkId) { + activeChecks.delete(result.serverId) + } +} + +/** Only for use in tests — clears all in-memory state */ +export function _resetForTesting(): void { + results.clear() + activeChecks.clear() +} diff --git a/src/static-game-servers/run-healthcheck.test.ts b/src/static-game-servers/run-healthcheck.test.ts new file mode 100644 index 000000000..ed8bc04cc --- /dev/null +++ b/src/static-game-servers/run-healthcheck.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { StaticGameServerModel } from '../database/models/static-game-server.model' + +// Mock withRconForServer +const mockWithRconForServer = vi.hoisted(() => vi.fn()) +vi.mock('./with-rcon-for-server', () => ({ + withRconForServer: mockWithRconForServer, +})) + +// Mock events +const mockEvents = vi.hoisted(() => ({ + on: vi.fn(), + off: vi.fn(), +})) +vi.mock('../events', () => ({ events: mockEvents })) + +// Mock store +const mockUpdatePhase = vi.hoisted(() => vi.fn()) +const mockCompleteCheck = vi.hoisted(() => vi.fn()) +const mockGetCheck = vi.hoisted(() => + vi.fn().mockReturnValue({ phases: { rconConnect: { status: 'running' } } }), +) +vi.mock('./healthcheck-store', () => ({ + updatePhase: mockUpdatePhase, + completeCheck: mockCompleteCheck, + getCheck: mockGetCheck, +})) + +// Mock environment +vi.mock('../environment', () => ({ + environment: { + LOG_RELAY_ADDRESS: '1.2.3.4', + LOG_RELAY_PORT: 12345, + }, +})) + +// Mock nanoid — return predictable values +let nanoidCallCount = 0 +vi.mock('nanoid', () => ({ + nanoid: vi.fn(() => { + nanoidCallCount++ + return `fake-id-${nanoidCallCount}` + }), +})) + +// Mock generate-password — logSecret must be numeric; return a fixed value for assertions +vi.mock('generate-password', () => ({ + generate: vi.fn(() => '1234567890123456'), +})) + +import { runHealthcheck } from './run-healthcheck' + +const mockServer = { + id: 'server-1', + name: 'test-server', + internalIpAddress: '10.0.0.1', + port: '27015', + rconPassword: 'secret', +} as StaticGameServerModel + +beforeEach(() => { + vi.clearAllMocks() + nanoidCallCount = 0 +}) + +describe('when RCON connection fails', () => { + beforeEach(() => { + mockWithRconForServer.mockRejectedValue(new Error('ECONNREFUSED')) + }) + + it('sets rconConnect to running then fail', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockUpdatePhase).toHaveBeenCalledWith('check-1', 'rconConnect', { status: 'running' }) + expect(mockUpdatePhase).toHaveBeenCalledWith('check-1', 'rconConnect', { + status: 'fail', + message: 'ECONNREFUSED', + }) + }) + + it('completes the check', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockCompleteCheck).toHaveBeenCalledWith('check-1') + }) +}) + +describe('when RCON command fails', () => { + beforeEach(() => { + mockWithRconForServer.mockImplementation( + async ( + _server: unknown, + callback: (args: { rcon: { send: ReturnType } }) => Promise, + ) => { + const mockRcon = { + send: vi.fn().mockRejectedValue(new Error('command failed')), + } + await callback({ rcon: mockRcon }) + }, + ) + }) + + it('sets rconConnect to ok, rconCommand to fail', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockUpdatePhase).toHaveBeenCalledWith('check-1', 'rconConnect', { status: 'ok' }) + expect(mockUpdatePhase).toHaveBeenCalledWith('check-1', 'rconCommand', { status: 'running' }) + expect(mockUpdatePhase).toHaveBeenCalledWith('check-1', 'rconCommand', { + status: 'fail', + message: 'command failed', + }) + }) + + it('completes the check', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockCompleteCheck).toHaveBeenCalledWith('check-1') + }) +}) + +describe('when log round-trip succeeds', () => { + beforeEach(() => { + mockWithRconForServer.mockImplementation( + async ( + _server: unknown, + callback: (args: { rcon: { send: ReturnType } }) => Promise, + ) => { + const mockRcon = { + send: vi.fn().mockResolvedValue(''), + } + // Simulate log arriving: fire the handler after events.on is called + mockEvents.on.mockImplementation( + (_event: string, handler: (arg: { message: unknown }) => void) => { + setImmediate(() => { + // logSecret comes from generate-password mock ('1234567890123456') + // probe is fake-id-1 (first nanoid call) + handler({ message: { password: '1234567890123456', payload: 'say "fake-id-1"' } }) + }) + }, + ) + await callback({ rcon: mockRcon }) + }, + ) + }) + + it('sets logRoundTrip to ok with timing message', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockUpdatePhase).toHaveBeenCalledWith('check-1', 'logRoundTrip', { + status: 'ok', + message: expect.stringMatching(/^received in \d+ms$/), + }) + }) + + it('removes the event listener', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockEvents.off).toHaveBeenCalledWith('gamelog:message', expect.any(Function)) + }) + + it('completes the check', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockCompleteCheck).toHaveBeenCalledWith('check-1') + }) +}) + +describe('when log round-trip times out', () => { + beforeEach(() => { + vi.useFakeTimers() + mockWithRconForServer.mockImplementation( + async ( + _server: unknown, + callback: (args: { rcon: { send: ReturnType } }) => Promise, + ) => { + const mockRcon = { send: vi.fn().mockResolvedValue('') } + mockEvents.on.mockImplementation(vi.fn()) + const callbackPromise = callback({ rcon: mockRcon }) + // Use advanceTimersByTimeAsync so pending microtasks (the awaited rcon.send + // calls inside the callback) flush before the setTimeout fires, ensuring + // the timeout Promise is registered before the timer advances. + await vi.advanceTimersByTimeAsync(11000) + await callbackPromise + }, + ) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('sets logRoundTrip to fail', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockUpdatePhase).toHaveBeenCalledWith('check-1', 'logRoundTrip', { + status: 'fail', + message: 'no log received within 10s', + }) + }) + + it('removes the event listener on timeout', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockEvents.off).toHaveBeenCalledWith('gamelog:message', expect.any(Function)) + }) + + it('completes the check', async () => { + await runHealthcheck(mockServer, 'check-1') + expect(mockCompleteCheck).toHaveBeenCalledWith('check-1') + }) +}) diff --git a/src/static-game-servers/run-healthcheck.ts b/src/static-game-servers/run-healthcheck.ts new file mode 100644 index 000000000..eb22ea18e --- /dev/null +++ b/src/static-game-servers/run-healthcheck.ts @@ -0,0 +1,99 @@ +import { nanoid } from 'nanoid' +import { secondsToMilliseconds } from 'date-fns' +import { events } from '../events' +import { environment } from '../environment' +import type { StaticGameServerModel } from '../database/models/static-game-server.model' +import type { LogMessage } from '../log-receiver/parse-log-message' +import { withRconForServer } from './with-rcon-for-server' +import { updatePhase, completeCheck, getCheck } from './healthcheck-store' +import { assertIsError } from '../utils/assert-is-error' +import { delay, withTimeout } from 'es-toolkit' +import { generate } from 'generate-password' + +export async function runHealthcheck( + server: StaticGameServerModel, + checkId: string, +): Promise { + try { + updatePhase(checkId, 'rconConnect', { status: 'running' }) + + await withRconForServer(server, async ({ rcon }) => { + updatePhase(checkId, 'rconConnect', { status: 'ok' }) + + // Phase 2: RCON command + updatePhase(checkId, 'rconCommand', { status: 'running' }) + try { + await rcon.send('status') + updatePhase(checkId, 'rconCommand', { status: 'ok' }) + } catch (error) { + assertIsError(error) + updatePhase(checkId, 'rconCommand', { status: 'fail', message: error.message }) + return + } + + // Phase 3: Log round-trip + updatePhase(checkId, 'logRoundTrip', { status: 'running' }) + + const logSecret = generate({ + length: 16, + numbers: true, + symbols: false, + lowercase: false, + uppercase: false, + }) + const probe = nanoid(16) + + let resolveLog!: () => void + const logReceived = new Promise(resolve => { + resolveLog = resolve + }) + + const handler = ({ message }: { message: LogMessage }) => { + if (message.password === logSecret && message.payload.includes(probe)) { + events.off('gamelog:message', handler) + resolveLog() + } + } + events.on('gamelog:message', handler) + + try { + await rcon.send(`sv_logsecret ${logSecret}`) + await rcon.send( + `logaddress_add ${environment.LOG_RELAY_ADDRESS}:${environment.LOG_RELAY_PORT}`, + ) + await delay(100) + const probeTime = Date.now() + await rcon.send(`say tf2pickup-healthcheck-${probe}`) + + try { + await withTimeout(() => logReceived, secondsToMilliseconds(10)) + updatePhase(checkId, 'logRoundTrip', { + status: 'ok', + message: `received in ${Date.now() - probeTime}ms`, + }) + } catch { + events.off('gamelog:message', handler) + updatePhase(checkId, 'logRoundTrip', { + status: 'fail', + message: 'no log received within 10s', + }) + } + } finally { + await rcon.send( + `logaddress_del ${environment.LOG_RELAY_ADDRESS}:${environment.LOG_RELAY_PORT}`, + ) + await rcon.send('sv_logsecret 0') + } + }) + } catch (error) { + assertIsError(error) + // Only mark rconConnect as failed if it's still 'running' — errors thrown + // from the cleanup finally block (logaddress_del, sv_logsecret 0) should + // not overwrite phases that already completed. + if (getCheck(checkId)?.phases.rconConnect.status === 'running') { + updatePhase(checkId, 'rconConnect', { status: 'fail', message: error.message }) + } + } finally { + completeCheck(checkId) + } +} diff --git a/src/static-game-servers/views/html/healthcheck-modal.tsx b/src/static-game-servers/views/html/healthcheck-modal.tsx new file mode 100644 index 000000000..c540e00ac --- /dev/null +++ b/src/static-game-servers/views/html/healthcheck-modal.tsx @@ -0,0 +1,134 @@ +import type { HealthcheckResult, PhaseStatus } from '../../healthcheck-store' +import type { StaticGameServerModel } from '../../../database/models/static-game-server.model' + +interface Props { + result: HealthcheckResult + checkId: string + server: StaticGameServerModel +} + +function PhaseIcon(props: { status: PhaseStatus['status'] }) { + if (props.status === 'ok') { + return ( + + ✓ + + ) + } + if (props.status === 'fail') { + return ( + + ✗ + + ) + } + if (props.status === 'running') { + return ( + + ● + + ) + } + // pending + return ( + + ○ + + ) +} + +function phaseMessage(phaseName: string, phase: PhaseStatus): string { + if (phase.status === 'ok' || phase.status === 'fail') { + return phase.message ?? '' + } + if (phase.status === 'running') { + if (phaseName === 'RCON connect') return 'connecting…' + if (phaseName === 'RCON command') return 'running…' + return 'waiting for UDP…' + } + return '' +} + +function PhaseRow(props: { name: string; phase: PhaseStatus }) { + const msg = phaseMessage(props.name, props.phase) + return ( +
+ + + {props.name} + + {msg !== '' && ( + + {msg} + + )} +
+ ) +} + +function ResultBanner(props: { result: HealthcheckResult }) { + if (props.result.status !== 'done') return <> + + const allOk = Object.values(props.result.phases).every(p => p.status === 'ok') + if (allOk) { + return ( +
+ ✓ All checks passed +
+ ) + } + return ( +
+ ✗ Check failed +
+ ) +} + +export function HealthcheckModal(props: Props) { + const { result, checkId, server } = props + const modalId = `healthcheck-modal-${result.serverId}` + const isDone = result.status === 'done' + + const pollingAttrs = isDone + ? {} + : { + 'hx-get': `/admin/game-servers/healthcheck/${checkId}`, + 'hx-trigger': 'every 500ms', + 'hx-target': `#${modalId}`, + 'hx-swap': 'innerHTML', + } + + return ( +
+
+
+
+ Healthcheck — {server.name} +
+
+ using internal address{' '} + + {server.internalIpAddress}:{server.port} + +
+ +
+ + + +
+ {isDone && ( + + )} +
+
+ ) +} diff --git a/src/static-game-servers/with-rcon-for-server.test.ts b/src/static-game-servers/with-rcon-for-server.test.ts new file mode 100644 index 000000000..41df27749 --- /dev/null +++ b/src/static-game-servers/with-rcon-for-server.test.ts @@ -0,0 +1,104 @@ +import { expect, it, vi, describe, beforeEach, afterEach } from 'vitest' +import { withRconForServer } from './with-rcon-for-server' +import type { StaticGameServerModel } from '../database/models/static-game-server.model' +import type { RconCommand } from '../shared/types/rcon-command' + +const mockRcon = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + + class MockRcon extends EventEmitter { + end = vi.fn() + send = vi.fn() + authenticated = true + connect = vi.fn().mockImplementation(async () => { + this.authenticated = true + return this + }) + } + + return new MockRcon() +}) +const mockRconClient = vi.hoisted(() => ({ + Rcon: { + connect: vi.fn().mockResolvedValue(mockRcon), + }, +})) +vi.mock('rcon-client', () => mockRconClient) + +const mockLogger = vi.hoisted(() => ({ + trace: vi.fn(), + error: vi.fn(), +})) +vi.mock('../logger', () => ({ + logger: mockLogger, +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +const mockServer = { + id: 'fake-id', + name: 'fake-server', + internalIpAddress: 'FAKE_INTERNAL_IP', + port: '12345', + rconPassword: 'FAKE_PASSWORD', +} as StaticGameServerModel + +it('should connect using internalIpAddress and coerced port', async () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + await withRconForServer(mockServer, async () => {}) + expect(mockRconClient.Rcon.connect).toHaveBeenCalledWith({ + host: 'FAKE_INTERNAL_IP', + port: 12345, + password: 'FAKE_PASSWORD', + timeout: 30000, + }) +}) + +it('should execute command', async () => { + await withRconForServer(mockServer, async ({ rcon }) => { + await rcon.send('fake_command' as RconCommand) + }) + expect(mockRcon.send).toHaveBeenCalledWith('fake_command') +}) + +it('should close the connection', async () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + await withRconForServer(mockServer, async () => {}) + expect(mockRcon.end).toHaveBeenCalled() +}) + +describe('when an error occurs', () => { + beforeEach(() => { + mockRcon.send.mockImplementation(() => { + mockRcon.emit('error', new Error('FAKE_ERROR')) + }) + }) + + it('should close the connection', async () => { + try { + // eslint-disable-next-line @typescript-eslint/no-empty-function + await withRconForServer(mockServer, async () => {}) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + // empty + } + expect(mockRcon.end).toHaveBeenCalled() + }) +}) + +describe('when the connection is closed', () => { + beforeEach(() => { + mockRcon.send.mockImplementation(() => { + mockRcon.authenticated = false + }) + }) + + it('should reconnect', async () => { + await withRconForServer(mockServer, async ({ rcon }) => { + await rcon.send('fake_command' as RconCommand) + }) + expect(mockRcon.connect).toHaveBeenCalled() + }) +}) diff --git a/src/static-game-servers/with-rcon-for-server.ts b/src/static-game-servers/with-rcon-for-server.ts new file mode 100644 index 000000000..6cb79344d --- /dev/null +++ b/src/static-game-servers/with-rcon-for-server.ts @@ -0,0 +1,45 @@ +import { Rcon as RconClient } from 'rcon-client' +import { type StaticGameServerModel } from '../database/models/static-game-server.model' +import { assertIsError } from '../utils/assert-is-error' +import { logger } from '../logger' +import type { RconCommand } from '../shared/types/rcon-command' + +interface Rcon { + send: (command: RconCommand) => Promise +} + +export async function withRconForServer( + server: StaticGameServerModel, + callback: (args: { rcon: Rcon }) => Promise, +): Promise { + logger.trace({ serverId: server.id }, `withRconForServer()`) + + let rcon: RconClient | undefined + + try { + rcon = await RconClient.connect({ + host: server.internalIpAddress, + port: Number(server.port), + password: server.rconPassword, + timeout: 30000, + }) + rcon.on('error', error => { + assertIsError(error) + logger.error(error, `server ${server.name}: rcon error`) + }) + + return await callback({ + rcon: { + send: async (command: RconCommand) => { + const ret = await rcon!.send(command) + if (!rcon!.authenticated) { + await rcon!.connect() + } + return ret + }, + }, + }) + } finally { + await rcon?.end() + } +}