Compare commits

...
15 changed files with 324 additions and 45 deletions

No files matched your search

+6
View File
@@ -56,6 +56,12 @@ CS2_DOCKER_EXTRA_ARGS=
# Einrichtung pro Host: bash deploy/docker-host-einrichten.sh <ssh-ziel> # Einrichtung pro Host: bash deploy/docker-host-einrichten.sh <ssh-ziel>
# CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}] # 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 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 DOCKER_BIN=docker
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) --- # --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
+10
View File
@@ -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. 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`) ### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
Für selbst betriebene, dauerhaft laufende CS2-Server: Für selbst betriebene, dauerhaft laufende CS2-Server:
+3 -1
View File
@@ -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 = [ const HOST_COLUMNS = [
{ key: 'name', label: 'Name', placeholder: 'host1' }, { key: 'name', label: 'Name', placeholder: 'host1' },
{ key: 'dockerHost', label: 'Docker-Ziel', placeholder: 'ssh://server@192.168.178.45' }, { 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: 'publicHost', label: 'Adresse für Spieler', placeholder: '192.168.178.45' },
{ key: 'portStart', label: 'Port von', type: 'number' }, { key: 'portStart', label: 'Port von', type: 'number' },
{ key: 'portEnd', label: 'Port bis', 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' }, { 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 } : {}), ...(r.rconHost && r.rconHost !== r.publicHost ? { rconHost: r.rconHost } : {}),
portStart: Number(r.portStart), portStart: Number(r.portStart),
portEnd: Number(r.portEnd), portEnd: Number(r.portEnd),
lanPorts: String(r.lanPorts ?? '').trim(),
volume: r.volume.trim() || undefined, volume: r.volume.trim() || undefined,
})), })),
}, },
+1 -1
View File
@@ -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-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); } .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 { 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) { @media (max-width: 900px) {
.host-edit-row { grid-template-columns: 1fr 1fr; } .host-edit-row { grid-template-columns: 1fr 1fr; }
.host-col-dockerHost { grid-column: 1 / -1; } .host-col-dockerHost { grid-column: 1 / -1; }
+2 -1
View File
@@ -39,6 +39,7 @@ Fertig. Diesen Eintrag in CS2_DOCKER_HOSTS (JSON-Liste in der .env) aufnehmen:
Hinweise: Hinweise:
- name muss pro Host eindeutig sein. - 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. - Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange.
EOF EOF
+2
View File
@@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import { formatPortList } from './config.js';
import { HttpError } from './matchService.js'; import { HttpError } from './matchService.js';
import { createRateLimiter, randomToken, safeEqual } from './security.js'; import { createRateLimiter, randomToken, safeEqual } from './security.js';
@@ -96,6 +97,7 @@ export function createAdminRouter({ service, manager, config, settings }) {
portStart: h.portRangeStart, portStart: h.portRangeStart,
portEnd: h.portRangeEnd, portEnd: h.portRangeEnd,
volume: h.volume, volume: h.volume,
lanPorts: formatPortList(h.lanPorts),
})), })),
overridden: settings.hostsOverridden(), overridden: settings.hostsOverridden(),
editable: !!manager.adapter.setHosts, editable: !!manager.adapter.setHosts,
+36
View File
@@ -22,6 +22,34 @@ function bool(name, fallback) {
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase()); 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) { export function parseDockerHosts(raw) {
if (!raw) return null; if (!raw) return null;
let list; let list;
@@ -44,6 +72,7 @@ export function parseDockerHosts(raw) {
portRangeStart: h.portStart == null ? undefined : Number(h.portStart), portRangeStart: h.portStart == null ? undefined : Number(h.portStart),
portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd), portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd),
volume: h.volume ?? undefined, 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'), volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'), extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20), 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. // 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}] // [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')), hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')),
+6
View File
@@ -160,6 +160,12 @@ export class ServerSettings {
throw new HttpError(400, `Ungültige Adresse für ${h.name}`, 'invalid'); 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'); 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 start = h.portRangeStart ?? this.config.docker.portRangeStart;
const end = h.portRangeEnd ?? this.config.docker.portRangeEnd; const end = h.portRangeEnd ?? this.config.docker.portRangeEnd;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || end - start < 1) { if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || end - start < 1) {
+100 -28
View File
@@ -2,8 +2,39 @@ import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js'; import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js'; import { runRcon } from './rcon.js';
import { sanitizeConsoleText } from './cs2Config.js';
const exec = promisify(execFile); 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; const DISK_CACHE_MS = 60_000;
/** Liest Gesamt- und freien Speicher aus einer Zeile von `df -B1` (Spalten: Filesystem, 1B-blocks, Used, Available …). */ /** 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(); return stdout.trim();
} }
allocatePort() { allocatePorts(blocked = new Set()) {
const used = this.getUsedPorts(); const used = new Set([...this.getUsedPorts(), ...blocked]);
for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) { const plan = planPorts(this.options, used);
if (!used.has(p) && !used.has(p + 1)) return p; if (!plan) throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
} return plan;
throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END'); }
/** 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) { async start(args) {
@@ -108,13 +146,34 @@ export class DockerServerAdapter {
} }
} }
async launch({ matchId, mode, secrets }) { /**
const port = this.allocatePort(); * Im Host-Netz meldet Docker keinen Portkonflikt, CS2 startet dann einfach nicht. Deshalb vorher per SSH
const tvPort = port + 1; * 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 container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = { const env = {
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }), ...(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_PORT: String(port),
CS2_RCONPW: secrets.rconPassword, CS2_RCONPW: secrets.rconPassword,
CS2_PW: secrets.serverPassword, CS2_PW: secrets.serverPassword,
@@ -133,19 +192,32 @@ export class DockerServerAdapter {
}; };
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1']; const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`); for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
args.push( if (lan) {
'-p', `${port}:${port}/tcp`, // Der LAN-Reiter von Steam sucht per Broadcast. Die Port-Weiterleitung von Docker reicht Broadcasts
'-p', `${port}:${port}/udp`, // nicht an den Container weiter, deshalb laufen Server auf LAN-Ports direkt im Netz des Hosts.
'-p', `${tvPort}:${tvPort}/udp`, args.push('--network', 'host');
'-v', `${this.options.volume}:/home/steam/cs2-dedicated`, } 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)); if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
args.push(this.options.image); args.push(this.options.image);
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt. // Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
await this.docker(['rm', '-f', container]).catch(() => {}); await this.docker(['rm', '-f', container]).catch(() => {});
// Das erste "docker run" kann das Image laden und dauert dann länger. // 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 { return {
handle: { handle: {
@@ -250,6 +322,8 @@ export class DockerHostPool {
portRangeStart: h.portRangeStart ?? options.portRangeStart, portRangeStart: h.portRangeStart ?? options.portRangeStart,
portRangeEnd: h.portRangeEnd ?? options.portRangeEnd, portRangeEnd: h.portRangeEnd ?? options.portRangeEnd,
volume: h.volume ?? options.volume, volume: h.volume ?? options.volume,
lanPorts: h.lanPorts ?? options.lanPorts ?? [],
tvPortOffset: options.tvPortOffset ?? 100,
}; };
const merged = { ...options, ...host, host: host.publicHost }; const merged = { ...options, ...host, host: host.publicHost };
// Die Startzeit kann der Admin zur Laufzeit ändern, deshalb immer aus den gemeinsamen Einstellungen lesen // 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); return this.diskInflight.get(name);
} }
/** Freie Port-Paare auf einem Host. */ /** Wie viele Server noch auf einen Host passen. */
freeSlots(host) { freeSlots(host) {
const used = this.getUsedPorts(host.publicHost); return countSlots(host, 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;
} }
/** Auslastung pro Host: Anzahl Port-Paare insgesamt und belegt. */ /** Auslastung pro Host: Anzahl Serverplätze insgesamt und belegt. */
hostStats() { hostStats() {
return this.hosts.map((h) => { 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) }; return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
}); });
} }
@@ -378,8 +447,11 @@ export class DockerHostPool {
adapterFor(handle) { adapterFor(handle) {
const name = handle?.hostName ?? this.hosts[0].name; const name = handle?.hostName ?? this.hosts[0].name;
const adapter = this.adapters.get(name); const adapter = this.adapters.get(name);
if (!adapter) throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`); if (adapter) return adapter;
return adapter; // Server aus der Zeit vor der Host-Liste (Name "default") oder nach einer Umbenennung: über die Adresse finden
const byAddress = handle?.rconHost && this.hosts.find((h) => h.rconHost === handle.rconHost || h.publicHost === handle.rconHost);
if (byAddress) return this.adapters.get(byAddress.name);
throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
} }
async status(handle) { async status(handle) {
+12 -5
View File
@@ -314,7 +314,12 @@ export class ServerManager {
const match = this.service.getMatch(matchId); const match = this.service.getMatch(matchId);
try { 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, { this.update(matchId, {
handle: JSON.stringify(result.handle), handle: JSON.stringify(result.handle),
host: result.host, host: result.host,
@@ -387,7 +392,8 @@ export class ServerManager {
maybeConfigure(matchId) { maybeConfigure(matchId) {
const match = this.service.getMatch(matchId); const match = this.service.getMatch(matchId);
const row = this.row(matchId); const row = this.row(matchId);
if (match.status === 'server_setup' && row?.status === 'ready') { // Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
this.configure(matchId).catch((err) => this.logger.error(err)); this.configure(matchId).catch((err) => this.logger.error(err));
} }
} }
@@ -462,7 +468,7 @@ export class ServerManager {
await this.provision(matchId); await this.provision(matchId);
return; return;
} }
if (row.status === 'config_failed' || (row.status === 'ready' && match.status === 'server_setup')) { if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration'); this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
this.configure(matchId).catch((err) => this.logger.error(err)); this.configure(matchId).catch((err) => this.logger.error(err));
return; return;
@@ -586,11 +592,12 @@ export class ServerManager {
continue; continue;
} }
const webActive = this.service.presence(row.match_id).size > 0; const webActive = this.service.presence(row.match_id).size > 0;
let lastUse = match.last_activity_at; // Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
if (row.status === 'configured' && row.player_count !== null) { if (row.status === 'configured' && row.player_count !== null) {
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist. // Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
if (row.player_count > 0) continue; if (row.player_count > 0) continue;
lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0); lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0, row.started_at ?? 0);
} else if (webActive) { } else if (webActive) {
continue; continue;
} }
+31
View File
@@ -477,3 +477,34 @@ test('Spieler verlassen die Lobby, Captain geht über', async () => {
await close(); await close();
} }
}); });
test('Neustart im laufenden Match: Map wird wieder geladen, Server läuft die volle Frist', async () => {
const { app, call, close } = await setup({ idleTimeoutMinutes: 30 });
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
await app.manager.poll();
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
// Lange nichts los, dann stoppt der Server
const hourAgo = Date.now() - 60 * 60_000;
app.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(hourAgo, id);
await call('POST', `/matches/${id}/server/stop`, { token: org });
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'gestoppt' });
// Neustart durch den Organisator: Server fährt hoch und lädt die Map wieder (Match ist weiter "live")
const res = await call('POST', `/matches/${id}/server/retry`, { token: org });
assert.equal(res.status, 200, JSON.stringify(res.body));
await waitFor(async () => { await app.manager.poll(); return app.service.serverRow(id).status === 'configured'; }, { label: 'neu konfiguriert' });
assert.equal(app.service.getMatch(id).status, 'live');
// Die Abschaltung darf den frisch gestarteten Server nicht sofort wieder stoppen
await app.manager.reap();
assert.equal(app.service.serverRow(id).status, 'configured');
// Nach Ablauf der Frist ab dem Neustart dagegen schon
await app.manager.reap(Date.now() + 31 * 60_000);
assert.equal(app.service.serverRow(id).status, 'stopped');
} finally {
await close();
}
});
+2 -2
View File
@@ -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_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback'); assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
assert.equal(env.CS2_PORT, '27100'); 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.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
assert.equal(r.host, '192.168.178.111'); 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); d.finishUpdate(first.handle.container);
const r2 = await second; const r2 = await second;
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2); 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); d.finishUpdate(r2.handle.container);
}); });
+16 -5
View File
@@ -72,8 +72,8 @@ test('Status, Stopp und Befehle gehen an den Host des Matches', async () => {
await p.stop(r.handle); await p.stop(r.handle);
assert.deepEqual(calls.at(-1)[0], 'rm'); assert.deepEqual(calls.at(-1)[0], 'rm');
assert.equal(calls.at(-1).at(-1), r.handle.container); assert.equal(calls.at(-1).at(-1), r.handle.container);
// Ein Handle von einem Host, der nicht mehr konfiguriert ist, wird abgelehnt // Ein Handle von einem Host, der weder per Name noch per Adresse bekannt ist, wird abgelehnt
await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht' }), /nicht \(mehr\) konfiguriert/); await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
}); });
test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => { test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => {
@@ -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')); 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] }); const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] });
assert.deepEqual(p.hostStats(), [ assert.deepEqual(p.hostStats(), [
{ name: 'a', publicHost: '10.0.0.1', total: 2, used: 1 }, { name: 'a', publicHost: '10.0.0.1', total: 4, used: 1 },
{ name: 'b', publicHost: '10.0.0.2', total: 20, used: 0 }, { name: 'b', publicHost: '10.0.0.2', total: 40, used: 0 },
]); ]);
}); });
test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => { test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => {
@@ -96,3 +96,14 @@ test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => {
}); });
assert.equal(parseDf('kaputt'), null); assert.equal(parseDf('kaputt'), null);
}); });
test('Alter Handle ohne passenden Host-Namen wird über die Adresse dem richtigen Host zugeordnet', async () => {
const docker = fakeDocker();
const p = new DockerHostPool(options([
{ ...hostA, rconHost: '10.0.0.1' },
{ ...hostB, rconHost: '10.0.0.2' },
]), { runDocker: docker.run });
await p.stop({ container: 'cs2mv-alt', hostName: 'default', rconHost: '10.0.0.2', port: 27100 });
assert.deepEqual(docker.calls.at(-1), ['rm', '-f', 'cs2mv-alt']);
assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b');
assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
});
+95
View File
@@ -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);
});
+2 -2
View File
@@ -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: '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 }, { 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); 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; pool.options.startTimeoutMinutes = 45;
assert.equal(pool.adapters.get('b').options.startTimeoutMinutes, 45); assert.equal(pool.adapters.get('b').options.startTimeoutMinutes, 45);