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:
@@ -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
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
· RCON_HOST = <code>{netDiagnosis.config.rconHost}</code>
|
||||||
|
· 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user