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}
-
- |
+
+
+ |
+
+ |
+
+ >
)
}
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 && (
+
+ Close
+
+ )}
+
+
+ )
+}
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()
+ }
+}