Add a Ports card to server pages: scan for open ports, find free ones, and keep notes

Each server's detail page now has a Ports card. "Scan…" runs a TCP connect
scan of a chosen range from the app and shows what's open, along with the
ranges that were actually confirmed free; clicking a free range starts a
reservation. Any port can carry a service name and a comment, so the page
also answers "what is this port for". A port with a note counts as taken
even when nothing is listening, which is what makes a reservation work.
Operators can scan and edit; everyone can read. Scans and note changes are
audit-logged.

Details that matter for correctness:
- "Free" means the host actively refused the connection AND nobody has
  claimed the port. A port that never answers (firewall drop, host down)
  is reported as not answering, not as free.
- A scan from elsewhere can't see services bound to localhost only, so the
  agent now also reports what is bound on the host (ss -tulnp) and those
  ports are treated as taken. They show as "local only". Existing agents
  keep working; re-run the install one-liner to add this. The field is
  validated leniently so one odd line can never cost an agent its whole
  report, tasks included.
- If nothing answers at all during a scan, existing results are left
  alone instead of being marked all-closed.
- Scan targets are limited to private addresses (RFC1918, Tailscale
  100.64/10, link-local, IPv6 ULA/link-local); loopback and public
  addresses are refused. Ranges are capped at 20,000 ports, and only one
  scan runs per server at a time.
- Rows exist only while they carry information: an open port, or one with
  a note. A closed port with no note disappears on the next scan; one with
  a note stays as "reserved".

New table server_ports plus two columns on servers (migration 0009).

Verified with 76 backend checks (scanner open/refused/filtered, address
rules, agent report leniency, note/reserve/clear semantics, free-range
calculation including the localhost-only case, roles, concurrency lock,
no-response guard, audit entries, cascade delete) and by driving the real
component against the real router in a browser. Real dev database mtime
untouched.

Not verified: the agent's ss/awk/jq pipeline on a real host — the awk step
was checked against sample ss output and the script passes bash -n, but
jq isn't available here to run the whole thing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-26 02:39:43 +02:00
1 parent aae4f0d74f
commit 4c11158e98
14 files changed
+2521 -1

No files matched your search

+46
View File
@@ -346,6 +346,44 @@ export interface ServerDetail {
links: ServerLink[];
}
export interface PortEntry {
/** Null for a port only known from the agent that has no note yet. */
id: number | null;
port: number;
protocol: "tcp" | "udp";
label: string | null;
comment: string | null;
/** The last scan from this app connected to it. */
scanOpen: boolean;
lastSeenOpenAt: string | null;
agent: { addresses: string[]; process: string | null; localOnly: boolean } | null;
state: "open" | "reserved";
}
export interface PortScanSummary {
at: string;
address: string;
from: number;
to: number;
open: number;
refused: number;
filtered: number;
responded: boolean;
}
export interface PortList {
ports: PortEntry[];
agentReporting: boolean;
agentReportedAt: string | null;
lastScan: PortScanSummary | null;
}
export interface PortScanResult extends Omit<PortList, "lastScan"> {
scan: PortScanSummary;
freeCount: number;
freeRanges: [number, number][];
}
export interface ServerUpdateInput {
name?: string;
hostname?: string;
@@ -803,6 +841,14 @@ export const api = {
request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
removeLink: (id: number, linkId: number) =>
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }),
ports: {
list: (id: number) => request<PortList>(`/api/servers/${id}/ports`),
scan: (id: number, data: { address: string; from: number; to: number }) =>
request<PortScanResult>(`/api/servers/${id}/ports/scan`, { method: "POST", body: JSON.stringify(data) }),
save: (id: number, data: { port: number; protocol: "tcp" | "udp"; label?: string; comment?: string }) =>
request<{ ports: PortEntry[] }>(`/api/servers/${id}/ports`, { method: "PUT", body: JSON.stringify(data) }),
remove: (id: number, portId: number) => request<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
},
},
tasks: {
list: (
+505
View File
@@ -0,0 +1,505 @@
import { useEffect, useMemo, useState } from "react";
import { api, type PortEntry, type PortList, type PortScanResult } from "../api/client";
import { sortItems, type SortDirection } from "../hooks/useSortable";
import { usePagination } from "../hooks/usePagination";
import { formatDateTime } from "../utils/date";
import { downloadCsv } from "../utils/csv";
import { readableError } from "../utils/errors";
import Pagination from "./Pagination";
import SortableTh from "./SortableTh";
const PRESETS: { key: string; label: string; from: number; to: number }[] = [
{ key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 },
{ key: "common", label: "1–10000 (most services)", from: 1, to: 10000 },
{ key: "high1", label: "10001–30000", from: 10001, to: 30000 },
{ key: "high2", label: "30001–50000", from: 30001, to: 50000 },
{ key: "high3", label: "50001–65535", from: 50001, to: 65535 },
{ key: "custom", label: "Custom range…", from: 8000, to: 8999 },
];
const MAX_SHOWN_RANGES = 24;
interface NoteForm {
editingId: number | null;
port: string;
protocol: "tcp" | "udp";
label: string;
comment: string;
}
function rangeText([from, to]: [number, number]) {
return from === to ? String(from) : `${from}–${to}`;
}
function StatusBadges({ entry }: { entry: PortEntry }) {
if (entry.state === "reserved") return <span className="badge bg-secondary-lt text-secondary">Reserved</span>;
return (
<div className="d-flex flex-wrap gap-1">
{entry.scanOpen && (
<span className="badge bg-green-lt text-green" title="A scan from this app connected to it">
Reachable
</span>
)}
{entry.agent && (
<span className="badge bg-blue-lt text-blue" title={`Bound on: ${entry.agent.addresses.join(", ")}`}>
Listening
</span>
)}
{entry.agent?.localOnly && (
<span className="badge bg-yellow-lt text-yellow" title="Bound to localhost only — other machines can't connect, but the port is still taken">
local only
</span>
)}
</div>
);
}
export default function ServerPorts({
serverId,
addresses,
canEdit,
}: {
serverId: number;
/** Addresses this server is known by, offered as scan targets. */
addresses: string[];
canEdit: boolean;
}) {
const [data, setData] = useState<PortList | null>(null);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState<keyof PortEntry | undefined>("port");
const [sortDir, setSortDir] = useState<SortDirection>("asc");
const [showScan, setShowScan] = useState(false);
const [address, setAddress] = useState("");
const [presetKey, setPresetKey] = useState("common");
const [customFrom, setCustomFrom] = useState("8000");
const [customTo, setCustomTo] = useState("8999");
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<PortScanResult | null>(null);
const [showAllRanges, setShowAllRanges] = useState(false);
const [note, setNote] = useState<NoteForm | null>(null);
const [savingNote, setSavingNote] = useState(false);
useEffect(() => {
api.servers.ports
.list(serverId)
.then(setData)
.catch((err) => setError(readableError(err)));
}, [serverId]);
// Default the scan target to the server's first known address once we have one.
useEffect(() => {
if (!address && addresses.length > 0) setAddress(addresses[0]);
}, [addresses, address]);
const visible = useMemo(() => {
if (!data) return null;
const q = filter.trim().toLowerCase();
const rows = q
? data.ports.filter((p) =>
[String(p.port), p.protocol, p.label, p.comment, p.agent?.process].some((v) => v?.toLowerCase().includes(q)),
)
: data.ports;
return sortItems(rows, sortKey, sortDir);
}, [data, filter, sortKey, sortDir]);
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(visible);
function requestSort(key: keyof PortEntry) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir("asc");
}
}
const preset = PRESETS.find((p) => p.key === presetKey)!;
const from = presetKey === "custom" ? Number(customFrom) : preset.from;
const to = presetKey === "custom" ? Number(customTo) : preset.to;
async function runScan(e: React.FormEvent) {
e.preventDefault();
setError(null);
setScanning(true);
setResult(null);
setShowAllRanges(false);
try {
const res = await api.servers.ports.scan(serverId, { address: address.trim(), from, to });
setResult(res);
setData({ ports: res.ports, agentReporting: res.agentReporting, agentReportedAt: res.agentReportedAt, lastScan: res.scan });
} catch (err) {
setError(readableError(err));
} finally {
setScanning(false);
}
}
function openNote(initial: Partial<NoteForm> = {}) {
setError(null);
setNote({ editingId: null, port: "", protocol: "tcp", label: "", comment: "", ...initial });
}
function editEntry(entry: PortEntry) {
openNote({
editingId: entry.id ?? -1, // -1: a port with no row yet — a note creates one
port: String(entry.port),
protocol: entry.protocol,
label: entry.label ?? "",
comment: entry.comment ?? "",
});
}
async function saveNote(e: React.FormEvent) {
e.preventDefault();
if (!note) return;
setSavingNote(true);
setError(null);
try {
const res = await api.servers.ports.save(serverId, {
port: Number(note.port),
protocol: note.protocol,
label: note.label.trim(),
comment: note.comment.trim(),
});
setData((prev) => (prev ? { ...prev, ports: res.ports } : prev));
// A port that was just reserved is no longer "available" in the last scan's result.
setResult((prev) => {
if (!prev) return prev;
const port = Number(note.port);
const ranges = prev.freeRanges.flatMap(([a, b]): [number, number][] => {
if (port < a || port > b) return [[a, b]];
const out: [number, number][] = [];
if (port > a) out.push([a, port - 1]);
if (port < b) out.push([port + 1, b]);
return out;
});
const removed = prev.freeRanges.some(([a, b]) => port >= a && port <= b);
return { ...prev, freeRanges: ranges, freeCount: prev.freeCount - (removed && note.protocol === "tcp" ? 1 : 0) };
});
setNote(null);
} catch (err) {
setError(readableError(err));
} finally {
setSavingNote(false);
}
}
async function removeNote(entry: PortEntry) {
if (entry.id === null) return;
const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`;
if (!confirm(what)) return;
try {
await api.servers.ports.remove(serverId, entry.id);
setData(await api.servers.ports.list(serverId));
} catch (err) {
setError(readableError(err));
}
}
function exportCsv() {
if (!visible) return;
downloadCsv(
"ports.csv",
["Port", "Protocol", "Status", "Service", "Process", "Comment"],
visible.map((p) => [p.port, p.protocol, p.state, p.label ?? "", p.agent?.process ?? "", p.comment ?? ""]),
);
}
const shownRanges = result ? (showAllRanges ? result.freeRanges : result.freeRanges.slice(0, MAX_SHOWN_RANGES)) : [];
const editing = note !== null && note.editingId !== null;
return (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Ports</h3>
<div className="card-actions btn-list flex-nowrap">
{data && data.ports.length > 0 && (
<button className="btn btn-outline-secondary btn-sm" onClick={exportCsv}>
Export CSV
</button>
)}
{canEdit && (
<>
<button className="btn btn-outline-secondary btn-sm" onClick={() => openNote()}>
Add note
</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowScan((s) => !s)}>
Scan…
</button>
</>
)}
</div>
</div>
<div className="card-body pb-2">
{error && <div className="alert alert-danger">{error}</div>}
{data && (
<div className="text-secondary small mb-2">
{data.agentReporting ? (
<>Listening ports reported by the agent{data.agentReportedAt ? ` as of ${formatDateTime(new Date(data.agentReportedAt))}` : ""}. </>
) : (
<>
The agent hasn't reported listening ports (update it to see services bound to localhost only, which a scan can't
detect).{" "}
</>
)}
{data.lastScan ? (
<>
Last scan {formatDateTime(new Date(data.lastScan.at))}: {data.lastScan.address}, ports {data.lastScan.from}–
{data.lastScan.to}.
</>
) : (
<>Not scanned yet.</>
)}
</div>
)}
{canEdit && showScan && (
<form onSubmit={runScan} className="row g-2 align-items-end border rounded p-3 mb-3">
<div className="col-md-4">
<label className="form-label">Address to scan</label>
<input
className="form-control"
required
list={`scan-addresses-${serverId}`}
placeholder="e.g. 10.0.0.5"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
<datalist id={`scan-addresses-${serverId}`}>
{addresses.map((a) => (
<option key={a} value={a} />
))}
</datalist>
</div>
<div className="col-md-4">
<label className="form-label">Ports</label>
<select className="form-select" value={presetKey} onChange={(e) => setPresetKey(e.target.value)}>
{PRESETS.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</div>
{presetKey === "custom" && (
<div className="col-md-2 d-flex gap-1 align-items-end">
<input
type="number"
className="form-control"
min={1}
max={65535}
required
aria-label="First port"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
/>
<span className="pb-2">–</span>
<input
type="number"
className="form-control"
min={1}
max={65535}
required
aria-label="Last port"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
/>
</div>
)}
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100" disabled={scanning || !address.trim()}>
{scanning ? "Scanning…" : "Scan"}
</button>
</div>
<div className="col-12 text-secondary small">
A TCP scan from this app: it can see what's reachable from here, not services bound to localhost or blocked by a
firewall. Only private addresses can be scanned, up to 20,000 ports at a time.
</div>
</form>
)}
{result && (
<div className="border rounded p-3 mb-3">
{!result.scan.responded ? (
<div className="text-warning">
Nothing answered on {result.scan.address} — the host may be down, or a firewall is dropping everything. Existing
results were left as they were.
</div>
) : (
<>
<div className="mb-2">
<strong>{result.scan.open}</strong> open in {result.scan.from}–{result.scan.to} on {result.scan.address}.{" "}
<strong>{result.freeCount}</strong> available
{result.scan.filtered > 0 && (
<span className="text-secondary">
{" "}
({result.scan.filtered} didn't answer at all, so can't be called available)
</span>
)}
.
</div>
{result.freeRanges.length > 0 ? (
<div className="d-flex flex-wrap gap-1 align-items-center">
{shownRanges.map((r) => (
<button
key={r[0]}
type="button"
className="btn btn-sm btn-outline-secondary"
title="Reserve this port"
disabled={!canEdit}
onClick={() => openNote({ port: String(r[0]) })}
>
{rangeText(r)}
</button>
))}
{result.freeRanges.length > MAX_SHOWN_RANGES && (
<button type="button" className="btn btn-link btn-sm" onClick={() => setShowAllRanges((s) => !s)}>
{showAllRanges ? "Show fewer" : `Show all ${result.freeRanges.length} ranges`}
</button>
)}
</div>
) : (
<div className="text-secondary">No port in this range was confirmed free.</div>
)}
{result.freeRanges.length > 0 && canEdit && (
<div className="text-secondary small mt-2">Click a range to reserve a port from it with a note.</div>
)}
</>
)}
</div>
)}
{canEdit && note && (
<form onSubmit={saveNote} className="row g-2 align-items-end border rounded p-3 mb-3">
<div className="col-md-2">
<label className="form-label">Port</label>
<input
type="number"
className="form-control"
min={1}
max={65535}
required
disabled={editing}
value={note.port}
onChange={(e) => setNote({ ...note, port: e.target.value })}
/>
</div>
<div className="col-md-2">
<label className="form-label">Protocol</label>
<select
className="form-select"
disabled={editing}
value={note.protocol}
onChange={(e) => setNote({ ...note, protocol: e.target.value as "tcp" | "udp" })}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div className="col-md-3">
<label className="form-label">Service</label>
<input
className="form-control"
maxLength={100}
placeholder="e.g. Grafana"
value={note.label}
onChange={(e) => setNote({ ...note, label: e.target.value })}
/>
</div>
<div className="col-md-5">
<label className="form-label">Comment</label>
<input
className="form-control"
maxLength={500}
placeholder="e.g. proxied by nginx, or: reserved for the new monitoring stack"
value={note.comment}
onChange={(e) => setNote({ ...note, comment: e.target.value })}
/>
</div>
<div className="col-12 d-flex gap-2">
<button type="submit" className="btn btn-primary btn-sm" disabled={savingNote}>
{editing ? "Save" : "Add"}
</button>
<button type="button" className="btn btn-sm" onClick={() => setNote(null)}>
Cancel
</button>
{!editing && <span className="text-secondary small align-self-center">A port with a note counts as taken, even when nothing is listening.</span>}
</div>
</form>
)}
{data && data.ports.length > 0 && (
<input
className="form-control form-control-sm mb-2"
style={{ maxWidth: 280 }}
placeholder="Filter by port, service, or comment"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
)}
</div>
{data && data.ports.length === 0 && (
<div className="card-body pt-0 text-secondary">
No ports recorded yet. {canEdit ? "Run a scan to find what's open, or add a note to reserve a port." : ""}
</div>
)}
{pageItems && pageItems.length > 0 && (
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<PortEntry> label="Port" sortKeyName="port" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PortEntry> label="Proto" sortKeyName="protocol" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PortEntry> label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PortEntry> label="Service" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PortEntry> label="Comment" sortKeyName="comment" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{pageItems.map((p) => (
<tr key={`${p.protocol}:${p.port}`}>
<td>
<code>{p.port}</code>
</td>
<td className="text-secondary">{p.protocol}</td>
<td>
<StatusBadges entry={p} />
</td>
<td>
{p.label ?? <span className="text-secondary">—</span>}
{p.agent?.process && <div className="text-secondary small">{p.agent.process}</div>}
</td>
<td className="text-secondary">{p.comment ?? "—"}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm btn-outline-secondary" onClick={() => editEntry(p)}>
{p.label || p.comment ? "Edit" : "Add note"}
</button>
{(p.label || p.comment) && (
<button className="btn btn-sm btn-outline-danger" onClick={() => removeNote(p)}>
{p.state === "reserved" ? "Remove" : "Clear"}
</button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{visible && visible.length === 0 && data && data.ports.length > 0 && (
<div className="card-body pt-0 text-secondary">Nothing matches that filter.</div>
)}
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
);
}
+7
View File
@@ -11,6 +11,7 @@ import {
type ServerLink,
type TaskRecord,
} from "../api/client";
import ServerPorts from "../components/ServerPorts";
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
import { formatDateTime } from "../utils/date";
@@ -685,6 +686,12 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
</div>
</div>
<ServerPorts
serverId={serverId}
addresses={[...ipAddresses, ...(server.hostname && !ipAddresses.includes(server.hostname) ? [server.hostname] : [])]}
canEdit={canEditTasks}
/>
<div className="d-flex align-items-center mb-3">
<h3 className="mb-0">Tasks</h3>
{canEditTasks && (