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>
506 lines
20 KiB
TypeScript
506 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|