Ports: whether each port is reachable from outside #55

Merged
jcoffey-dev merged 1 commits from feature/port-reachability into main 2026-09-29 01:07:29 +00:00
4 changed files with 328 additions and 1 deletions

No files matched your search

+3 -1
View File
@@ -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 (
<div className="space-y-4">
<PortsTable onChanged={() => setVersion((v) => v + 1)} />
<ReachabilityCard key={`reach-${version}`} />
<DynamicList key={version} viewName={viewName} />
</div>
);
+157
View File
@@ -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<CellState, ReactNode> = {
reachable: <CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />,
partly: <TriangleAlert className="h-4 w-4 text-amber-600 dark:text-amber-400" />,
blocked: <XCircle className="h-4 w-4 text-destructive" />,
pending: <CircleDashed className="h-4 w-4 text-muted-foreground" />,
};
async function fetchAnswer(): Promise<ReachabilityAnswer> {
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<ReachabilityAnswer | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-3 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center gap-3">
<Globe className="h-5 w-5 text-primary" />
<p className="flex-1 font-medium">{t('reachability.title', 'Reachable from outside')}</p>
<Button type="button" variant="ghost" size="sm" onClick={() => void load()} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{answer?.mode === 'cluster' && (
<>
<p className="text-sm text-muted-foreground">
{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() })}`}
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="py-1 pr-3 font-medium">{t('ports.port', 'Port')}</th>
{answer.nodes.map((n) => (
<th key={n.hostname} className="py-1 pr-3 font-medium">
{n.hostname}
</th>
))}
</tr>
</thead>
<tbody>
{answer.ports.map((port) => (
<tr key={port} className="border-t">
<td className="py-1.5 pr-3 font-mono">{port}</td>
{answer.nodes.map((n) => {
const cell = cellFor(n, port);
return (
<td key={n.hostname} className="py-1.5 pr-3">
<span className="flex items-start gap-1.5" title={cell.detail}>
<span className="mt-0.5 shrink-0">{ICON[cell.state]}</span>
<span className={cell.state === 'reachable' ? 'text-muted-foreground' : ''}>
{cell.detail}
</span>
</span>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{answer?.mode === 'local' && (
<>
<p className="text-sm text-muted-foreground">
{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 <this server’s name> <port>.',
)}
</p>
{answer.listening.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('reachability.none', 'No listeners to check.')}</p>
) : (
<ul className="space-y-1 text-sm">
{answer.listening.map((l) => (
<li key={`${l.address}:${l.port}`} className="flex items-center gap-2">
{l.listening ? ICON.reachable : ICON.blocked}
<span className="font-mono">{l.port}</span>
<span className="text-muted-foreground">
{l.listening
? t('reachability.listening', 'listening on this server')
: t('reachability.notListening', 'not listening on this server')}
</span>
</li>
))}
</ul>
)}
</>
)}
</div>
);
}
+84
View File
@@ -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();
});
});
+84
View File
@@ -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;
}