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,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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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