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
@@ -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
});
};