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

+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));
}