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:
1 parent
aae4f0d74f
commit
4c11158e98
14 files changed
+2521
-1
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user