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:
1 parent
aae4f0d74f
commit
4c11158e98
14 files changed
+2521
-1
No files matched your search
@@ -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: (
|
||||
|
||||
Reference in new issue
Block a user