Ports: whether each port is reachable from outside #55
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user