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)));
|
||||
}
|
||||
Reference in New Issue
Block a user