From 0a406889455b3d68df59ee92a94bb07cb01304ec Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Thu, 29 Oct 2026 14:34:51 +0100 Subject: [PATCH] Server im LAN-Reiter des Steam-Serverbrowsers: LAN-Ports, Host-Netz, Teamnamen, CSTV +100 --- .env.example | 6 ++ README.md | 10 +++ client/src/pages/AdminPage.jsx | 4 +- client/src/styles.css | 2 +- deploy/docker-host-einrichten.sh | 3 +- server/src/adminRoutes.js | 2 + server/src/config.js | 36 +++++++++ server/src/serverSettings.js | 6 ++ server/src/servers/dockerAdapter.js | 121 ++++++++++++++++++++++------ server/src/servers/serverManager.js | 7 +- server/test/dockerAdapter.test.js | 4 +- server/test/dockerHostPool.test.js | 6 +- server/test/lanPorts.test.js | 95 ++++++++++++++++++++++ server/test/serverSettings.test.js | 4 +- 14 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 server/test/lanPorts.test.js diff --git a/.env.example b/.env.example index 9ba47fc..db102ec 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,12 @@ CS2_DOCKER_EXTRA_ARGS= # Einrichtung pro Host: bash deploy/docker-host-einrichten.sh # CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}] CS2_START_TIMEOUT_MINUTES=20 +# Spielports für den LAN-Reiter des Steam-Serverbrowsers (Steam sucht nur 27015–27020). Leer = aus. +CS2_LAN_PORTS=27015-27020 +# CSTV-Port = Spielport + dieser Abstand (z. B. 27015 -> 27115) +CS2_TV_PORT_OFFSET=100 +# Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen +CS2_SERVERNAME_PREFIX=MaxLAN DOCKER_BIN=docker # --- Static-Pool-Adapter (SERVER_ADAPTER=static) --- diff --git a/README.md b/README.md index 388bf4e..49a8dce 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,16 @@ Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts. Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein. +### Server im Steam-Serverbrowser (LAN-Reiter) + +Der LAN-Reiter des Steam-Serverbrowsers (Steam → Ansicht → Spielserver → LAN) fragt nur die Ports 27015 bis 27020 ab. Deshalb vergibt die App den Spielport zuerst aus `CS2_LAN_PORTS` (Standard `27015-27020`), danach aus dem normalen Portbereich. Der CSTV-Port liegt immer 100 über dem Spielport (Spiel 27015, CSTV 27115; Spiel 27100, CSTV 27200), der Abstand lässt sich mit `CS2_TV_PORT_OFFSET` ändern. Auf jedem Host müssen also auch die Ports 100 über dem Portbereich frei sein. So passen bis zu sechs Server pro Host in den LAN-Reiter. Weitere Server laufen im normalen Portbereich. Sie sind dann nicht im LAN-Reiter sichtbar, Spieler erreichen sie aber über den Verbindungsbefehl. + +- Pro Host lassen sich die LAN-Ports in `CS2_DOCKER_HOSTS` mit `"lanPorts":"27016-27019"` einschränken, wenn dort schon etwas anderes auf einem dieser Ports läuft. `"lanPorts":""` schaltet es für einen Host ab. Im Admin-Bereich steht das in der Spalte „LAN-Ports (Steam)“. +- Ist ein Port trotzdem belegt, versucht der Start automatisch die nächsten freien Ports. +- Server auf LAN-Ports laufen im Netz des Hosts (`--network host`), weil die Port-Weiterleitung von Docker die Suchanfragen (Broadcasts) des Serverbrowsers nicht weitergibt. Die Firewall des Hosts muss die Ports deshalb selbst erlauben. +- Der Servername im Browser ist `MaxLAN | Team A vs. Team B`. Den Anfang ändert `CS2_SERVERNAME_PREFIX`. +- Die Server sind mit Passwort geschützt und zeigen ein Schloss. Ohne `CS2_GSLT` laufen sie mit `sv_lan 1` und erscheinen nicht im Internet-Reiter. + ### Option B: Vorhandene Server (`SERVER_ADAPTER=static`) Für selbst betriebene, dauerhaft laufende CS2-Server: diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index a668bd7..cda2d2f 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -555,13 +555,14 @@ function ServerSettingsPanel({ token, setNotice }) { ); } -const EMPTY_HOST = { name: '', dockerHost: 'ssh://', publicHost: '', portStart: 27100, portEnd: 27139, volume: 'cs2-mapveto-gamefiles' }; +const EMPTY_HOST = { name: '', dockerHost: 'ssh://', publicHost: '', portStart: 27100, portEnd: 27139, lanPorts: '27015-27020', volume: 'cs2-mapveto-gamefiles' }; const HOST_COLUMNS = [ { key: 'name', label: 'Name', placeholder: 'host1' }, { key: 'dockerHost', label: 'Docker-Ziel', placeholder: 'ssh://server@192.168.178.45' }, { key: 'publicHost', label: 'Adresse für Spieler', placeholder: '192.168.178.45' }, { key: 'portStart', label: 'Port von', type: 'number' }, { key: 'portEnd', label: 'Port bis', type: 'number' }, + { key: 'lanPorts', label: 'LAN-Ports (Steam)', placeholder: '27015-27020' }, { key: 'volume', label: 'Volume', placeholder: 'cs2-mapveto-gamefiles' }, ]; @@ -623,6 +624,7 @@ function DockerHostsPanel({ token, setNotice }) { ...(r.rconHost && r.rconHost !== r.publicHost ? { rconHost: r.rconHost } : {}), portStart: Number(r.portStart), portEnd: Number(r.portEnd), + lanPorts: String(r.lanPorts ?? '').trim(), volume: r.volume.trim() || undefined, })), }, diff --git a/client/src/styles.css b/client/src/styles.css index e4b73f0..27f0be7 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -578,7 +578,7 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .setting-field textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; } .setting-live { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 999px; font-size: .7rem; font-weight: 600; color: var(--primary); border: 1px solid var(--primary); } .host-edit { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; } -.host-edit-row { display: grid; grid-template-columns: 1fr 2fr 1.4fr .8fr .8fr 1.4fr auto; gap: 8px; align-items: end; padding-bottom: 10px; border-bottom: 1px solid var(--line); } +.host-edit-row { display: grid; grid-template-columns: 1fr 2fr 1.4fr .8fr .8fr 1.1fr 1.4fr auto; gap: 8px; align-items: end; padding-bottom: 10px; border-bottom: 1px solid var(--line); } @media (max-width: 900px) { .host-edit-row { grid-template-columns: 1fr 1fr; } .host-col-dockerHost { grid-column: 1 / -1; } diff --git a/deploy/docker-host-einrichten.sh b/deploy/docker-host-einrichten.sh index b25c30b..38bffe9 100644 --- a/deploy/docker-host-einrichten.sh +++ b/deploy/docker-host-einrichten.sh @@ -39,6 +39,7 @@ Fertig. Diesen Eintrag in CS2_DOCKER_HOSTS (JSON-Liste in der .env) aufnehmen: Hinweise: - name muss pro Host eindeutig sein. -- Ports 27100–27139 dürfen auf dem Host frei sein. Pro Host sind bis zu 20 Matches möglich. +- Frei sein müssen: 27015–27020 (LAN-Reiter von Steam), 27100–27139 und jeweils 100 darüber für CSTV + (27115–27120, 27200–27239). Pro Host sind bis zu 40 Matches möglich, 6 davon im LAN-Reiter. - Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange. EOF diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index fb25c56..c43adae 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -1,4 +1,5 @@ import express from 'express'; +import { formatPortList } from './config.js'; import { HttpError } from './matchService.js'; import { createRateLimiter, randomToken, safeEqual } from './security.js'; @@ -96,6 +97,7 @@ export function createAdminRouter({ service, manager, config, settings }) { portStart: h.portRangeStart, portEnd: h.portRangeEnd, volume: h.volume, + lanPorts: formatPortList(h.lanPorts), })), overridden: settings.hostsOverridden(), editable: !!manager.adapter.setHosts, diff --git a/server/src/config.js b/server/src/config.js index c3cbc39..d13c48a 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -22,6 +22,34 @@ function bool(name, fallback) { return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase()); } +/** Portliste aus Text wie "27015-27020" oder "27016,27018-27019". Leer = keine Ports. */ +export function parsePortList(value, label = 'LAN-Ports') { + if (Array.isArray(value)) value = value.join(','); + const ports = []; + for (const part of String(value ?? '').split(',').map((p) => p.trim()).filter(Boolean)) { + const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part); + if (!m) throw new Error(`${label}: "${part}" ist kein Port oder Bereich`); + const from = Number(m[1]); + const to = Number(m[2] ?? m[1]); + if (from < 1024 || to > 65535 || to < from || to - from > 100) throw new Error(`${label}: Bereich ${part} ist ungültig`); + for (let p = from; p <= to; p += 1) if (!ports.includes(p)) ports.push(p); + } + return ports; +} + +/** Portliste kompakt als Text, z. B. [27015, 27016, 27017] -> "27015-27017". */ +export function formatPortList(ports = []) { + const sorted = [...ports].sort((a, b) => a - b); + const parts = []; + for (let i = 0; i < sorted.length; i += 1) { + let j = i; + while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j += 1; + parts.push(i === j ? String(sorted[i]) : `${sorted[i]}-${sorted[j]}`); + i = j; + } + return parts.join(','); +} + export function parseDockerHosts(raw) { if (!raw) return null; let list; @@ -44,6 +72,7 @@ export function parseDockerHosts(raw) { portRangeStart: h.portStart == null ? undefined : Number(h.portStart), portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd), volume: h.volume ?? undefined, + lanPorts: h.lanPorts === undefined ? undefined : parsePortList(h.lanPorts, `LAN-Ports von ${h.name}`), }; }); } @@ -111,6 +140,13 @@ export function loadConfig(overrides = {}) { volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'), extraArgs: str('CS2_DOCKER_EXTRA_ARGS'), startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20), + // Spielports, die der Steam-Serverbrowser im LAN-Reiter absucht (Steam fragt nur 27015–27020 ab). + // Diese Ports werden zuerst vergeben, CSTV kommt dann aus dem normalen Portbereich. Leer = aus. + lanPorts: parsePortList(str('CS2_LAN_PORTS', '27015-27020'), 'CS2_LAN_PORTS'), + // Abstand des CSTV-Ports zum Spielport, z. B. Spiel 27015 -> CSTV 27115 + tvPortOffset: int('CS2_TV_PORT_OFFSET', 100), + // Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen + serverNamePrefix: str('CS2_SERVERNAME_PREFIX', 'MaxLAN'), // Optional: mehrere Docker-Hosts als JSON-Liste, z. B. // [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}] hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')), diff --git a/server/src/serverSettings.js b/server/src/serverSettings.js index bf1d6b2..56cb51c 100644 --- a/server/src/serverSettings.js +++ b/server/src/serverSettings.js @@ -160,6 +160,12 @@ export class ServerSettings { throw new HttpError(400, `Ungültige Adresse für ${h.name}`, 'invalid'); } if (h.volume && !/^[\w.-]+$/.test(h.volume)) throw new HttpError(400, `Ungültiger Volume-Name für ${h.name}`, 'invalid'); + const lan = h.lanPorts ?? this.config.docker.lanPorts ?? []; + const rangeStart = h.portRangeStart ?? this.config.docker.portRangeStart; + const rangeEnd = h.portRangeEnd ?? this.config.docker.portRangeEnd; + if (lan.some((p) => p >= rangeStart && p <= rangeEnd)) { + throw new HttpError(400, `LAN-Ports von ${h.name} dürfen nicht im normalen Portbereich liegen`, 'invalid'); + } const start = h.portRangeStart ?? this.config.docker.portRangeStart; const end = h.portRangeEnd ?? this.config.docker.portRangeEnd; if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || end - start < 1) { diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index 0fc29e4..dc2c294 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -2,8 +2,39 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js'; import { runRcon } from './rcon.js'; +import { sanitizeConsoleText } from './cs2Config.js'; const exec = promisify(execFile); + +/** + * Plant Spiel- und CSTV-Port für einen neuen Server. Der CSTV-Port liegt immer `tvPortOffset` (Standard 100) + * über dem Spielport. Spielports kommen zuerst aus den LAN-Ports (damit der Server im LAN-Reiter des + * Steam-Serverbrowsers auftaucht), danach aus dem normalen Bereich. null = kein Platz. + */ +export function planPorts(host, used) { + const offset = host.tvPortOffset ?? 100; + const free = (p) => !used.has(p) && p <= 65535; + const candidates = [...(host.lanPorts ?? [])]; + for (let p = host.portRangeStart; p <= host.portRangeEnd; p += 1) candidates.push(p); + const port = candidates.find((p) => free(p) && free(p + offset)); + return port === undefined ? null : { port, tvPort: port + offset }; +} + +/** Wie viele Server passen noch auf einen Host, wenn bereits `used` belegt ist. */ +export function countSlots(host, used) { + const taken = new Set(used); + let count = 0; + for (;;) { + const plan = planPorts(host, taken); + if (!plan) return count; + taken.add(plan.port); + taken.add(plan.tvPort); + count += 1; + } +} + +/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */ +const PORT_BUSY = /port is already allocated|address already in use/i; const DISK_CACHE_MS = 60_000; /** Liest Gesamt- und freien Speicher aus einer Zeile von `df -B1` (Spalten: Filesystem, 1B-blocks, Used, Available …). */ @@ -52,12 +83,19 @@ export class DockerServerAdapter { return stdout.trim(); } - allocatePort() { - const used = this.getUsedPorts(); - for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) { - if (!used.has(p) && !used.has(p + 1)) return p; - } - throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END'); + allocatePorts(blocked = new Set()) { + const used = new Set([...this.getUsedPorts(), ...blocked]); + const plan = planPorts(this.options, used); + if (!plan) throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END'); + return plan; + } + + /** Servername im Steam-Serverbrowser, z. B. "MaxLAN | Team A vs. Team B". */ + serverName(matchId, teamNames) { + const prefix = this.options.serverNamePrefix ? sanitizeConsoleText(this.options.serverNamePrefix, 24) : ''; + if (!teamNames) return `${prefix} ${matchId}`.trim(); + const title = `${sanitizeConsoleText(teamNames.A)} vs. ${sanitizeConsoleText(teamNames.B)}`; + return (prefix ? `${prefix} | ${title}` : title).slice(0, 63); } async start(args) { @@ -108,13 +146,34 @@ export class DockerServerAdapter { } } - async launch({ matchId, mode, secrets }) { - const port = this.allocatePort(); - const tvPort = port + 1; + /** + * Im Host-Netz meldet Docker keinen Portkonflikt, CS2 startet dann einfach nicht. Deshalb vorher per SSH + * nachsehen, ob ein fremder Dienst den Port belegt. Ohne SSH-Ziel (oder in Tests) gilt der Port als frei. + */ + async hostPortsFree(ports) { + if (this.runDocker || !this.options.dockerHost?.startsWith('ssh://')) return true; + try { + const filter = ports.map((p) => `sport = :${Number(p)}`).join(' or '); + const { stdout } = await exec('ssh', [ + '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', this.options.dockerHost.slice('ssh://'.length), + `ss -Hlnut '( ${filter} )'`, + ], { timeout: 10_000 }); + return stdout.trim() === ''; + } catch { + return true; + } + } + + async launch({ matchId, mode, secrets, teamNames }, blocked = new Set()) { + const { port, tvPort } = this.allocatePorts(blocked); + const lan = (this.options.lanPorts ?? []).includes(port); + if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) { + return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort])); + } const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase(); const env = { ...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }), - CS2_SERVERNAME: `CS2 Mapveto ${matchId}`, + CS2_SERVERNAME: this.serverName(matchId, teamNames), CS2_PORT: String(port), CS2_RCONPW: secrets.rconPassword, CS2_PW: secrets.serverPassword, @@ -133,19 +192,32 @@ export class DockerServerAdapter { }; const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1']; for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`); - args.push( - '-p', `${port}:${port}/tcp`, - '-p', `${port}:${port}/udp`, - '-p', `${tvPort}:${tvPort}/udp`, - '-v', `${this.options.volume}:/home/steam/cs2-dedicated`, - ); + if (lan) { + // Der LAN-Reiter von Steam sucht per Broadcast. Die Port-Weiterleitung von Docker reicht Broadcasts + // nicht an den Container weiter, deshalb laufen Server auf LAN-Ports direkt im Netz des Hosts. + args.push('--network', 'host'); + } else { + args.push( + '-p', `${port}:${port}/tcp`, + '-p', `${port}:${port}/udp`, + '-p', `${tvPort}:${tvPort}/udp`, + ); + } + args.push('-v', `${this.options.volume}:/home/steam/cs2-dedicated`); if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean)); args.push(this.options.image); // Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt. await this.docker(['rm', '-f', container]).catch(() => {}); // Das erste "docker run" kann das Image laden und dauert dann länger. - await this.docker(args, 15 * 60_000); + try { + await this.docker(args, 15 * 60_000); + } catch (err) { + // Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen + if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err; + await this.docker(['rm', '-f', container]).catch(() => {}); + return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort])); + } return { handle: { @@ -250,6 +322,8 @@ export class DockerHostPool { portRangeStart: h.portRangeStart ?? options.portRangeStart, portRangeEnd: h.portRangeEnd ?? options.portRangeEnd, volume: h.volume ?? options.volume, + lanPorts: h.lanPorts ?? options.lanPorts ?? [], + tvPortOffset: options.tvPortOffset ?? 100, }; const merged = { ...options, ...host, host: host.publicHost }; // Die Startzeit kann der Admin zur Laufzeit ändern, deshalb immer aus den gemeinsamen Einstellungen lesen @@ -340,20 +414,15 @@ export class DockerHostPool { return this.diskInflight.get(name); } - /** Freie Port-Paare auf einem Host. */ + /** Wie viele Server noch auf einen Host passen. */ freeSlots(host) { - const used = this.getUsedPorts(host.publicHost); - let free = 0; - for (let p = host.portRangeStart; p + 1 <= host.portRangeEnd; p += 2) { - if (!used.has(p) && !used.has(p + 1)) free += 1; - } - return free; + return countSlots(host, this.getUsedPorts(host.publicHost)); } - /** Auslastung pro Host: Anzahl Port-Paare insgesamt und belegt. */ + /** Auslastung pro Host: Anzahl Serverplätze insgesamt und belegt. */ hostStats() { return this.hosts.map((h) => { - const total = Math.floor((h.portRangeEnd - h.portRangeStart + 1) / 2); + const total = countSlots(h, new Set()); return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) }; }); } diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 0801aef..586b2c1 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -314,7 +314,12 @@ export class ServerManager { const match = this.service.getMatch(matchId); try { - const result = await this.adapter.start({ matchId, mode: match.mode, secrets }); + const result = await this.adapter.start({ + matchId, + mode: match.mode, + secrets, + teamNames: { A: match.team_a_name, B: match.team_b_name }, + }); this.update(matchId, { handle: JSON.stringify(result.handle), host: result.host, diff --git a/server/test/dockerAdapter.test.js b/server/test/dockerAdapter.test.js index f6cb2d1..449ca3d 100644 --- a/server/test/dockerAdapter.test.js +++ b/server/test/dockerAdapter.test.js @@ -39,7 +39,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async ( assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve'); assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback'); assert.equal(env.CS2_PORT, '27100'); - assert.equal(env.TV_PORT, '27101'); + assert.equal(env.TV_PORT, '27200'); assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated')); assert.equal(r.host, '192.168.178.111'); @@ -74,7 +74,7 @@ test('Zweiter Container startet erst, wenn der erste seinen Update-Check abgesch d.finishUpdate(first.handle.container); const r2 = await second; assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2); - assert.equal(r2.port, 27102, 'zweiter Container bekommt die nächsten Ports'); + assert.equal(r2.port, 27101, 'zweiter Container bekommt den nächsten Port'); d.finishUpdate(r2.handle.container); }); diff --git a/server/test/dockerHostPool.test.js b/server/test/dockerHostPool.test.js index 100ab29..3ee030d 100644 --- a/server/test/dockerHostPool.test.js +++ b/server/test/dockerHostPool.test.js @@ -81,11 +81,11 @@ test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => { assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a')); }); -test('Auslastung pro Host: Port-Paare insgesamt und belegt', () => { +test('Auslastung pro Host: Serverplätze insgesamt und belegt', () => { const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); assert.deepEqual(p.hostStats(), [ - { name: 'a', publicHost: '10.0.0.1', total: 2, used: 1 }, - { name: 'b', publicHost: '10.0.0.2', total: 20, used: 0 }, + { name: 'a', publicHost: '10.0.0.1', total: 4, used: 1 }, + { name: 'b', publicHost: '10.0.0.2', total: 40, used: 0 }, ]); }); test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => { diff --git a/server/test/lanPorts.test.js b/server/test/lanPorts.test.js new file mode 100644 index 0000000..6394062 --- /dev/null +++ b/server/test/lanPorts.test.js @@ -0,0 +1,95 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatPortList, parsePortList } from '../src/config.js'; +import { countSlots, DockerHostPool, planPorts } from '../src/servers/dockerAdapter.js'; + +const host = { portRangeStart: 27100, portRangeEnd: 27105, lanPorts: [27015, 27016] }; +const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' }; + +test('Portlisten werden gelesen und kompakt geschrieben', () => { + assert.deepEqual(parsePortList('27015-27017, 27020'), [27015, 27016, 27017, 27020]); + assert.deepEqual(parsePortList(''), []); + assert.throws(() => parsePortList('abc'), /kein Port/); + assert.throws(() => parsePortList('80-90'), /ungültig/); + assert.equal(formatPortList([27020, 27015, 27016, 27017]), '27015-27017,27020'); +}); + +test('Spielport kommt zuerst aus den LAN-Ports, CSTV liegt immer 100 darüber', () => { + assert.deepEqual(planPorts(host, new Set()), { port: 27015, tvPort: 27115 }); + assert.deepEqual(planPorts(host, new Set([27015, 27115])), { port: 27016, tvPort: 27116 }); + // LAN-Ports voll: weiter im normalen Bereich, CSTV ebenfalls +100 + assert.deepEqual(planPorts(host, new Set([27015, 27016, 27115, 27116])), { port: 27100, tvPort: 27200 }); + // Spielport frei, aber sein CSTV-Port belegt: nächster Spielport + assert.deepEqual(planPorts({ ...host, lanPorts: [] }, new Set([27200])), { port: 27101, tvPort: 27201 }); + assert.equal(planPorts({ ...host, lanPorts: [] }, new Set([27100, 27101, 27102, 27103, 27104, 27105])), null); +}); + +test('Abstand zwischen Spiel- und CSTV-Port ist einstellbar', () => { + assert.deepEqual(planPorts({ ...host, tvPortOffset: 5 }, new Set()), { port: 27015, tvPort: 27020 }); +}); + +test('Plätze pro Host: 2 LAN-Ports plus 6 Spielports im normalen Bereich', () => { + assert.equal(countSlots(host, new Set()), 8); + assert.equal(countSlots({ ...host, lanPorts: [] }, new Set()), 6); +}); + +function fakePool({ busy = [] } = {}) { + const calls = []; + const runDocker = async (args) => { + calls.push(args); + if (args[0] === 'run') { + const ports = args.filter((a, i) => args[i - 1] === '-p').map((a) => Number(a.split(':')[0])); + const portEnv = args.find((a) => a.startsWith('CS2_PORT=')); + if (args.includes('--network') && portEnv) ports.push(Number(portEnv.split('=')[1])); + if (ports.some((p) => busy.includes(p))) { + const err = new Error('Command failed'); + err.stderr = 'Bind for 0.0.0.0:27015 failed: port is already allocated'; + throw err; + } + } + if (args[0] === 'inspect') return 'running'; + return ''; + }; + const p = new DockerHostPool({ + bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9', + portRangeStart: 27100, portRangeEnd: 27139, gslt: '', volume: 'v', extraArgs: '', + startTimeoutMinutes: 1, lanPorts: [27015, 27016], serverNamePrefix: 'MaxLAN', + hosts: [{ name: 'a', publicHost: '10.0.0.1' }], + }, { runDocker }); + for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {}; + return { pool: p, calls }; +} + +test('Servername zeigt die Teams, Spielport ist ein LAN-Port', async () => { + const { pool: p, calls } = fakePool(); + const r = await p.start({ matchId: 'm1', mode: 'wingman', secrets, teamNames: { A: 'Rote "Füchse"', B: 'Blau' } }); + assert.equal(r.port, 27015); + assert.equal(r.tvPort, 27115); + const run = calls.find((c) => c[0] === 'run'); + assert.ok(run.includes('CS2_SERVERNAME=MaxLAN | Rote Füchse vs. Blau')); + // Auf LAN-Ports im Host-Netz, damit Broadcasts des Serverbrowsers ankommen; keine Port-Weiterleitung + assert.equal(run[run.indexOf('--network') + 1], 'host'); + assert.ok(!run.includes('-p')); +}); + +test('Server außerhalb der LAN-Ports laufen weiter mit Port-Weiterleitung', async () => { + const { pool: p, calls } = fakePool(); + await p.start({ matchId: 'x1', mode: 'wingman', secrets, teamNames: { A: 'A', B: 'B' } }); + await p.start({ matchId: 'x2', mode: 'wingman', secrets, teamNames: { A: 'A', B: 'B' } }); + calls.length = 0; + // Die App kennt ihre Ports aus der Datenbank; hier sind beide LAN-Ports belegt + const adapter = p.adapters.get('a'); + adapter.getUsedPorts = () => new Set([27015, 27016, 27115, 27116]); + await adapter.launch({ matchId: 'x3', mode: 'wingman', secrets }); + const run = calls.find((c) => c[0] === 'run'); + assert.ok(!run.includes('--network')); + assert.ok(run.includes('27100:27100/udp')); + assert.ok(run.includes('27200:27200/udp')); +}); + +test('Ist ein LAN-Port von einem fremden Dienst belegt, nimmt der Start den nächsten', async () => { + const { pool: p, calls } = fakePool({ busy: [27015] }); + const r = await p.start({ matchId: 'm2', mode: 'competitive', secrets, teamNames: { A: 'A', B: 'B' } }); + assert.equal(r.port, 27016); + assert.equal(calls.filter((c) => c[0] === 'run').length, 2); +}); diff --git a/server/test/serverSettings.test.js b/server/test/serverSettings.test.js index c7addc4..bcca7d2 100644 --- a/server/test/serverSettings.test.js +++ b/server/test/serverSettings.test.js @@ -52,9 +52,9 @@ test('Docker-Hosts: Prüfung der Liste und Übernahme in den Pool, Startzeit wir { name: 'a', publicHost: '10.0.0.1', dockerHost: 'ssh://server@10.0.0.1', portStart: 27100, portEnd: 27139 }, { name: 'b', publicHost: '10.0.0.2', dockerHost: 'ssh://b', portStart: 27100, portEnd: 27119 }, ]); - const pool = new DockerHostPool({ ...config.docker, startTimeoutMinutes: 20, hosts: null }); + const pool = new DockerHostPool({ ...config.docker, startTimeoutMinutes: 20, lanPorts: [], hosts: null }); pool.setHosts(hosts); - assert.deepEqual(pool.hostStats().map((h) => [h.name, h.total]), [['a', 20], ['b', 10]]); + assert.deepEqual(pool.hostStats().map((h) => [h.name, h.total]), [['a', 40], ['b', 20]]); pool.options.startTimeoutMinutes = 45; assert.equal(pool.adapters.get('b').options.startTimeoutMinutes, 45);