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
@@ -220,6 +220,8 @@ export const servers = sqliteTable("servers", {
|
||||
memTotalBytes: integer("mem_total_bytes"),
|
||||
memUsedBytes: integer("mem_used_bytes"),
|
||||
disks: text("disks"), // JSON string: {mount, sizeBytes, usedBytes}[]
|
||||
listeningPorts: text("listening_ports"), // JSON string: {protocol, port, address, process}[] — what the agent sees bound on the host
|
||||
lastPortScan: text("last_port_scan"), // JSON string: summary of the most recent network scan from this app
|
||||
|
||||
// Optional link to a Proxmox VM/LXC — set by an admin, not the agent.
|
||||
proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, {
|
||||
@@ -300,3 +302,26 @@ export const integrations = sqliteTable("integrations", {
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// A port on a server that's either been seen open (by a scan or the agent) or that someone wrote a note about.
|
||||
// Rows exist only while they carry information: an open port, or one with a label/comment ("reserved").
|
||||
export const serverPorts = sqliteTable(
|
||||
"server_ports",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
port: integer("port").notNull(),
|
||||
protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"),
|
||||
label: text("label"),
|
||||
comment: text("comment"),
|
||||
// True when the last network scan connected to it. The agent's view is stored on the server row instead.
|
||||
open: integer("open", { mode: "boolean" }).notNull().default(false),
|
||||
lastSeenOpenAt: text("last_seen_open_at"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
},
|
||||
(t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)],
|
||||
);
|
||||
Reference in new issue
Block a user