Diagnose network button: raw TCP probe matrix

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 09:48:27 -04:00
co-authored by Claude Opus 4.7
parent 08de5a4064
commit 1e9d08f27e
3 changed files with 335 additions and 0 deletions
+93
View File
@@ -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<TcpProbeResult> {
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<TcpProbeResult>((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<TcpProbeResult[]> {
// 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)));
}
@@ -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
});
};
+165
View File
@@ -19,6 +19,29 @@
url?: string; 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 { interface Server {
name: string; name: string;
komodo_type: string; komodo_type: string;
@@ -95,6 +118,8 @@
let komodoProbe = $state<KomodoProbeResult | null>(null); let komodoProbe = $state<KomodoProbeResult | null>(null);
let probing = $state(false); let probing = $state(false);
let netDiagnosis = $state<NetDiagnosis | null>(null);
let diagnosing = $state(false);
async function probeKomodo(): Promise<void> { async function probeKomodo(): Promise<void> {
if (probing || !name) return; if (probing || !name) return;
@@ -123,6 +148,22 @@
probing = false; probing = false;
} }
} }
async function diagnoseNetwork(): Promise<void> {
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;
}
}
</script> </script>
{#if server} {#if server}
@@ -191,6 +232,52 @@
</div> </div>
{/if} {/if}
<div class="probe-row">
<button class="probe-btn" onclick={diagnoseNetwork} disabled={diagnosing}>
{diagnosing ? 'diagnosing…' : 'diagnose network'}
</button>
<span class="probe-hint">Raw TCP reachability probe across candidate hosts.</span>
</div>
{#if netDiagnosis}
{#if netDiagnosis.ok && netDiagnosis.results}
<table class="net-table">
<thead>
<tr>
<th>trial</th>
<th>resolved</th>
<th>outcome</th>
<th class="num">elapsed</th>
<th>detail</th>
</tr>
</thead>
<tbody>
{#each netDiagnosis.results as r (r.label)}
<tr>
<td class="mono">{r.label}</td>
<td class="mono">{r.resolvedIp ?? '—'}</td>
<td><span class="net-tag net-{r.outcome}">{r.outcome}</span></td>
<td class="mono num">{r.elapsedMs}ms</td>
<td class="mono detail-cell">{r.detail ?? ''}</td>
</tr>
{/each}
</tbody>
</table>
{#if netDiagnosis.config}
<div class="net-meta">
KOMODO_ADDRESS = <code>{netDiagnosis.config.komodoAddress ?? '(unset)'}</code>
&nbsp;·&nbsp; RCON_HOST = <code>{netDiagnosis.config.rconHost}</code>
&nbsp;·&nbsp; probe timeout = <code>{netDiagnosis.config.probeTimeoutMs}ms</code>
</div>
{/if}
{:else}
<div class="probe-result probe-bad">
<span class="probe-tag bad">error</span>
<span class="probe-detail">{netDiagnosis.error ?? 'network diagnostic failed'}</span>
</div>
{/if}
{/if}
{#if komodoProbe} {#if komodoProbe}
{#if komodoProbe.ok} {#if komodoProbe.ok}
<div class="probe-result probe-ok"> <div class="probe-result probe-ok">
@@ -536,6 +623,84 @@
word-break: break-all; 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 { .snippet-details {
margin-top: 10px; margin-top: 10px;
font-family: var(--font-mono); font-family: var(--font-mono);