Replace IW4MAdmin with direct RCON for player counts

IW4M kept hitting a network wall (container -> host LAN IP -> IW4M
loopback bind) that we couldn't cleanly punch through, and the two-minute
!rt token added enough operational friction that the pause-all-deployments
gate never got a fair test. RCON gives us the same fact — how many
players are on a server right now — direct from each game server over
UDP, with no session, no token, no proxy.

- Add integrations/rcon.ts: dgram UDP client. rconGetPlayerCount() and
  rconGetStatus() parse both `status` (print + scoreboard) and
  `statusResponse` (info line + rows) dialects. Timeout via
  RCON_TIMEOUT_MS.
- Add komodoResolveGamePort() + extractUdpPort(). Reads Komodo's read
  API for a server, walks both docker-compose-style port objects and
  docker-run-style "host:container/udp" strings, caches into
  servers.game_port on first success.
- Replace pipelineReady() guard in sweep with a simple
  integrations-configured check. Same held/failed/manual outcomes,
  but keyed on RCON+Komodo instead of IW4M+auth-state.
- Worker's status refresh fans out per-server over RCON at concurrency=4
  so a 15-server fleet doesn't spike UDP.
- Schema: drop iw4m_match/iw4m_value, add game_port + rcon_host.
- Delete every auth artifact: TokenModal, auth store, auth-state module,
  /api/auth/token, /api/events/auth, iw4m.test.ts. Layout no longer
  mounts the modal, pipeline store no longer tracks a frozen state.
- README + .env.example + docker-compose.yml + komodo/stack.toml swap
  IW4M_* env for RCON_PASSWORD / RCON_HOST / RCON_TIMEOUT_MS. Same
  host.docker.internal:host-gateway extra_hosts entry, repurposed
  for RCON reach.
- Tests: add rcon.test.ts and komodo.test.ts, update sweep.test.ts to
  drop the auth-freeze case. 23/23 passing.
This commit is contained in:
2026-09-12 01:33:06 -04:00
parent f118c179cd
commit 854f5532a9
27 changed files with 530 additions and 730 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { deployments, frozen, frozenReason } from '$ui/stores/pipeline.js';
import { deployments } from '$ui/stores/pipeline.js';
// Newest first, up to 8 rows on the dashboard.
let rows = $derived(
@@ -32,9 +32,6 @@
<div class="wrapper">
<header>
<h3>Pipeline</h3>
{#if $frozen}
<span class="frozen">frozen — {$frozenReason ?? 'no reason'}</span>
{/if}
</header>
{#if rows.length === 0}
<p class="empty">no active deployments</p>
@@ -77,7 +74,6 @@
margin-bottom: 8px;
}
h3 { margin: 0; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; }
.frozen { color: #ff6060; font-size: 12px; }
.empty { color: #666; font-size: 13px; margin: 0; }
.rows { display: grid; gap: 8px; }
.row {
-160
View File
@@ -1,160 +0,0 @@
<script lang="ts">
import { authState, msRemaining, shouldBlock, submitToken } from '$ui/stores/auth.js';
let value = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
async function onSubmit(e: Event) {
e.preventDefault();
if (submitting) return;
submitting = true;
error = null;
const res = await submitToken(value);
submitting = false;
if (!res.ok) {
error = res.error;
return;
}
value = '';
}
// Reactively re-open the modal when we cross the warning threshold. The
// server pushes msRemaining every 5s; that's enough for the UI to notice
// "about to expire" and coax the user in-game before things freeze.
let warnOpen = $state(false);
$effect(() => {
if ($authState.kind === 'ready' && $msRemaining < 20_000 && $msRemaining > 0) {
warnOpen = true;
} else {
warnOpen = false;
}
});
let showModal = $derived($shouldBlock || warnOpen);
</script>
{#if showModal}
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="tm-title">
<div class="modal">
<h2 id="tm-title">
{#if $shouldBlock}
Deployments paused
{:else}
Token expiring
{/if}
</h2>
<p class="explain">
{#if $authState.kind === 'missing'}
Run <code>!rt</code> in-game to generate a 4-character token, then paste it here.
{:else if $authState.kind === 'stale'}
The last token was {$authState.reason}. Generate a new one with <code>!rt</code> in-game.
{:else}
Your token expires in {Math.ceil($msRemaining / 1000)}s. Paste a new one to keep deployments flowing.
{/if}
</p>
<form onsubmit={onSubmit}>
<input
type="text"
inputmode="text"
autocomplete="off"
autocorrect="off"
spellcheck="false"
maxlength="4"
minlength="4"
pattern="[a-zA-Z0-9]{'{'}4{'}'}"
placeholder="aB3d"
bind:value
disabled={submitting}
aria-label="4-character !rt token"
/>
<button type="submit" disabled={submitting || value.length !== 4}>
{submitting ? '…' : 'unfreeze'}
</button>
</form>
{#if error}
<p class="error">{error}</p>
{/if}
<p class="hint">
The <code>!rt</code> token is single-use and dies after 2 minutes. That's normal.
</p>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: grid;
place-items: center;
z-index: 9999;
backdrop-filter: blur(2px);
}
.modal {
background: #111;
color: #eee;
border: 1px solid #444;
border-radius: 8px;
padding: 24px;
min-width: 360px;
max-width: 460px;
font-family: system-ui, -apple-system, sans-serif;
}
h2 {
margin: 0 0 8px;
font-size: 18px;
color: #ff6060;
}
.explain {
margin: 0 0 16px;
font-size: 14px;
color: #ccc;
line-height: 1.4;
}
form {
display: flex;
gap: 8px;
}
input {
flex: 1;
background: #000;
color: #eee;
border: 1px solid #666;
border-radius: 4px;
padding: 10px 12px;
font-size: 20px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
letter-spacing: 8px;
text-align: center;
}
button {
background: #ff3030;
color: #fff;
border: 0;
border-radius: 4px;
padding: 0 16px;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.error {
color: #ff6060;
margin: 12px 0 0;
font-size: 13px;
}
.hint {
margin: 16px 0 0;
font-size: 12px;
color: #888;
}
code {
background: #222;
padding: 1px 5px;
border-radius: 3px;
}
</style>