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