Add a Network > Ports page: agent-reported ports plus manual openings

Summarizes every server's agent-reported listening ports in one
cross-server table (grouped by protocol+port, addresses merged,
loopback-only flagged) - previously this only existed per-server on
each server's own detail page.

Adds a second table for ports this app has no way to see on its own:
manually-recorded openings on a router, edge firewall, or cloud
security group, each with a label, external port/protocol, an optional
link to a tracked server (with its own internal port when NAT changes
it) or a freeform destination, a free-text source, and a comment.
Viewer-readable; adding/editing/deleting needs operator or admin.

The agent-port grouping logic (dedupe by protocol+port, detect
loopback-only sockets) was shared with the existing per-server Ports
card via a new agentPorts.ts service instead of duplicating it.

Verified with a real HTTP-level test: a genuine Express app with the
actual routers, a scratch SQLite DB, and forged admin/viewer sessions,
covering grouping correctness, the server-name join, input validation,
and role enforcement - the real dev DB was confirmed untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-29 23:48:35 +02:00
1 parent 4e48377348
commit 236b1da0dc
14 files changed
+2486 -39

No files matched your search

+27
View File
@@ -331,6 +331,33 @@ export const serverPorts = sqliteTable(
(t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)],
);
// A manually-recorded port opening on something this app doesn't monitor directly — a router's port forward, an
// edge firewall rule, a cloud provider's security group, etc. Distinct from serverPorts (which is what a server
// itself, or a scan of it, reports): this is what someone tells the app is open further out on the network path,
// for the same reason people keep a spreadsheet of "what did I open on the router and why."
export const portForwards = sqliteTable("port_forwards", {
id: integer("id").primaryKey({ autoIncrement: true }),
label: text("label").notNull(),
externalPort: integer("external_port").notNull(),
protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"),
// Optional link to a tracked server this forward points at; "destination" covers anything else (a bare IP,
// an untracked device) or extra detail alongside a linked server.
serverId: integer("server_id").references(() => servers.id, { onDelete: "set null" }),
destination: text("destination"),
// The port it's actually forwarded to, when NAT changes it (a router forwarding external 8443 to internal 443).
internalPort: integer("internal_port"),
// Free text: where this rule actually lives ("Home router", "OPNsense WAN rule", "Cloudflare Tunnel") — this
// app has no integration with any firewall/router, so it can't verify or manage the rule, only record it.
source: text("source"),
comment: text("comment"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Domain registrations ───────────────────────────────────────────────────
export const domainOrigins = ["manual", "zone"] as const;