From 1e9d08f27e62aa8ef60f939f3b4bee4633214afb Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 09:48:27 -0400 Subject: [PATCH] Diagnose network button: raw TCP probe matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Komodo probe answers a specific question (can we resolve a port from Komodo's API), but leaves ambiguity when it fails: is DNS broken, is the route blocked, is the target port unreachable, or is the target refusing? A 10-second timeout looks the same for all of those. Adds a second button, "diagnose network", that fires parallel raw TCP connect attempts (no HTTP, no auth, no bytes sent) at a fixed matrix of candidate hosts against the configured Komodo port: - 127.0.0.1 (sanity — container loopback) - host.docker.internal (bridge-gateway alias) - 172.17.0.1 (raw default bridge gateway) - the KOMODO_ADDRESS host (if different) - RCON_HOST (if different) Each row shows resolved IP, outcome (connected/refused/timeout/dns-fail/ unreachable/error), elapsed ms, and detail. Together they pinpoint which network layer the traffic dies at without guessing. New helper: src/lib/server/integrations/netProbe.ts — DNS lookup + net. createConnection with explicit timeout, classifying errors by errno (ECONNREFUSED, EHOSTUNREACH, ENOTFOUND). Runs trials in parallel so the whole diagnostic completes in one probe-timeout budget. Co-Authored-By: Claude Opus 4.7 --- src/lib/server/integrations/netProbe.ts | 93 ++++++++++ .../[name]/diagnose-network/+server.ts | 77 ++++++++ src/routes/servers/[name]/+page.svelte | 165 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 src/lib/server/integrations/netProbe.ts create mode 100644 src/routes/api/servers/[name]/diagnose-network/+server.ts diff --git a/src/lib/server/integrations/netProbe.ts b/src/lib/server/integrations/netProbe.ts new file mode 100644 index 0000000..da5ca12 --- /dev/null +++ b/src/lib/server/integrations/netProbe.ts @@ -0,0 +1,93 @@ +import { createConnection } from 'node:net'; +import { lookup } from 'node:dns/promises'; + +export type TcpProbeOutcome = + | 'connected' + | 'refused' + | 'timeout' + | 'dns-fail' + | 'unreachable' + | 'error'; + +export interface TcpProbeResult { + label: string; + host: string; + port: number; + resolvedIp: string | null; + outcome: TcpProbeOutcome; + elapsedMs: number; + detail?: string; +} + +const DEFAULT_TIMEOUT_MS = 3000; + +/** + * Best-effort DNS resolution. Returns null on failure so the caller can + * distinguish "DNS failed" from "connect failed". + */ +async function resolve(host: string): Promise<{ ip: string | null; error: string | null }> { + try { + const r = await lookup(host); + return { ip: r.address, error: null }; + } catch (err) { + const e = err as NodeJS.ErrnoException; + return { ip: null, error: `${e.code ?? 'UNKNOWN'}: ${e.message}` }; + } +} + +/** + * Attempt a raw TCP connect to `host:port` with an explicit timeout, and + * classify the result. Purely diagnostic — no bytes are sent after the + * handshake completes. Isolates network reachability from every HTTP-layer + * concern (auth, TLS, redirects, wrong path). + */ +export async function tcpProbe( + label: string, + host: string, + port: number, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise { + const started = Date.now(); + const { ip, error: dnsError } = await resolve(host); + + if (!ip) { + return { + label, + host, + port, + resolvedIp: null, + outcome: 'dns-fail', + elapsedMs: Date.now() - started, + detail: dnsError ?? 'DNS lookup returned no address' + }; + } + + return new Promise((resolve) => { + const sock = createConnection({ host: ip, port, timeout: timeoutMs }); + let settled = false; + const finish = (outcome: TcpProbeOutcome, detail?: string): void => { + if (settled) return; + settled = true; + try { sock.destroy(); } catch { /* already destroyed */ } + resolve({ label, host, port, resolvedIp: ip, outcome, elapsedMs: Date.now() - started, detail }); + }; + sock.once('connect', () => finish('connected')); + sock.once('timeout', () => finish('timeout', `no SYN-ACK within ${timeoutMs}ms`)); + sock.once('error', (err) => { + const code = (err as NodeJS.ErrnoException).code ?? ''; + const msg = (err as Error).message ?? String(err); + if (code === 'ECONNREFUSED') return finish('refused', code); + if (code === 'EHOSTUNREACH' || code === 'ENETUNREACH') return finish('unreachable', `${code}: ${msg}`); + return finish('error', `${code || 'unknown'}: ${msg}`); + }); + }); +} + +export async function tcpProbeAll( + trials: Array<{ label: string; host: string; port: number }>, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise { + // Run in parallel — they don't share sockets and the whole point is to + // finish fast even when some probes hit the timeout. + return Promise.all(trials.map((t) => tcpProbe(t.label, t.host, t.port, timeoutMs))); +} diff --git a/src/routes/api/servers/[name]/diagnose-network/+server.ts b/src/routes/api/servers/[name]/diagnose-network/+server.ts new file mode 100644 index 0000000..c52f488 --- /dev/null +++ b/src/routes/api/servers/[name]/diagnose-network/+server.ts @@ -0,0 +1,77 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getServer } from '$lib/server/fleet/servers.js'; +import { config } from '$lib/server/config.js'; +import { tcpProbeAll } from '$lib/server/integrations/netProbe.js'; + +/** + * Multi-target TCP reachability probe. Fires parallel connect attempts at a + * fixed matrix of candidate hosts + the configured KOMODO_ADDRESS host, so + * we can see at a glance which network paths reach anything and where + * packets die (DNS, routing, or refused-at-port). + */ +export const POST: RequestHandler = async ({ params }) => { + const name = params.name; + if (!name) return json({ ok: false, error: 'missing server name' }, { status: 400 }); + + const row = getServer(name); + if (!row) return json({ ok: false, error: `unknown server: ${name}` }, { status: 404 }); + + // Parse the configured Komodo URL to extract host+port. + let komodoHost: string | null = null; + let komodoPort: number | null = null; + if (config.komodo.address) { + try { + const u = new URL(config.komodo.address); + komodoHost = u.hostname; + komodoPort = u.port ? Number.parseInt(u.port, 10) : u.protocol === 'https:' ? 443 : 80; + } catch { + /* leave null */ + } + } + + const probePort = komodoPort ?? 9120; + + // Build the trial matrix. Every trial hits the same target port (the + // Komodo port) via a different network path, so we can see which paths + // reach the same service. + const trials: Array<{ label: string; host: string; port: number }> = [ + { label: `127.0.0.1:${probePort} (container loopback)`, host: '127.0.0.1', port: probePort }, + { label: `host.docker.internal:${probePort} (bridge gateway alias)`, host: 'host.docker.internal', port: probePort }, + { label: `172.17.0.1:${probePort} (default bridge gateway)`, host: '172.17.0.1', port: probePort } + ]; + + if (komodoHost && !trials.some((t) => t.host === komodoHost)) { + trials.push({ + label: `${komodoHost}:${probePort} (KOMODO_ADDRESS)`, + host: komodoHost, + port: probePort + }); + } + + // RCON's default host is worth testing too — even though RCON is UDP, + // if TCP to the same host also fails, it's a routing/firewall issue, + // not a UDP-specific one. + const rconHost = row.rcon_host ?? config.rcon.host; + if (rconHost && !trials.some((t) => t.host === rconHost)) { + trials.push({ + label: `${rconHost}:${probePort} (RCON_HOST)`, + host: rconHost, + port: probePort + }); + } + + const results = await tcpProbeAll(trials, config.httpTimeoutMs); + + return json({ + ok: true, + config: { + komodoAddress: config.komodo.address || null, + komodoHost, + komodoPort, + rconHost, + probeTimeoutMs: config.httpTimeoutMs + }, + results + }); +}; diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index b1e15dc..092f1ea 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -19,6 +19,29 @@ url?: string; } + type NetOutcome = 'connected' | 'refused' | 'timeout' | 'dns-fail' | 'unreachable' | 'error'; + interface NetTrial { + label: string; + host: string; + port: number; + resolvedIp: string | null; + outcome: NetOutcome; + elapsedMs: number; + detail?: string; + } + interface NetDiagnosis { + ok: boolean; + config?: { + komodoAddress: string | null; + komodoHost: string | null; + komodoPort: number | null; + rconHost: string; + probeTimeoutMs: number; + }; + results?: NetTrial[]; + error?: string; + } + interface Server { name: string; komodo_type: string; @@ -95,6 +118,8 @@ let komodoProbe = $state(null); let probing = $state(false); + let netDiagnosis = $state(null); + let diagnosing = $state(false); async function probeKomodo(): Promise { if (probing || !name) return; @@ -123,6 +148,22 @@ probing = false; } } + + async function diagnoseNetwork(): Promise { + if (diagnosing || !name) return; + diagnosing = true; + netDiagnosis = null; + try { + const res = await fetch(`/api/servers/${encodeURIComponent(name)}/diagnose-network`, { + method: 'POST' + }); + netDiagnosis = (await res.json()) as NetDiagnosis; + } catch (err) { + netDiagnosis = { ok: false, error: (err as Error).message }; + } finally { + diagnosing = false; + } + } {#if server} @@ -191,6 +232,52 @@ {/if} +
+ + Raw TCP reachability probe across candidate hosts. +
+ + {#if netDiagnosis} + {#if netDiagnosis.ok && netDiagnosis.results} + + + + + + + + + + + + {#each netDiagnosis.results as r (r.label)} + + + + + + + + {/each} + +
trialresolvedoutcomeelapseddetail
{r.label}{r.resolvedIp ?? '—'}{r.outcome}{r.elapsedMs}ms{r.detail ?? ''}
+ {#if netDiagnosis.config} +
+ KOMODO_ADDRESS = {netDiagnosis.config.komodoAddress ?? '(unset)'} +  ·  RCON_HOST = {netDiagnosis.config.rconHost} +  ·  probe timeout = {netDiagnosis.config.probeTimeoutMs}ms +
+ {/if} + {:else} +
+ error + {netDiagnosis.error ?? 'network diagnostic failed'} +
+ {/if} + {/if} + {#if komodoProbe} {#if komodoProbe.ok}
@@ -536,6 +623,84 @@ word-break: break-all; } + .net-table { + width: 100%; + margin-top: 14px; + border-collapse: separate; + border-spacing: 0; + background: var(--cherry-975); + border: 1px solid var(--cherry-700); + border-radius: var(--r-sm); + overflow: hidden; + font-size: 12px; + } + .net-table thead th { + text-align: left; + padding: 8px 12px; + font-family: var(--font-body); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--ink-500); + border-bottom: 1px solid var(--cherry-700); + background: var(--cherry-925); + } + .net-table tbody td { + padding: 8px 12px; + border-bottom: 1px solid rgba(74, 15, 30, 0.4); + color: var(--ink-100); + vertical-align: top; + } + .net-table tbody tr:last-child td { border-bottom: none; } + .net-table .mono { font-family: var(--font-mono); } + .net-table .num { text-align: right; } + .net-table .detail-cell { + color: var(--ink-300); + font-size: 11px; + word-break: break-word; + max-width: 320px; + } + + .net-tag { + display: inline-block; + padding: 2px 8px; + border-radius: var(--r-sm); + font-family: var(--font-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + border: 1px solid transparent; + } + .net-connected { + background: rgba(34, 224, 255, 0.15); + border-color: rgba(34, 224, 255, 0.4); + color: var(--spark-500); + } + .net-refused { + background: rgba(255, 176, 32, 0.15); + border-color: rgba(255, 176, 32, 0.4); + color: var(--amber-500); + } + .net-timeout, .net-unreachable, .net-dns-fail, .net-error { + background: rgba(255, 48, 80, 0.15); + border-color: rgba(255, 48, 80, 0.4); + color: var(--danger-500); + } + + .net-meta { + margin-top: 10px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-500); + } + .net-meta code { + color: var(--cream); + background: var(--cherry-875); + padding: 1px 6px; + border-radius: var(--r-sm); + } + .snippet-details { margin-top: 10px; font-family: var(--font-mono);