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

+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE `server_ports` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`port` integer NOT NULL,
`protocol` text DEFAULT 'tcp' NOT NULL,
`label` text,
`comment` text,
`open` integer DEFAULT false NOT NULL,
`last_seen_open_at` text,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `server_ports_unique` ON `server_ports` (`server_id`,`port`,`protocol`);--> statement-breakpoint
ALTER TABLE `servers` ADD `listening_ports` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `last_port_scan` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -64,6 +64,13 @@
"when": 1790381917814,
"tag": "0008_thin_boom_boom",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1790382571470,
"tag": "0009_sharp_magus",
"breakpoints": true
}
]
}
+25
View File
@@ -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)],
);
+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;
+136
View File
@@ -0,0 +1,136 @@
import * as net from "node:net";
import * as dns from "node:dns/promises";
export const MAX_SCAN_SPAN = 20_000;
export interface ScanResult {
/** Ports that accepted a connection. */
open: number[];
/** Ports that actively refused — the host answered "nothing here", so they are genuinely free on the scanned address. */
refused: number[];
/** Ports that never answered (a firewall dropping packets, or a host that's down) — can't be said to be free or in use. */
filtered: number;
}
type Probe = "open" | "refused" | "filtered";
function probe(host: string, port: number, timeoutMs: number): Promise<Probe> {
return new Promise((resolve) => {
const socket = net.connect({ host, port });
let done = false;
const finish = (result: Probe) => {
if (done) return;
done = true;
socket.destroy();
resolve(result);
};
socket.setTimeout(timeoutMs, () => finish("filtered"));
socket.on("connect", () => finish("open"));
socket.on("error", (err: NodeJS.ErrnoException) => finish(err.code === "ECONNREFUSED" ? "refused" : "filtered"));
});
}
/** TCP connect scan of an inclusive port range, with a bounded number of connections in flight. */
export async function scanPorts(
host: string,
from: number,
to: number,
options: { timeoutMs?: number; concurrency?: number } = {},
): Promise<ScanResult> {
const timeoutMs = options.timeoutMs ?? 700;
const concurrency = options.concurrency ?? 400;
const open: number[] = [];
const refused: number[] = [];
let filtered = 0;
let next = from;
async function worker() {
while (next <= to) {
const port = next++;
const result = await probe(host, port, timeoutMs);
if (result === "open") open.push(port);
else if (result === "refused") refused.push(port);
else filtered++;
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, to - from + 1) }, worker));
open.sort((a, b) => a - b);
refused.sort((a, b) => a - b);
return { open, refused, filtered };
}
function isPrivateIPv4(ip: string): boolean {
const [a, b] = ip.split(".").map(Number);
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) || // CGNAT — where Tailscale addresses live
(a === 169 && b === 254)
);
}
function isPrivateIPv6(ip: string): boolean {
const first = parseInt(ip.split(":")[0] || "0", 16);
return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80; // unique-local fc00::/7, link-local fe80::/10
}
function isPrivateAddress(ip: string): boolean {
const family = net.isIP(ip);
if (family === 4) return isPrivateIPv4(ip);
if (family === 6) return isPrivateIPv6(ip);
return false;
}
/**
* Resolves an address (IP literal or hostname) to the IP to scan, refusing anything that isn't on a private
* network. A port scanner that will probe any address the caller types is a tool for scanning other people's
* machines, and this one exists to look at the homelab — loopback is refused too, since it would only ever
* describe the machine the app itself runs on.
*/
export async function resolveScanTarget(address: string): Promise<string> {
const trimmed = address.trim();
if (!trimmed) throw new Error("No address to scan.");
let ips: string[];
if (net.isIP(trimmed)) {
ips = [trimmed];
} else {
try {
ips = (await dns.lookup(trimmed, { all: true })).map((r) => r.address);
} catch {
throw new Error(`Couldn't resolve "${trimmed}".`);
}
}
const ip = ips.find(isPrivateAddress);
if (!ip) {
throw new Error(
`"${trimmed}" isn't on a private network. Scanning is limited to homelab addresses (10.x, 172.16–31.x, 192.168.x, Tailscale 100.64–127.x, and IPv6 unique-local/link-local).`,
);
}
return ip;
}
/** Collapses a sorted list of ports into inclusive [start, end] ranges. */
export function toRanges(ports: number[]): [number, number][] {
const ranges: [number, number][] = [];
for (const port of ports) {
const last = ranges[ranges.length - 1];
if (last && port === last[1] + 1) last[1] = port;
else ranges.push([port, port]);
}
return ranges;
}
/** One scan at a time per server — a full range holds hundreds of sockets open, and two overlapping scans would just fight over the results. */
const scansInProgress = new Set<number>();
export function beginScan(serverId: number): boolean {
if (scansInProgress.has(serverId)) return false;
scansInProgress.add(serverId);
return true;
}
export function endScan(serverId: number): void {
scansInProgress.delete(serverId);
}
+3
View File
@@ -18,6 +18,7 @@ export interface IncomingSystemInfo {
cpu?: { model?: string; cores?: number; load_percent?: number | null };
memory?: { total_bytes?: number; used_bytes?: number };
disks?: { mount: string; size_bytes: number; used_bytes: number }[];
listening_ports?: { protocol: "tcp" | "udp"; port: number; address: string; process?: string }[];
}
export interface AgentReport {
@@ -112,6 +113,8 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
),
}
: {}),
// Only stored when the agent reported it, so an agent that predates this doesn't wipe the field.
...(system?.listening_ports ? { listeningPorts: JSON.stringify(system.listening_ports) } : {}),
})
.where(eq(servers.id, serverId));
}