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:
@@ -150,6 +150,17 @@ a problem that began during a window and is still present when it ends alerts
|
||||
then — a forgotten window can't hide an outage. A banner shows what's currently
|
||||
silenced to every signed-in user.
|
||||
|
||||
**Ports** — each server's detail page has a Ports card for finding free ports
|
||||
and remembering what each one is for. "Scan…" runs a TCP scan of a port range
|
||||
from the app against the server's address (private addresses only, up to
|
||||
20,000 ports at a time) and lists what's open plus the ranges that were
|
||||
confirmed free; a free range can be clicked to reserve a port. Any port can
|
||||
carry a service name and a comment, and a port with a note counts as taken
|
||||
even when nothing is listening. The agent also reports what is bound on the
|
||||
host (`ss`), which catches services listening on localhost only — a scan from
|
||||
elsewhere can't see those, so they'd otherwise look free. Re-run the agent
|
||||
install one-liner on a host to pick that up.
|
||||
|
||||
The app is installable as a PWA — "Install app" / "Add to Home Screen" from the
|
||||
browser gives it its own icon and a standalone window on phone or desktop. This
|
||||
needs the site to be served over HTTPS (browsers only offer install on secure
|
||||
|
||||
@@ -226,8 +226,26 @@ collect_system_info() {
|
||||
')
|
||||
fi
|
||||
|
||||
# Everything bound to a port on this host, including services listening on localhost only (which a network
|
||||
# scan from elsewhere can't see). `ss -p` needs root to name the process; without it the process is blank.
|
||||
# One row per socket — the server groups them per port.
|
||||
local ports_json="[]"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ports_json=$(ss -H -tulnp 2>/dev/null \
|
||||
| awk '{
|
||||
local = $5; port = local; sub(/.*:/, "", port); addr = local; sub(/:[0-9]+$/, "", addr);
|
||||
proc = ""; if (match($0, /users:\(\("[^"]+"/)) { proc = substr($0, RSTART + 9, RLENGTH - 10) }
|
||||
if (port ~ /^[0-9]+$/) print $1 "\t" port "\t" addr "\t" proc
|
||||
}' \
|
||||
| jq -R -s -c '
|
||||
split("\n") | map(select(length > 0) | split("\t")) |
|
||||
map({protocol: .[0], port: (.[1]|tonumber), address: .[2], process: (.[3] // "")})
|
||||
')
|
||||
fi
|
||||
|
||||
SYSTEM_JSON=$(jq -n \
|
||||
--argjson ip_addresses "$ip_json" \
|
||||
--argjson listening_ports "$ports_json" \
|
||||
--arg cpu_model "$cpu_model" \
|
||||
--argjson cpu_cores "${cpu_cores:-0}" \
|
||||
--argjson cpu_load_percent "$cpu_load_percent" \
|
||||
@@ -238,7 +256,8 @@ collect_system_info() {
|
||||
ip_addresses: $ip_addresses,
|
||||
cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent },
|
||||
memory: { total_bytes: $mem_total_bytes, used_bytes: $mem_used_bytes },
|
||||
disks: $disks
|
||||
disks: $disks,
|
||||
listening_ports: $listening_ports
|
||||
}')
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -346,6 +346,44 @@ export interface ServerDetail {
|
||||
links: ServerLink[];
|
||||
}
|
||||
|
||||
export interface PortEntry {
|
||||
/** Null for a port only known from the agent that has no note yet. */
|
||||
id: number | null;
|
||||
port: number;
|
||||
protocol: "tcp" | "udp";
|
||||
label: string | null;
|
||||
comment: string | null;
|
||||
/** The last scan from this app connected to it. */
|
||||
scanOpen: boolean;
|
||||
lastSeenOpenAt: string | null;
|
||||
agent: { addresses: string[]; process: string | null; localOnly: boolean } | null;
|
||||
state: "open" | "reserved";
|
||||
}
|
||||
|
||||
export interface PortScanSummary {
|
||||
at: string;
|
||||
address: string;
|
||||
from: number;
|
||||
to: number;
|
||||
open: number;
|
||||
refused: number;
|
||||
filtered: number;
|
||||
responded: boolean;
|
||||
}
|
||||
|
||||
export interface PortList {
|
||||
ports: PortEntry[];
|
||||
agentReporting: boolean;
|
||||
agentReportedAt: string | null;
|
||||
lastScan: PortScanSummary | null;
|
||||
}
|
||||
|
||||
export interface PortScanResult extends Omit<PortList, "lastScan"> {
|
||||
scan: PortScanSummary;
|
||||
freeCount: number;
|
||||
freeRanges: [number, number][];
|
||||
}
|
||||
|
||||
export interface ServerUpdateInput {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
@@ -803,6 +841,14 @@ export const api = {
|
||||
request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
removeLink: (id: number, linkId: number) =>
|
||||
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }),
|
||||
ports: {
|
||||
list: (id: number) => request<PortList>(`/api/servers/${id}/ports`),
|
||||
scan: (id: number, data: { address: string; from: number; to: number }) =>
|
||||
request<PortScanResult>(`/api/servers/${id}/ports/scan`, { method: "POST", body: JSON.stringify(data) }),
|
||||
save: (id: number, data: { port: number; protocol: "tcp" | "udp"; label?: string; comment?: string }) =>
|
||||
request<{ ports: PortEntry[] }>(`/api/servers/${id}/ports`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
remove: (id: number, portId: number) => request<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
list: (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ServerLink,
|
||||
type TaskRecord,
|
||||
} from "../api/client";
|
||||
import ServerPorts from "../components/ServerPorts";
|
||||
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
@@ -685,6 +686,12 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ServerPorts
|
||||
serverId={serverId}
|
||||
addresses={[...ipAddresses, ...(server.hostname && !ipAddresses.includes(server.hostname) ? [server.hostname] : [])]}
|
||||
canEdit={canEditTasks}
|
||||
/>
|
||||
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<h3 className="mb-0">Tasks</h3>
|
||||
{canEditTasks && (
|
||||
|
||||
Reference in New Issue
Block a user