Files
Homelab-manager/web/src/components/ServerPorts.tsx
T
bobbanandClaude Sonnet 5 4c11158e98 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>
2026-09-26 02:39:43 +02:00

506 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}