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:
bobbanandClaude Sonnet 5 committed 2026-09-26 02:39:43 +02:00
1 parent aae4f0d74f
commit 4c11158e98
14 files changed
+2521 -1

No files matched your search

+17
View File
@@ -7,11 +7,28 @@ import { asyncHandler } from "../utils/asyncHandler.js";
export const agentReportRouter = Router();
const listeningPortSchema = z.object({
protocol: z.enum(["tcp", "udp"]),
port: z.number().int().min(1).max(65535),
address: z.string().max(100),
process: z.string().max(100).optional(),
});
const systemSchema = z.object({
ip_addresses: z.array(z.string()).optional(),
cpu: z.object({ model: z.string().optional(), cores: z.number().optional(), load_percent: z.number().nullable().optional() }).optional(),
memory: z.object({ total_bytes: z.number().optional(), used_bytes: z.number().optional() }).optional(),
disks: z.array(z.object({ mount: z.string(), size_bytes: z.number(), used_bytes: z.number() })).optional(),
// Deliberately lenient: one odd line from `ss` must never cost the agent its whole report (tasks included),
// so entries are validated one by one and bad ones dropped rather than failing the request.
listening_ports: z
.array(z.unknown())
.max(5000)
.optional()
.transform((entries) => entries?.flatMap((e) => {
const parsed = listeningPortSchema.safeParse(e);
return parsed.success ? [parsed.data] : [];
})),
});
const reportSchema = z.object({
+338
View File
@@ -0,0 +1,338 @@
import { Router } from "express";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
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 { 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;
from: number;
to: number;
open: number;
refused: number;
filtered: number;
responded: boolean;
}
export interface PortEntry {
/** Null for a port that's only known from the agent and 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;
/** What the agent sees bound on the host, when it reports listening ports. */
agent: { addresses: string[]; process: string | null; localOnly: boolean } | null;
/** "open" if anything is using it; "reserved" if it only has a note. */
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;
const rows = await db.select().from(serverPorts).where(eq(serverPorts.serverId, serverId));
const agentRaw = parseJson<AgentPort[] | null>(server.listeningPorts, null);
const agent = groupAgentPorts(agentRaw ?? []);
const entries = new Map<string, PortEntry>();
for (const row of rows) {
const key = `${row.protocol}:${row.port}`;
entries.set(key, {
id: row.id,
port: row.port,
protocol: row.protocol,
label: row.label,
comment: row.comment,
scanOpen: row.open,
lastSeenOpenAt: row.lastSeenOpenAt,
agent: agent.get(key) ?? null,
state: "reserved",
});
}
for (const [key, info] of agent) {
if (entries.has(key)) continue;
const [protocol, port] = key.split(":");
entries.set(key, {
id: null,
port: Number(port),
protocol: protocol as "tcp" | "udp",
label: null,
comment: null,
scanOpen: false,
lastSeenOpenAt: null,
agent: info,
state: "reserved",
});
}
for (const entry of entries.values()) {
if (entry.scanOpen || entry.agent) entry.state = "open";
}
const ports = [...entries.values()].sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol));
return {
server,
ports,
agentReporting: agentRaw !== null,
agentReportedAt: agentRaw !== null ? server.lastSeenAt : null,
lastScan: parseJson<StoredScan | null>(server.lastPortScan, null),
};
}
function serverIdOf(req: { params: Record<string, string> }): number | null {
const id = Number(req.params.id);
return Number.isInteger(id) && id > 0 ? id : null;
}
serverPortsRouter.get("/", asyncHandler(async (req, res) => {
const id = serverIdOf(req);
if (!id) return res.status(400).json({ error: "invalid_id" });
const list = await buildPortList(id);
if (!list) return res.status(404).json({ error: "not_found" });
const { server: _server, ...out } = list;
res.json(out);
}));
const scanSchema = z
.object({
address: z.string().min(1).max(255),
from: z.number().int().min(1).max(65535),
to: z.number().int().min(1).max(65535),
})
.refine((d) => d.to >= d.from, { message: "The end of the range is before the start." })
.refine((d) => d.to - d.from + 1 <= MAX_SCAN_SPAN, { message: `Scan at most ${MAX_SCAN_SPAN} ports at a time.` });
serverPortsRouter.post("/scan", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = serverIdOf(req);
if (!serverId) return res.status(400).json({ error: "invalid_id" });
const parsed = scanSchema.safeParse(req.body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Invalid scan request.";
return res.status(400).json({ error: "invalid_body", message, details: parsed.error.flatten() });
}
const { address, from, to } = parsed.data;
const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
let target: string;
try {
target = await resolveScanTarget(address);
} catch (err) {
return res.status(400).json({ error: "invalid_address", message: err instanceof Error ? err.message : String(err) });
}
if (!beginScan(serverId)) {
return res.status(409).json({ error: "scan_in_progress", message: "A scan of this server is already running." });
}
let scan;
try {
scan = await scanPorts(target, from, to);
} finally {
endScan(serverId);
}
const now = new Date().toISOString();
// If nothing at all answered, the host is probably down or dropping everything — that says nothing about
// which ports are open, so leave what we knew before rather than marking it all closed.
const responded = scan.open.length + scan.refused.length > 0;
if (responded) {
const existing = await db
.select()
.from(serverPorts)
.where(and(eq(serverPorts.serverId, serverId), eq(serverPorts.protocol, "tcp")));
const inRange = existing.filter((r) => r.port >= from && r.port <= to);
const openSet = new Set(scan.open);
const known = new Set(existing.map((r) => r.port));
const newlyFound = scan.open.filter((p) => !known.has(p));
for (let i = 0; i < newlyFound.length; i += 50) {
await db.insert(serverPorts).values(
newlyFound.slice(i, i + 50).map((port) => ({ serverId, port, protocol: "tcp" as const, open: true, lastSeenOpenAt: now })),
);
}
const stillOpen = inRange.filter((r) => openSet.has(r.port)).map((r) => r.id);
if (stillOpen.length > 0) {
await db.update(serverPorts).set({ open: true, lastSeenOpenAt: now }).where(inArray(serverPorts.id, stillOpen));
}
// No longer open: keep it if someone wrote a note about it (it's now "reserved"), otherwise it carries no information.
const gone = inRange.filter((r) => r.open && !openSet.has(r.port));
const keep = gone.filter((r) => r.label || r.comment).map((r) => r.id);
const drop = gone.filter((r) => !(r.label || r.comment)).map((r) => r.id);
if (keep.length > 0) await db.update(serverPorts).set({ open: false }).where(inArray(serverPorts.id, keep));
if (drop.length > 0) await db.delete(serverPorts).where(inArray(serverPorts.id, drop));
}
const summary: StoredScan = {
at: now,
address: target,
from,
to,
open: scan.open.length,
refused: scan.refused.length,
filtered: scan.filtered,
responded,
};
await db.update(servers).set({ lastPortScan: JSON.stringify(summary) }).where(eq(servers.id, serverId));
// "Free" is what the host actively refused AND nobody has claimed — by a note, or by the agent seeing it bound
// (which catches services listening only on localhost, invisible to a scan from elsewhere).
const list = (await buildPortList(serverId))!;
const taken = new Set(list.ports.filter((p) => p.protocol === "tcp").map((p) => p.port));
const free = scan.refused.filter((p) => !taken.has(p));
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "scan_ports",
targetType: "server",
targetId: serverId,
detail: { name: server.name, address: target, from, to, open: scan.open.length },
});
res.json({
scan: summary,
freeCount: free.length,
freeRanges: toRanges(free),
ports: list.ports,
agentReporting: list.agentReporting,
agentReportedAt: list.agentReportedAt,
});
}));
const noteSchema = z.object({
port: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"]).default("tcp"),
label: z.string().trim().max(100).nullish(),
comment: z.string().trim().max(500).nullish(),
});
serverPortsRouter.put("/", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = serverIdOf(req);
if (!serverId) return res.status(400).json({ error: "invalid_id" });
const parsed = noteSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", message: "Invalid port note.", details: parsed.error.flatten() });
}
const { port, protocol } = parsed.data;
const label = parsed.data.label || null;
const comment = parsed.data.comment || null;
const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
const [existing] = await db
.select()
.from(serverPorts)
.where(and(eq(serverPorts.serverId, serverId), eq(serverPorts.port, port), eq(serverPorts.protocol, protocol)))
.limit(1);
if (!existing && !label && !comment) {
return res.status(400).json({ error: "invalid_body", message: "Add a label or a comment to reserve a port." });
}
const now = new Date().toISOString();
if (existing) {
if (!label && !comment && !existing.open) {
await db.delete(serverPorts).where(eq(serverPorts.id, existing.id));
} else {
await db.update(serverPorts).set({ label, comment, updatedAt: now }).where(eq(serverPorts.id, existing.id));
}
} else {
await db.insert(serverPorts).values({ serverId, port, protocol, label, comment, updatedAt: now });
}
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "set_port_note",
targetType: "server",
targetId: serverId,
detail: { name: server.name, port, protocol, label },
});
const list = (await buildPortList(serverId))!;
res.json({ ports: list.ports });
}));
serverPortsRouter.delete("/:portId", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = serverIdOf(req);
const portId = Number(req.params.portId);
if (!serverId || !Number.isInteger(portId)) return res.status(400).json({ error: "invalid_id" });
const [row] = await db
.select()
.from(serverPorts)
.where(and(eq(serverPorts.id, portId), eq(serverPorts.serverId, serverId)))
.limit(1);
if (!row) return res.status(404).json({ error: "not_found" });
// A port that's currently open stays listed — removing its note just blanks it. A reserved-only port disappears.
if (row.open) {
await db.update(serverPorts).set({ label: null, comment: null }).where(eq(serverPorts.id, portId));
} else {
await db.delete(serverPorts).where(eq(serverPorts.id, portId));
}
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "remove_port_note",
targetType: "server",
targetId: serverId,
detail: { port: row.port, protocol: row.protocol, label: row.label },
});
res.status(204).end();
}));
+4
View File
@@ -9,9 +9,11 @@ import { recordAudit } from "../services/audit.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js";
import { serverPortsRouter } from "./serverPorts.js";
export const serversRouter = Router();
serversRouter.use(requireAuth);
serversRouter.use("/:id/ports", serverPortsRouter);
const createServerSchema = z.object({
name: z.string().min(1).max(100),
@@ -238,6 +240,8 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
cpuLoadPercent: _cpuLoadPercent,
memTotalBytes: _memTotalBytes,
memUsedBytes: _memUsedBytes,
listeningPorts: _listeningPorts,
lastPortScan: _lastPortScan,
...serverOut
} = server;