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:
2026-09-26 02:39:43 +02:00
co-authored by Claude Sonnet 5
parent aae4f0d74f
commit 4c11158e98
14 changed files with 2521 additions and 1 deletions
+11
View File
@@ -150,6 +150,17 @@ a problem that began during a window and is still present when it ends alerts
then — a forgotten window can't hide an outage. A banner shows what's currently
silenced to every signed-in user.
**Ports** — each server's detail page has a Ports card for finding free ports
and remembering what each one is for. "Scan…" runs a TCP scan of a port range
from the app against the server's address (private addresses only, up to
20,000 ports at a time) and lists what's open plus the ranges that were
confirmed free; a free range can be clicked to reserve a port. Any port can
carry a service name and a comment, and a port with a note counts as taken
even when nothing is listening. The agent also reports what is bound on the
host (`ss`), which catches services listening on localhost only — a scan from
elsewhere can't see those, so they'd otherwise look free. Re-run the agent
install one-liner on a host to pick that up.
The app is installable as a PWA — "Install app" / "Add to Home Screen" from the
browser gives it its own icon and a standalone window on phone or desktop. This
needs the site to be served over HTTPS (browsers only offer install on secure
+20 -1
View File
@@ -226,8 +226,26 @@ collect_system_info() {
')
fi
# Everything bound to a port on this host, including services listening on localhost only (which a network
# scan from elsewhere can't see). `ss -p` needs root to name the process; without it the process is blank.
# One row per socket — the server groups them per port.
local ports_json="[]"
if command -v ss >/dev/null 2>&1; then
ports_json=$(ss -H -tulnp 2>/dev/null \
| awk '{
local = $5; port = local; sub(/.*:/, "", port); addr = local; sub(/:[0-9]+$/, "", addr);
proc = ""; if (match($0, /users:\(\("[^"]+"/)) { proc = substr($0, RSTART + 9, RLENGTH - 10) }
if (port ~ /^[0-9]+$/) print $1 "\t" port "\t" addr "\t" proc
}' \
| jq -R -s -c '
split("\n") | map(select(length > 0) | split("\t")) |
map({protocol: .[0], port: (.[1]|tonumber), address: .[2], process: (.[3] // "")})
')
fi
SYSTEM_JSON=$(jq -n \
--argjson ip_addresses "$ip_json" \
--argjson listening_ports "$ports_json" \
--arg cpu_model "$cpu_model" \
--argjson cpu_cores "${cpu_cores:-0}" \
--argjson cpu_load_percent "$cpu_load_percent" \
@@ -238,7 +256,8 @@ collect_system_info() {
ip_addresses: $ip_addresses,
cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent },
memory: { total_bytes: $mem_total_bytes, used_bytes: $mem_used_bytes },
disks: $disks
disks: $disks,
listening_ports: $listening_ports
}')
}
+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));
}
+46
View File
@@ -346,6 +346,44 @@ export interface ServerDetail {
links: ServerLink[];
}
export interface PortEntry {
/** Null for a port only known from the agent that 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;
agent: { addresses: string[]; process: string | null; localOnly: boolean } | null;
state: "open" | "reserved";
}
export interface PortScanSummary {
at: string;
address: string;
from: number;
to: number;
open: number;
refused: number;
filtered: number;
responded: boolean;
}
export interface PortList {
ports: PortEntry[];
agentReporting: boolean;
agentReportedAt: string | null;
lastScan: PortScanSummary | null;
}
export interface PortScanResult extends Omit<PortList, "lastScan"> {
scan: PortScanSummary;
freeCount: number;
freeRanges: [number, number][];
}
export interface ServerUpdateInput {
name?: string;
hostname?: string;
@@ -803,6 +841,14 @@ export const api = {
request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
removeLink: (id: number, linkId: number) =>
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }),
ports: {
list: (id: number) => request<PortList>(`/api/servers/${id}/ports`),
scan: (id: number, data: { address: string; from: number; to: number }) =>
request<PortScanResult>(`/api/servers/${id}/ports/scan`, { method: "POST", body: JSON.stringify(data) }),
save: (id: number, data: { port: number; protocol: "tcp" | "udp"; label?: string; comment?: string }) =>
request<{ ports: PortEntry[] }>(`/api/servers/${id}/ports`, { method: "PUT", body: JSON.stringify(data) }),
remove: (id: number, portId: number) => request<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
},
},
tasks: {
list: (
+505
View File
@@ -0,0 +1,505 @@
import { useEffect, useMemo, useState } from "react";
import { api, type PortEntry, type PortList, type PortScanResult } from "../api/client";
import { sortItems, type SortDirection } from "../hooks/useSortable";
import { usePagination } from "../hooks/usePagination";
import { formatDateTime } from "../utils/date";
import { downloadCsv } from "../utils/csv";
import { readableError } from "../utils/errors";
import Pagination from "./Pagination";
import SortableTh from "./SortableTh";
const PRESETS: { key: string; label: string; from: number; to: number }[] = [
{ key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 },
{ key: "common", label: "1–10000 (most services)", from: 1, to: 10000 },
{ key: "high1", label: "10001–30000", from: 10001, to: 30000 },
{ key: "high2", label: "30001–50000", from: 30001, to: 50000 },
{ key: "high3", label: "50001–65535", from: 50001, to: 65535 },
{ key: "custom", label: "Custom range…", from: 8000, to: 8999 },
];
const MAX_SHOWN_RANGES = 24;
interface NoteForm {
editingId: number | null;
port: string;
protocol: "tcp" | "udp";
label: string;
comment: string;
}
function rangeText([from, to]: [number, number]) {
return from === to ? String(from) : `${from}–${to}`;
}
function StatusBadges({ entry }: { entry: PortEntry }) {
if (entry.state === "reserved") return <span className="badge bg-secondary-lt text-secondary">Reserved</span>;
return (
<div className="d-flex flex-wrap gap-1">
{entry.scanOpen && (
<span className="badge bg-green-lt text-green" title="A scan from this app connected to it">
Reachable
</span>
)}
{entry.agent && (
<span className="badge bg-blue-lt text-blue" title={`Bound on: ${entry.agent.addresses.join(", ")}`}>
Listening
</span>
)}
{entry.agent?.localOnly && (
<span className="badge bg-yellow-lt text-yellow" title="Bound to localhost only — other machines can't connect, but the port is still taken">
local only
</span>
)}
</div>
);
}
export default function ServerPorts({
serverId,
addresses,
canEdit,
}: {
serverId: number;
/** Addresses this server is known by, offered as scan targets. */
addresses: string[];
canEdit: boolean;
}) {
const [data, setData] = useState<PortList | null>(null);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState<keyof PortEntry | undefined>("port");
const [sortDir, setSortDir] = useState<SortDirection>("asc");
const [showScan, setShowScan] = useState(false);
const [address, setAddress] = useState("");
const [presetKey, setPresetKey] = useState("common");
const [customFrom, setCustomFrom] = useState("8000");
const [customTo, setCustomTo] = useState("8999");
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<PortScanResult | null>(null);
const [showAllRanges, setShowAllRanges] = useState(false);
const [note, setNote] = useState<NoteForm | null>(null);
const [savingNote, setSavingNote] = useState(false);
useEffect(() => {
api.servers.ports
.list(serverId)
.then(setData)
.catch((err) => setError(readableError(err)));
}, [serverId]);
// Default the scan target to the server's first known address once we have one.
useEffect(() => {
if (!address && addresses.length > 0) setAddress(addresses[0]);
}, [addresses, address]);
const visible = useMemo(() => {
if (!data) return null;
const q = filter.trim().toLowerCase();
const rows = q
? data.ports.filter((p) =>
[String(p.port), p.protocol, p.label, p.comment, p.agent?.process].some((v) => v?.toLowerCase().includes(q)),
)
: data.ports;
return sortItems(rows, sortKey, sortDir);
}, [data, filter, sortKey, sortDir]);
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(visible);
function requestSort(key: keyof PortEntry) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir("asc");
}
}
const preset = PRESETS.find((p) => p.key === presetKey)!;
const from = presetKey === "custom" ? Number(customFrom) : preset.from;
const to = presetKey === "custom" ? Number(customTo) : preset.to;
async function runScan(e: React.FormEvent) {
e.preventDefault();
setError(null);
setScanning(true);
setResult(null);
setShowAllRanges(false);
try {
const res = await api.servers.ports.scan(serverId, { address: address.trim(), from, to });
setResult(res);
setData({ ports: res.ports, agentReporting: res.agentReporting, agentReportedAt: res.agentReportedAt, lastScan: res.scan });
} catch (err) {
setError(readableError(err));
} finally {
setScanning(false);
}
}
function openNote(initial: Partial<NoteForm> = {}) {
setError(null);
setNote({ editingId: null, port: "", protocol: "tcp", label: "", comment: "", ...initial });
}
function editEntry(entry: PortEntry) {
openNote({
editingId: entry.id ?? -1, // -1: a port with no row yet — a note creates one
port: String(entry.port),
protocol: entry.protocol,
label: entry.label ?? "",
comment: entry.comment ?? "",
});
}
async function saveNote(e: React.FormEvent) {
e.preventDefault();
if (!note) return;
setSavingNote(true);
setError(null);
try {
const res = await api.servers.ports.save(serverId, {
port: Number(note.port),
protocol: note.protocol,
label: note.label.trim(),
comment: note.comment.trim(),
});
setData((prev) => (prev ? { ...prev, ports: res.ports } : prev));
// A port that was just reserved is no longer "available" in the last scan's result.
setResult((prev) => {
if (!prev) return prev;
const port = Number(note.port);
const ranges = prev.freeRanges.flatMap(([a, b]): [number, number][] => {
if (port < a || port > b) return [[a, b]];
const out: [number, number][] = [];
if (port > a) out.push([a, port - 1]);
if (port < b) out.push([port + 1, b]);
return out;
});
const removed = prev.freeRanges.some(([a, b]) => port >= a && port <= b);
return { ...prev, freeRanges: ranges, freeCount: prev.freeCount - (removed && note.protocol === "tcp" ? 1 : 0) };
});
setNote(null);
} catch (err) {
setError(readableError(err));
} finally {
setSavingNote(false);
}
}
async function removeNote(entry: PortEntry) {
if (entry.id === null) return;
const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`;
if (!confirm(what)) return;
try {
await api.servers.ports.remove(serverId, entry.id);
setData(await api.servers.ports.list(serverId));
} catch (err) {
setError(readableError(err));
}
}
function exportCsv() {
if (!visible) return;
downloadCsv(
"ports.csv",
["Port", "Protocol", "Status", "Service", "Process", "Comment"],
visible.map((p) => [p.port, p.protocol, p.state, p.label ?? "", p.agent?.process ?? "", p.comment ?? ""]),
);
}
const shownRanges = result ? (showAllRanges ? result.freeRanges : result.freeRanges.slice(0, MAX_SHOWN_RANGES)) : [];
const editing = note !== null && note.editingId !== null;
return (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Ports</h3>
<div className="card-actions btn-list flex-nowrap">
{data && data.ports.length > 0 && (
<button className="btn btn-outline-secondary btn-sm" onClick={exportCsv}>
Export CSV
</button>
)}
{canEdit && (
<>
<button className="btn btn-outline-secondary btn-sm" onClick={() => openNote()}>
Add note
</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowScan((s) => !s)}>
Scan…
</button>
</>
)}
</div>
</div>
<div className="card-body pb-2">
{error && <div className="alert alert-danger">{error}</div>}
{data && (
<div className="text-secondary small mb-2">
{data.agentReporting ? (
<>Listening ports reported by the agent{data.agentReportedAt ? ` as of ${formatDateTime(new Date(data.agentReportedAt))}` : ""}. </>
) : (
<>
The agent hasn't reported listening ports (update it to see services bound to localhost only, which a scan can't
detect).{" "}
</>
)}
{data.lastScan ? (
<>
Last scan {formatDateTime(new Date(data.lastScan.at))}: {data.lastScan.address}, ports {data.lastScan.from}–
{data.lastScan.to}.
</>
) : (
<>Not scanned yet.</>
)}
</div>
)}
{canEdit && showScan && (
<form onSubmit={runScan} className="row g-2 align-items-end border rounded p-3 mb-3">
<div className="col-md-4">
<label className="form-label">Address to scan</label>
<input
className="form-control"
required
list={`scan-addresses-${serverId}`}
placeholder="e.g. 10.0.0.5"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
<datalist id={`scan-addresses-${serverId}`}>
{addresses.map((a) => (
<option key={a} value={a} />
))}
</datalist>
</div>
<div className="col-md-4">
<label className="form-label">Ports</label>
<select className="form-select" value={presetKey} onChange={(e) => setPresetKey(e.target.value)}>
{PRESETS.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</div>
{presetKey === "custom" && (
<div className="col-md-2 d-flex gap-1 align-items-end">
<input
type="number"
className="form-control"
min={1}
max={65535}
required
aria-label="First port"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
/>
<span className="pb-2">–</span>
<input
type="number"
className="form-control"
min={1}
max={65535}
required
aria-label="Last port"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
/>
</div>
)}
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100" disabled={scanning || !address.trim()}>
{scanning ? "Scanning…" : "Scan"}
</button>
</div>
<div className="col-12 text-secondary small">
A TCP scan from this app: it can see what's reachable from here, not services bound to localhost or blocked by a
firewall. Only private addresses can be scanned, up to 20,000 ports at a time.
</div>
</form>
)}
{result && (
<div className="border rounded p-3 mb-3">
{!result.scan.responded ? (
<div className="text-warning">
Nothing answered on {result.scan.address} — the host may be down, or a firewall is dropping everything. Existing
results were left as they were.
</div>
) : (
<>
<div className="mb-2">
<strong>{result.scan.open}</strong> open in {result.scan.from}–{result.scan.to} on {result.scan.address}.{" "}
<strong>{result.freeCount}</strong> available
{result.scan.filtered > 0 && (
<span className="text-secondary">
{" "}
({result.scan.filtered} didn't answer at all, so can't be called available)
</span>
)}
.
</div>
{result.freeRanges.length > 0 ? (
<div className="d-flex flex-wrap gap-1 align-items-center">
{shownRanges.map((r) => (
<button
key={r[0]}
type="button"
className="btn btn-sm btn-outline-secondary"
title="Reserve this port"
disabled={!canEdit}
onClick={() => openNote({ port: String(r[0]) })}
>
{rangeText(r)}
</button>
))}
{result.freeRanges.length > MAX_SHOWN_RANGES && (
<button type="button" className="btn btn-link btn-sm" onClick={() => setShowAllRanges((s) => !s)}>
{showAllRanges ? "Show fewer" : `Show all ${result.freeRanges.length} ranges`}
</button>
)}
</div>
) : (
<div className="text-secondary">No port in this range was confirmed free.</div>
)}
{result.freeRanges.length > 0 && canEdit && (
<div className="text-secondary small mt-2">Click a range to reserve a port from it with a note.</div>
)}
</>
)}
</div>
)}
{canEdit && note && (
<form onSubmit={saveNote} className="row g-2 align-items-end border rounded p-3 mb-3">
<div className="col-md-2">
<label className="form-label">Port</label>
<input
type="number"
className="form-control"
min={1}
max={65535}
required
disabled={editing}
value={note.port}
onChange={(e) => setNote({ ...note, port: e.target.value })}
/>
</div>
<div className="col-md-2">
<label className="form-label">Protocol</label>
<select
className="form-select"
disabled={editing}
value={note.protocol}
onChange={(e) => setNote({ ...note, protocol: e.target.value as "tcp" | "udp" })}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div className="col-md-3">
<label className="form-label">Service</label>
<input
className="form-control"
maxLength={100}
placeholder="e.g. Grafana"
value={note.label}
onChange={(e) => setNote({ ...note, label: e.target.value })}
/>
</div>
<div className="col-md-5">
<label className="form-label">Comment</label>
<input
className="form-control"
maxLength={500}
placeholder="e.g. proxied by nginx, or: reserved for the new monitoring stack"
value={note.comment}
onChange={(e) => setNote({ ...note, comment: e.target.value })}
/>
</div>
<div className="col-12 d-flex gap-2">
<button type="submit" className="btn btn-primary btn-sm" disabled={savingNote}>
{editing ? "Save" : "Add"}
</button>
<button type="button" className="btn btn-sm" onClick={() => setNote(null)}>
Cancel
</button>
{!editing && <span className="text-secondary small align-self-center">A port with a note counts as taken, even when nothing is listening.</span>}
</div>
</form>
)}
{data && data.ports.length > 0 && (
<input
className="form-control form-control-sm mb-2"
style={{ maxWidth: 280 }}
placeholder="Filter by port, service, or comment"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
)}
</div>
{data && data.ports.length === 0 && (
<div className="card-body pt-0 text-secondary">
No ports recorded yet. {canEdit ? "Run a scan to find what's open, or add a note to reserve a port." : ""}
</div>
)}
{pageItems && pageItems.length > 0 && (
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<PortEntry> label="Port" sortKeyName="port" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PortEntry> label="Proto" sortKeyName="protocol" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PortEntry> label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PortEntry> label="Service" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PortEntry> label="Comment" sortKeyName="comment" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{pageItems.map((p) => (
<tr key={`${p.protocol}:${p.port}`}>
<td>
<code>{p.port}</code>
</td>
<td className="text-secondary">{p.protocol}</td>
<td>
<StatusBadges entry={p} />
</td>
<td>
{p.label ?? <span className="text-secondary">—</span>}
{p.agent?.process && <div className="text-secondary small">{p.agent.process}</div>}
</td>
<td className="text-secondary">{p.comment ?? "—"}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm btn-outline-secondary" onClick={() => editEntry(p)}>
{p.label || p.comment ? "Edit" : "Add note"}
</button>
{(p.label || p.comment) && (
<button className="btn btn-sm btn-outline-danger" onClick={() => removeNote(p)}>
{p.state === "reserved" ? "Remove" : "Clear"}
</button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{visible && visible.length === 0 && data && data.ports.length > 0 && (
<div className="card-body pt-0 text-secondary">Nothing matches that filter.</div>
)}
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
);
}
+7
View File
@@ -11,6 +11,7 @@ import {
type ServerLink,
type TaskRecord,
} from "../api/client";
import ServerPorts from "../components/ServerPorts";
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
import { formatDateTime } from "../utils/date";
@@ -685,6 +686,12 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
</div>
</div>
<ServerPorts
serverId={serverId}
addresses={[...ipAddresses, ...(server.hostname && !ipAddresses.includes(server.hostname) ? [server.hostname] : [])]}
canEdit={canEditTasks}
/>
<div className="d-flex align-items-center mb-3">
<h3 className="mb-0">Tasks</h3>
{canEditTasks && (