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

+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 then — a forgotten window can't hide an outage. A banner shows what's currently
silenced to every signed-in user. 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 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 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 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 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 \ SYSTEM_JSON=$(jq -n \
--argjson ip_addresses "$ip_json" \ --argjson ip_addresses "$ip_json" \
--argjson listening_ports "$ports_json" \
--arg cpu_model "$cpu_model" \ --arg cpu_model "$cpu_model" \
--argjson cpu_cores "${cpu_cores:-0}" \ --argjson cpu_cores "${cpu_cores:-0}" \
--argjson cpu_load_percent "$cpu_load_percent" \ --argjson cpu_load_percent "$cpu_load_percent" \
@@ -238,7 +256,8 @@ collect_system_info() {
ip_addresses: $ip_addresses, ip_addresses: $ip_addresses,
cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent }, cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent },
memory: { total_bytes: $mem_total_bytes, used_bytes: $mem_used_bytes }, 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, "when": 1790381917814,
"tag": "0008_thin_boom_boom", "tag": "0008_thin_boom_boom",
"breakpoints": true "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"), memTotalBytes: integer("mem_total_bytes"),
memUsedBytes: integer("mem_used_bytes"), memUsedBytes: integer("mem_used_bytes"),
disks: text("disks"), // JSON string: {mount, sizeBytes, usedBytes}[] 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. // Optional link to a Proxmox VM/LXC — set by an admin, not the agent.
proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, { proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, {
@@ -300,3 +302,26 @@ export const integrations = sqliteTable("integrations", {
.notNull() .notNull()
.default(sql`(current_timestamp)`), .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(); 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({ const systemSchema = z.object({
ip_addresses: z.array(z.string()).optional(), 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(), 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(), 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(), 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({ 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 { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js"; import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js";
import { serverPortsRouter } from "./serverPorts.js";
export const serversRouter = Router(); export const serversRouter = Router();
serversRouter.use(requireAuth); serversRouter.use(requireAuth);
serversRouter.use("/:id/ports", serverPortsRouter);
const createServerSchema = z.object({ const createServerSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
@@ -238,6 +240,8 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
cpuLoadPercent: _cpuLoadPercent, cpuLoadPercent: _cpuLoadPercent,
memTotalBytes: _memTotalBytes, memTotalBytes: _memTotalBytes,
memUsedBytes: _memUsedBytes, memUsedBytes: _memUsedBytes,
listeningPorts: _listeningPorts,
lastPortScan: _lastPortScan,
...serverOut ...serverOut
} = server; } = 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 }; cpu?: { model?: string; cores?: number; load_percent?: number | null };
memory?: { total_bytes?: number; used_bytes?: number }; memory?: { total_bytes?: number; used_bytes?: number };
disks?: { mount: string; size_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 { 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)); .where(eq(servers.id, serverId));
} }
+46
View File
@@ -346,6 +346,44 @@ export interface ServerDetail {
links: ServerLink[]; 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 { export interface ServerUpdateInput {
name?: string; name?: string;
hostname?: string; hostname?: string;
@@ -803,6 +841,14 @@ export const api = {
request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }), request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
removeLink: (id: number, linkId: number) => removeLink: (id: number, linkId: number) =>
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }), 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: { tasks: {
list: ( 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 ServerLink,
type TaskRecord, type TaskRecord,
} from "../api/client"; } from "../api/client";
import ServerPorts from "../components/ServerPorts";
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable"; import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
import { formatDateTime } from "../utils/date"; import { formatDateTime } from "../utils/date";
@@ -685,6 +686,12 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
</div> </div>
</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"> <div className="d-flex align-items-center mb-3">
<h3 className="mb-0">Tasks</h3> <h3 className="mb-0">Tasks</h3>
{canEditTasks && ( {canEditTasks && (