From fc432744e5a1b4479d58631424567c7933d101cb Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 18:02:49 -0700 Subject: [PATCH] Ports: whether each port is reachable from outside --- src/features/ports/ListenersPage.tsx | 4 +- src/features/ports/ReachabilityCard.tsx | 157 ++++++++++++++++++++++++ src/features/ports/reachability.test.ts | 84 +++++++++++++ src/features/ports/reachability.ts | 84 +++++++++++++ 4 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/features/ports/ReachabilityCard.tsx create mode 100644 src/features/ports/reachability.test.ts create mode 100644 src/features/ports/reachability.ts diff --git a/src/features/ports/ListenersPage.tsx b/src/features/ports/ListenersPage.tsx index 398c17b..051aa1b 100644 --- a/src/features/ports/ListenersPage.tsx +++ b/src/features/ports/ListenersPage.tsx @@ -7,13 +7,15 @@ import { useState } from 'react'; import { DynamicList } from '@/components/lists/DynamicList'; import { PortsTable } from './PortsTable'; +import { ReachabilityCard } from './ReachabilityCard'; -/** Network › Listeners: the standard ports as a table, then every listener, refreshed on a change. */ +/** Network › Listeners: the standard ports as a table, whether they're reachable, then every listener, refreshed on a change. */ export function ListenersPage({ viewName }: { viewName: string }) { const [version, setVersion] = useState(0); return (
setVersion((v) => v + 1)} /> +
); diff --git a/src/features/ports/ReachabilityCard.tsx b/src/features/ports/ReachabilityCard.tsx new file mode 100644 index 0000000..53bc67c --- /dev/null +++ b/src/features/ports/ReachabilityCard.tsx @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: under the ports table, whether each port can be reached from + * outside (settings-reorg, Ports). In a cluster, each node as the other + * nodes see it over the real network, refreshed by the server every ten + * minutes. On a single server, only whether each port is listening, and + * said plainly: a server can't see its own firewall from inside. + */ + +import { useEffect, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, CircleDashed, Globe, Loader2, RefreshCw, TriangleAlert, XCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/services/api'; +import { cellFor, oldestCheck, type CellState, type ReachabilityAnswer } from './reachability'; + +const ICON: Record = { + reachable: , + partly: , + blocked: , + pending: , +}; + +async function fetchAnswer(): Promise { + const res = await apiFetch('/api/ports/check'); + if (!res.ok) { + const problem = (await res.json().catch(() => ({}))) as { detail?: string; title?: string }; + throw new Error(problem.detail ?? problem.title ?? `The check failed (${res.status}).`); + } + return (await res.json()) as ReachabilityAnswer; +} + +export function ReachabilityCard() { + const { t } = useTranslation(); + const [answer, setAnswer] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const load = async () => { + setBusy(true); + setError(null); + try { + setAnswer(await fetchAnswer()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + useEffect(() => { + let live = true; + fetchAnswer() + .then((a) => live && setAnswer(a)) + .catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e))); + return () => { + live = false; + }; + }, []); + + const oldest = answer ? oldestCheck(answer) : null; + + return ( +
+
+ +

{t('reachability.title', 'Reachable from outside')}

+ +
+ + {error &&

{error}

} + + {answer?.mode === 'cluster' && ( + <> +

+ {t( + 'reachability.clusterBody', + 'Each server tries the others’ ports every {{minutes}} minutes, over the internet, by their public names.', + { minutes: Math.round(answer.intervalSeconds / 60) }, + )} + {oldest !== null && + ` ${t('reachability.since', 'Oldest result: {{time}}.', { time: new Date(oldest * 1000).toLocaleTimeString() })}`} +

+
+ + + + + {answer.nodes.map((n) => ( + + ))} + + + + {answer.ports.map((port) => ( + + + {answer.nodes.map((n) => { + const cell = cellFor(n, port); + return ( + + ); + })} + + ))} + +
{t('ports.port', 'Port')} + {n.hostname} +
{port} + + {ICON[cell.state]} + + {cell.detail} + + +
+
+ + )} + + {answer?.mode === 'local' && ( + <> +

+ {t( + 'reachability.localBody', + 'A single server can’t see its own firewall from inside, so this only says whether each port is listening here. To check from outside, try from another network: nc -vz .', + )} +

+ {answer.listening.length === 0 ? ( +

{t('reachability.none', 'No listeners to check.')}

+ ) : ( +
    + {answer.listening.map((l) => ( +
  • + {l.listening ? ICON.reachable : ICON.blocked} + {l.port} + + {l.listening + ? t('reachability.listening', 'listening on this server') + : t('reachability.notListening', 'not listening on this server')} + +
  • + ))} +
+ )} + + )} +
+ ); +} diff --git a/src/features/ports/reachability.test.ts b/src/features/ports/reachability.test.ts new file mode 100644 index 0000000..24c9ce1 --- /dev/null +++ b/src/features/ports/reachability.test.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { cellFor, oldestCheck, type ProbeReport } from './reachability'; + +const report = (probes: [number, string, boolean, string?][], at = 100): ProbeReport => ({ + checked_at: at, + probes: probes.map(([port, address, ok, error]) => ({ port, address, ok, error })), +}); + +describe('cellFor', () => { + it('is reachable when every other node got through', () => { + const node = { + seenBy: [ + { prober: 'mx2.inbuxa.com', report: report([[25, '192.0.2.1', true]]) }, + { prober: 'mx3.inbuxa.com', report: report([[25, '192.0.2.1', true]]) }, + ], + }; + expect(cellFor(node, 25)).toEqual({ state: 'reachable', detail: 'Reached from mx2, mx3' }); + }); + + it('names who could not get through, and why', () => { + const node = { + seenBy: [ + { prober: 'mx2.inbuxa.com', report: report([[25, '192.0.2.1', true]]) }, + { + prober: 'mx3.inbuxa.com', + report: report([ + [25, '192.0.2.1', true], + [25, '2001:db8::1', false, 'no answer within 5 seconds'], + ]), + }, + ], + }; + expect(cellFor(node, 25)).toEqual({ + state: 'partly', + detail: 'Reached from mx2. Not from mx3: 2001:db8::1 no answer within 5 seconds', + }); + }); + + it('is blocked from everyone, pending before a first round, and says when a name does not resolve', () => { + expect( + cellFor({ seenBy: [{ prober: 'mx2.x', report: report([[587, '192.0.2.1', false, 'Connection refused']]) }] }, 587) + .state, + ).toBe('blocked'); + expect(cellFor({ seenBy: [{ prober: 'mx2.x', report: null }] }, 25)).toEqual({ + state: 'pending', + detail: 'Not tried yet', + }); + expect( + cellFor( + { seenBy: [{ prober: 'mx2.x', report: { checked_at: 1, probes: [], error: 'mail.x doesn’t resolve' } }] }, + 25, + ), + ).toEqual({ state: 'blocked', detail: 'Not from mx2: mail.x doesn’t resolve' }); + }); +}); + +describe('oldestCheck', () => { + it('finds the oldest round, and nothing for a single server', () => { + expect( + oldestCheck({ + mode: 'cluster', + ports: [25], + intervalSeconds: 600, + nodes: [ + { hostname: 'a', seenBy: [{ prober: 'b', report: report([], 300) }] }, + { + hostname: 'b', + seenBy: [ + { prober: 'a', report: report([], 200) }, + { prober: 'c', report: null }, + ], + }, + ], + }), + ).toBe(200); + expect(oldestCheck({ mode: 'local', ports: [], listening: [] })).toBeNull(); + }); +}); diff --git a/src/features/ports/reachability.ts b/src/features/ports/reachability.ts new file mode 100644 index 0000000..b02f572 --- /dev/null +++ b/src/features/ports/reachability.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: what GET /api/ports/check answers, turned into cells (settings-reorg, + * Ports). In a cluster each node is tried from the others, over the real + * network; a single server can only say whether a port is listening. + */ + +export interface Probe { + port: number; + address: string; + ok: boolean; + error?: string; +} + +export interface ProbeReport { + checked_at: number; + probes: Probe[]; + error?: string; +} + +export type ReachabilityAnswer = + | { + mode: 'cluster'; + ports: number[]; + intervalSeconds: number; + nodes: { hostname: string; seenBy: { prober: string; report: ProbeReport | null }[] }[]; + } + | { + mode: 'local'; + ports: number[]; + listening: { port: number; address: string; listening: boolean }[]; + }; + +export type CellState = 'reachable' | 'partly' | 'blocked' | 'pending'; + +export interface Cell { + state: CellState; + /** Who could connect, who couldn't and why, in words. */ + detail: string; +} + +function short(hostname: string): string { + return hostname.split('.')[0]; +} + +/** One node and port: reachable from every other node, from some, from none, or not tried yet. */ +export function cellFor(node: { seenBy: { prober: string; report: ProbeReport | null }[] }, port: number): Cell { + const ok: string[] = []; + const failed: string[] = []; + let tried = 0; + for (const { prober, report } of node.seenBy) { + if (!report) continue; + if (report.error) { + tried++; + failed.push(`${short(prober)}: ${report.error}`); + continue; + } + const probes = report.probes.filter((p) => p.port === port); + if (probes.length === 0) continue; + tried++; + // A port counts as reachable from a prober when every address it resolved to answered. + const bad = probes.filter((p) => !p.ok); + if (bad.length === 0) ok.push(short(prober)); + else failed.push(`${short(prober)}: ${bad.map((p) => `${p.address} ${p.error ?? 'no answer'}`).join('; ')}`); + } + if (tried === 0) return { state: 'pending', detail: 'Not tried yet' }; + const parts = []; + if (ok.length > 0) parts.push(`Reached from ${ok.join(', ')}`); + if (failed.length > 0) parts.push(`Not from ${failed.join('; ')}`); + const state: CellState = failed.length === 0 ? 'reachable' : ok.length === 0 ? 'blocked' : 'partly'; + return { state, detail: parts.join('. ') }; +} + +/** The oldest check behind the answer, so the page can say how fresh it is. */ +export function oldestCheck(answer: ReachabilityAnswer): number | null { + if (answer.mode !== 'cluster') return null; + const times = answer.nodes.flatMap((n) => n.seenBy.flatMap((s) => (s.report ? [s.report.checked_at] : []))); + return times.length > 0 ? Math.min(...times) : null; +} -- 2.54.0