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

+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `port_forwards` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`label` text NOT NULL,
`external_port` integer NOT NULL,
`protocol` text DEFAULT 'tcp' NOT NULL,
`server_id` integer,
`destination` text,
`internal_port` integer,
`source` text,
`comment` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE set null
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -99,6 +99,13 @@
"when": 1790449897833,
"tag": "0013_sturdy_bloodstorm",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1790718056783,
"tag": "0014_empty_gabe_jones",
"breakpoints": true
}
]
}
+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;
+2
View File
@@ -28,6 +28,7 @@ import { domainsRouter } from "./routes/domains.js";
import { consistencyRouter } from "./routes/consistency.js";
import { privacyRouter } from "./routes/privacy.js";
import { tagsRouter } from "./routes/tags.js";
import { portsRouter } from "./routes/ports.js";
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
@@ -102,6 +103,7 @@ app.use("/api/domains", domainsRouter);
app.use("/api/consistency", consistencyRouter);
app.use("/api/privacy", privacyRouter);
app.use("/api/tags", tagsRouter);
app.use("/api/ports", portsRouter);
if (existsSync(webDist)) {
app.use(express.static(webDist));
+180
View File
@@ -0,0 +1,180 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { servers, portForwards } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { type AgentPort, groupAgentPorts, parseJson, splitPortKey } from "../services/agentPorts.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const portsRouter = Router();
portsRouter.use(requireAuth);
// ─── Ports reported by agents, across every server ──────────────────────────
export interface AgentPortRow {
serverId: number;
serverName: string;
serverHostname: string | null;
protocol: "tcp" | "udp";
port: number;
addresses: string[];
process: string | null;
localOnly: boolean;
lastSeenAt: string | null;
}
portsRouter.get("/agent", asyncHandler(async (_req, res) => {
const rows = await db
.select({ id: servers.id, name: servers.name, hostname: servers.hostname, listeningPorts: servers.listeningPorts, lastSeenAt: servers.lastSeenAt })
.from(servers);
const out: AgentPortRow[] = [];
for (const s of rows) {
const raw = parseJson<AgentPort[] | null>(s.listeningPorts, null);
if (!raw) continue;
for (const [key, grouped] of groupAgentPorts(raw)) {
const { protocol, port } = splitPortKey(key);
out.push({
serverId: s.id,
serverName: s.name,
serverHostname: s.hostname,
protocol,
port,
addresses: grouped.addresses,
process: grouped.process,
localOnly: grouped.localOnly,
lastSeenAt: s.lastSeenAt,
});
}
}
out.sort((a, b) => a.serverName.localeCompare(b.serverName) || a.port - b.port || a.protocol.localeCompare(b.protocol));
res.json({ ports: out, reportingServers: rows.filter((s) => s.listeningPorts !== null).length, totalServers: rows.length });
}));
// ─── Manually-recorded port openings (router/firewall/cloud security group, etc.) ──
// Blank/omitted optional fields normalize to null right here, so every downstream handler can
// just use parsed.data as-is (matching the DB columns, which store NULL, not empty strings).
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((v) => v || null);
const forwardInput = z.object({
label: z.string().trim().min(1).max(200),
externalPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"]).default("tcp"),
serverId: z
.number()
.int()
.nullish()
.transform((v) => v ?? null),
destination: optionalText(255),
internalPort: z
.number()
.int()
.min(1)
.max(65535)
.nullish()
.transform((v) => v ?? null),
source: optionalText(200),
comment: optionalText(2000),
});
const updateForwardInput = forwardInput.partial();
portsRouter.get("/forwards", asyncHandler(async (_req, res) => {
const rows = await db.query.portForwards.findMany({ orderBy: (p, { asc }) => [asc(p.externalPort)] });
const serverRows = await db.select({ id: servers.id, name: servers.name }).from(servers);
const nameById = new Map(serverRows.map((s) => [s.id, s.name]));
res.json({
forwards: rows.map((r) => ({ ...r, serverName: r.serverId !== null ? nameById.get(r.serverId) ?? null : null })),
});
}));
portsRouter.post("/forwards", requireRole("operator"), asyncHandler(async (req, res) => {
const parsed = forwardInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const data = parsed.data;
if (data.serverId) {
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
if (!server) return res.status(400).json({ error: "invalid_server" });
}
const [created] = await db.insert(portForwards).values(data).returning();
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "create_port_forward",
targetType: "port_forward",
targetId: created.id,
detail: { label: created.label, externalPort: created.externalPort, protocol: created.protocol },
});
res.status(201).json({ forward: created });
}));
portsRouter.patch("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const parsed = updateForwardInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const data = parsed.data;
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
if (data.serverId) {
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
if (!server) return res.status(400).json({ error: "invalid_server" });
}
const [updated] = await db
.update(portForwards)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(portForwards.id, id))
.returning();
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "update_port_forward",
targetType: "port_forward",
targetId: id,
detail: { label: updated.label, externalPort: updated.externalPort, protocol: updated.protocol },
});
res.json({ forward: updated });
}));
portsRouter.delete("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
await db.delete(portForwards).where(eq(portForwards.id, id));
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "delete_port_forward",
targetType: "port_forward",
targetId: id,
detail: { label: existing.label, externalPort: existing.externalPort, protocol: existing.protocol },
});
res.status(204).end();
}));
+1 -39
View File
@@ -6,18 +6,12 @@ import { servers, serverPorts } from "../db/schema.js";
import { requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { beginScan, endScan, MAX_SCAN_SPAN, resolveScanTarget, scanPorts, toRanges } from "../services/portScan.js";
import { type AgentPort, groupAgentPorts, parseJson } from "../services/agentPorts.js";
import { asyncHandler } from "../utils/asyncHandler.js";
// Mounted under /api/servers/:id/ports by the servers router, which has already required a signed-in user.
export const serverPortsRouter = Router({ mergeParams: true });
interface AgentPort {
protocol: "tcp" | "udp";
port: number;
address: string;
process?: string;
}
interface StoredScan {
at: string;
address: string;
@@ -45,38 +39,6 @@ export interface PortEntry {
state: "open" | "reserved";
}
function parseJson<T>(text: string | null | undefined, fallback: T): T {
if (!text) return fallback;
try {
return JSON.parse(text) as T;
} catch {
return fallback;
}
}
function isLoopback(address: string): boolean {
const bare = address.replace(/%.*$/, "").replace(/^\[|\]$/g, "");
return bare.startsWith("127.") || bare === "::1";
}
/** Groups the agent's raw one-row-per-socket report into one entry per protocol+port. */
function groupAgentPorts(raw: AgentPort[]): Map<string, { addresses: string[]; process: string | null; localOnly: boolean }> {
const grouped = new Map<string, { addresses: Set<string>; process: string | null }>();
for (const p of raw) {
const key = `${p.protocol}:${p.port}`;
const entry = grouped.get(key) ?? { addresses: new Set<string>(), process: null };
entry.addresses.add(p.address);
if (!entry.process && p.process) entry.process = p.process;
grouped.set(key, entry);
}
const out = new Map<string, { addresses: string[]; process: string | null; localOnly: boolean }>();
for (const [key, entry] of grouped) {
const addresses = [...entry.addresses];
out.set(key, { addresses, process: entry.process, localOnly: addresses.every(isLoopback) });
}
return out;
}
async function buildPortList(serverId: number) {
const [server] = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return null;
+53
View File
@@ -0,0 +1,53 @@
// Shared between the per-server Ports card (routes/serverPorts.ts) and the cross-server
// Network > Ports page (routes/ports.ts) — both read the same agent-reported "listeningPorts"
// JSON column and need the same one-row-per-socket -> one-row-per-protocol+port grouping.
export interface AgentPort {
protocol: "tcp" | "udp";
port: number;
address: string;
process?: string;
}
export interface GroupedAgentPort {
addresses: string[];
process: string | null;
localOnly: boolean;
}
export function parseJson<T>(text: string | null | undefined, fallback: T): T {
if (!text) return fallback;
try {
return JSON.parse(text) as T;
} catch {
return fallback;
}
}
export function isLoopback(address: string): boolean {
const bare = address.replace(/%.*$/, "").replace(/^\[|\]$/g, "");
return bare.startsWith("127.") || bare === "::1";
}
/** Groups the agent's raw one-row-per-socket report into one entry per protocol+port, keyed "tcp:443". */
export function groupAgentPorts(raw: AgentPort[]): Map<string, GroupedAgentPort> {
const grouped = new Map<string, { addresses: Set<string>; process: string | null }>();
for (const p of raw) {
const key = `${p.protocol}:${p.port}`;
const entry = grouped.get(key) ?? { addresses: new Set<string>(), process: null };
entry.addresses.add(p.address);
if (!entry.process && p.process) entry.process = p.process;
grouped.set(key, entry);
}
const out = new Map<string, GroupedAgentPort>();
for (const [key, entry] of grouped) {
const addresses = [...entry.addresses];
out.set(key, { addresses, process: entry.process, localOnly: addresses.every(isLoopback) });
}
return out;
}
export function splitPortKey(key: string): { protocol: "tcp" | "udp"; port: number } {
const [protocol, port] = key.split(":");
return { protocol: protocol as "tcp" | "udp", port: Number(port) };
}