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>
94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
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)));
|
|
}
|