Add a Network > Ports page: agent-reported ports plus manual openings

Summarizes every server's agent-reported listening ports in one
cross-server table (grouped by protocol+port, addresses merged,
loopback-only flagged) - previously this only existed per-server on
each server's own detail page.

Adds a second table for ports this app has no way to see on its own:
manually-recorded openings on a router, edge firewall, or cloud
security group, each with a label, external port/protocol, an optional
link to a tracked server (with its own internal port when NAT changes
it) or a freeform destination, a free-text source, and a comment.
Viewer-readable; adding/editing/deleting needs operator or admin.

The agent-port grouping logic (dedupe by protocol+port, detect
loopback-only sockets) was shared with the existing per-server Ports
card via a new agentPorts.ts service instead of duplicating it.

Verified with a real HTTP-level test: a genuine Express app with the
actual routers, a scratch SQLite DB, and forged admin/viewer sessions,
covering grouping correctness, the server-name join, input validation,
and role enforcement - the real dev DB was confirmed untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-29 23:48:35 +02:00
1 parent 4e48377348
commit 236b1da0dc
14 files changed
+2486 -39

No files matched your search

+14
View File
@@ -268,6 +268,20 @@ 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 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. install one-liner on a host to pick that up.
**Network → Ports** is the cross-server counterpart: one page listing every
port every agent currently reports as listening, across all servers at once
(protocol, address, process, last report time), each linking back to its
server. Below that, a separate, manually-maintained table is for the ports
this app can't see on its own — a router's port forward, an edge firewall
rule, a cloud security group — the same reason people keep a spreadsheet of
"what did I open and why." Each entry has a label, the external port/protocol,
an optional link to a tracked server (plus its own internal port, when NAT
changes it) or a freeform destination, a free-text "source" (which
router/firewall/service it's actually configured on — this app doesn't talk
to any firewall, so it can't manage or verify the rule, only record it), and
a comment. Viewers can see both tables; adding, editing, or deleting a manual
entry needs operator or admin.
The app is installable as a PWA — "Install app" / "Add to Home Screen" from the The app is installable as a PWA — "Install app" / "Add to Home Screen" from the
browser gives it its own icon and a standalone window on phone or desktop. This browser gives it its own icon and a standalone window on phone or desktop. This
needs the site to be served over HTTPS (browsers only offer install on secure needs the site to be served over HTTPS (browsers only offer install on secure
+3
View File
@@ -30,6 +30,7 @@ page's own top-level link.
| DNS | `/dns` | ✅ | ✅ | ✅ | | DNS | `/dns` | ✅ | ✅ | ✅ |
| Domains | `/domains` | ✅ | ✅ | ✅ | | Domains | `/domains` | ✅ | ✅ | ✅ |
| IP Addresses | `/ipam` | ✅ | ✅ | ✅ | | IP Addresses | `/ipam` | ✅ | ✅ | ✅ |
| Ports | `/ports` | ✅ | ✅ | ✅ |
| Consistency | `/consistency` | ✅ | ✅ | ✅ | | Consistency | `/consistency` | ✅ | ✅ | ✅ |
| **Automation** | | | | | | **Automation** | | | | |
| Semaphore | `/semaphore` | ✅ | ✅ | ✅ | | Semaphore | `/semaphore` | ✅ | ✅ | ✅ |
@@ -71,6 +72,8 @@ even further, to admin only:
linking/unlinking it to a Proxmox guest just need operator. linking/unlinking it to a Proxmox guest just need operator.
- **Tags**: creating, renaming, recoloring, or deleting a tag is - **Tags**: creating, renaming, recoloring, or deleting a tag is
admin-only (applying an existing tag to a server needs operator). admin-only (applying an existing tag to a server needs operator).
- **Ports**: everyone can see both the agent-reported and manual tables;
adding, editing, or deleting a manual port opening needs operator.
- Everything under **Settings** (notification channels, badge colors, - Everything under **Settings** (notification channels, badge colors,
display prefs, log retention, backup/restore) is admin-only, matching display prefs, log retention, backup/restore) is admin-only, matching
the page itself being admin-only. the page itself being admin-only.
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `port_forwards` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`label` text NOT NULL,
`external_port` integer NOT NULL,
`protocol` text DEFAULT 'tcp' NOT NULL,
`server_id` integer,
`destination` text,
`internal_port` integer,
`source` text,
`comment` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE set null
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -99,6 +99,13 @@
"when": 1790449897833, "when": 1790449897833,
"tag": "0013_sturdy_bloodstorm", "tag": "0013_sturdy_bloodstorm",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1790718056783,
"tag": "0014_empty_gabe_jones",
"breakpoints": true
} }
] ]
} }
+27
View File
@@ -331,6 +331,33 @@ export const serverPorts = sqliteTable(
(t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)], (t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)],
); );
// A manually-recorded port opening on something this app doesn't monitor directly — a router's port forward, an
// edge firewall rule, a cloud provider's security group, etc. Distinct from serverPorts (which is what a server
// itself, or a scan of it, reports): this is what someone tells the app is open further out on the network path,
// for the same reason people keep a spreadsheet of "what did I open on the router and why."
export const portForwards = sqliteTable("port_forwards", {
id: integer("id").primaryKey({ autoIncrement: true }),
label: text("label").notNull(),
externalPort: integer("external_port").notNull(),
protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"),
// Optional link to a tracked server this forward points at; "destination" covers anything else (a bare IP,
// an untracked device) or extra detail alongside a linked server.
serverId: integer("server_id").references(() => servers.id, { onDelete: "set null" }),
destination: text("destination"),
// The port it's actually forwarded to, when NAT changes it (a router forwarding external 8443 to internal 443).
internalPort: integer("internal_port"),
// Free text: where this rule actually lives ("Home router", "OPNsense WAN rule", "Cloudflare Tunnel") — this
// app has no integration with any firewall/router, so it can't verify or manage the rule, only record it.
source: text("source"),
comment: text("comment"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Domain registrations ─────────────────────────────────────────────────── // ─── Domain registrations ───────────────────────────────────────────────────
export const domainOrigins = ["manual", "zone"] as const; export const domainOrigins = ["manual", "zone"] as const;
+2
View File
@@ -28,6 +28,7 @@ import { domainsRouter } from "./routes/domains.js";
import { consistencyRouter } from "./routes/consistency.js"; import { consistencyRouter } from "./routes/consistency.js";
import { privacyRouter } from "./routes/privacy.js"; import { privacyRouter } from "./routes/privacy.js";
import { tagsRouter } from "./routes/tags.js"; import { tagsRouter } from "./routes/tags.js";
import { portsRouter } from "./routes/ports.js";
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
@@ -102,6 +103,7 @@ app.use("/api/domains", domainsRouter);
app.use("/api/consistency", consistencyRouter); app.use("/api/consistency", consistencyRouter);
app.use("/api/privacy", privacyRouter); app.use("/api/privacy", privacyRouter);
app.use("/api/tags", tagsRouter); app.use("/api/tags", tagsRouter);
app.use("/api/ports", portsRouter);
if (existsSync(webDist)) { if (existsSync(webDist)) {
app.use(express.static(webDist)); app.use(express.static(webDist));
+180
View File
@@ -0,0 +1,180 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { servers, portForwards } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { type AgentPort, groupAgentPorts, parseJson, splitPortKey } from "../services/agentPorts.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const portsRouter = Router();
portsRouter.use(requireAuth);
// ─── Ports reported by agents, across every server ──────────────────────────
export interface AgentPortRow {
serverId: number;
serverName: string;
serverHostname: string | null;
protocol: "tcp" | "udp";
port: number;
addresses: string[];
process: string | null;
localOnly: boolean;
lastSeenAt: string | null;
}
portsRouter.get("/agent", asyncHandler(async (_req, res) => {
const rows = await db
.select({ id: servers.id, name: servers.name, hostname: servers.hostname, listeningPorts: servers.listeningPorts, lastSeenAt: servers.lastSeenAt })
.from(servers);
const out: AgentPortRow[] = [];
for (const s of rows) {
const raw = parseJson<AgentPort[] | null>(s.listeningPorts, null);
if (!raw) continue;
for (const [key, grouped] of groupAgentPorts(raw)) {
const { protocol, port } = splitPortKey(key);
out.push({
serverId: s.id,
serverName: s.name,
serverHostname: s.hostname,
protocol,
port,
addresses: grouped.addresses,
process: grouped.process,
localOnly: grouped.localOnly,
lastSeenAt: s.lastSeenAt,
});
}
}
out.sort((a, b) => a.serverName.localeCompare(b.serverName) || a.port - b.port || a.protocol.localeCompare(b.protocol));
res.json({ ports: out, reportingServers: rows.filter((s) => s.listeningPorts !== null).length, totalServers: rows.length });
}));
// ─── Manually-recorded port openings (router/firewall/cloud security group, etc.) ──
// Blank/omitted optional fields normalize to null right here, so every downstream handler can
// just use parsed.data as-is (matching the DB columns, which store NULL, not empty strings).
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((v) => v || null);
const forwardInput = z.object({
label: z.string().trim().min(1).max(200),
externalPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"]).default("tcp"),
serverId: z
.number()
.int()
.nullish()
.transform((v) => v ?? null),
destination: optionalText(255),
internalPort: z
.number()
.int()
.min(1)
.max(65535)
.nullish()
.transform((v) => v ?? null),
source: optionalText(200),
comment: optionalText(2000),
});
const updateForwardInput = forwardInput.partial();
portsRouter.get("/forwards", asyncHandler(async (_req, res) => {
const rows = await db.query.portForwards.findMany({ orderBy: (p, { asc }) => [asc(p.externalPort)] });
const serverRows = await db.select({ id: servers.id, name: servers.name }).from(servers);
const nameById = new Map(serverRows.map((s) => [s.id, s.name]));
res.json({
forwards: rows.map((r) => ({ ...r, serverName: r.serverId !== null ? nameById.get(r.serverId) ?? null : null })),
});
}));
portsRouter.post("/forwards", requireRole("operator"), asyncHandler(async (req, res) => {
const parsed = forwardInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const data = parsed.data;
if (data.serverId) {
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
if (!server) return res.status(400).json({ error: "invalid_server" });
}
const [created] = await db.insert(portForwards).values(data).returning();
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "create_port_forward",
targetType: "port_forward",
targetId: created.id,
detail: { label: created.label, externalPort: created.externalPort, protocol: created.protocol },
});
res.status(201).json({ forward: created });
}));
portsRouter.patch("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const parsed = updateForwardInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const data = parsed.data;
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
if (data.serverId) {
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
if (!server) return res.status(400).json({ error: "invalid_server" });
}
const [updated] = await db
.update(portForwards)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(portForwards.id, id))
.returning();
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "update_port_forward",
targetType: "port_forward",
targetId: id,
detail: { label: updated.label, externalPort: updated.externalPort, protocol: updated.protocol },
});
res.json({ forward: updated });
}));
portsRouter.delete("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
await db.delete(portForwards).where(eq(portForwards.id, id));
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "delete_port_forward",
targetType: "port_forward",
targetId: id,
detail: { label: existing.label, externalPort: existing.externalPort, protocol: existing.protocol },
});
res.status(204).end();
}));
+1 -39
View File
@@ -6,18 +6,12 @@ import { servers, serverPorts } from "../db/schema.js";
import { requireRole } from "../auth/middleware.js"; import { requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { beginScan, endScan, MAX_SCAN_SPAN, resolveScanTarget, scanPorts, toRanges } from "../services/portScan.js"; import { beginScan, endScan, MAX_SCAN_SPAN, resolveScanTarget, scanPorts, toRanges } from "../services/portScan.js";
import { type AgentPort, groupAgentPorts, parseJson } from "../services/agentPorts.js";
import { asyncHandler } from "../utils/asyncHandler.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. // Mounted under /api/servers/:id/ports by the servers router, which has already required a signed-in user.
export const serverPortsRouter = Router({ mergeParams: true }); export const serverPortsRouter = Router({ mergeParams: true });
interface AgentPort {
protocol: "tcp" | "udp";
port: number;
address: string;
process?: string;
}
interface StoredScan { interface StoredScan {
at: string; at: string;
address: string; address: string;
@@ -45,38 +39,6 @@ export interface PortEntry {
state: "open" | "reserved"; 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) { async function buildPortList(serverId: number) {
const [server] = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1); const [server] = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return null; if (!server) return null;
+53
View File
@@ -0,0 +1,53 @@
// Shared between the per-server Ports card (routes/serverPorts.ts) and the cross-server
// Network > Ports page (routes/ports.ts) — both read the same agent-reported "listeningPorts"
// JSON column and need the same one-row-per-socket -> one-row-per-protocol+port grouping.
export interface AgentPort {
protocol: "tcp" | "udp";
port: number;
address: string;
process?: string;
}
export interface GroupedAgentPort {
addresses: string[];
process: string | null;
localOnly: boolean;
}
export function parseJson<T>(text: string | null | undefined, fallback: T): T {
if (!text) return fallback;
try {
return JSON.parse(text) as T;
} catch {
return fallback;
}
}
export 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, keyed "tcp:443". */
export function groupAgentPorts(raw: AgentPort[]): Map<string, GroupedAgentPort> {
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, GroupedAgentPort>();
for (const [key, entry] of grouped) {
const addresses = [...entry.addresses];
out.set(key, { addresses, process: entry.process, localOnly: addresses.every(isLoopback) });
}
return out;
}
export function splitPortKey(key: string): { protocol: "tcp" | "udp"; port: number } {
const [protocol, port] = key.split(":");
return { protocol: protocol as "tcp" | "udp", port: Number(port) };
}
+2
View File
@@ -9,6 +9,7 @@ import AuditLog from "./pages/AuditLog";
import DiagLog from "./pages/DiagLog"; import DiagLog from "./pages/DiagLog";
import Secrets from "./pages/Secrets"; import Secrets from "./pages/Secrets";
import Ipam from "./pages/Ipam"; import Ipam from "./pages/Ipam";
import Ports from "./pages/Ports";
import Dns from "./pages/Dns"; import Dns from "./pages/Dns";
import Servers from "./pages/Servers"; import Servers from "./pages/Servers";
import ServerDetail from "./pages/ServerDetail"; import ServerDetail from "./pages/ServerDetail";
@@ -93,6 +94,7 @@ export default function App() {
<Route path="/servers/:id" element={<ServerDetail user={user} />} /> <Route path="/servers/:id" element={<ServerDetail user={user} />} />
<Route path="/dns" element={<Dns user={user} />} /> <Route path="/dns" element={<Dns user={user} />} />
<Route path="/ipam" element={<Ipam user={user} />} /> <Route path="/ipam" element={<Ipam user={user} />} />
<Route path="/ports" element={<Ports user={user} />} />
<Route path="/secrets" element={<Secrets user={user} />} /> <Route path="/secrets" element={<Secrets user={user} />} />
<Route path="/integrations" element={<Integrations user={user} />} /> <Route path="/integrations" element={<Integrations user={user} />} />
<Route path="/generator" element={<Generator />} /> <Route path="/generator" element={<Generator />} />
+55
View File
@@ -140,6 +140,50 @@ export interface IpamInput {
notes?: string; notes?: string;
} }
export interface AgentPortRow {
serverId: number;
serverName: string;
serverHostname: string | null;
protocol: "tcp" | "udp";
port: number;
addresses: string[];
process: string | null;
localOnly: boolean;
lastSeenAt: string | null;
}
export interface AgentPortsResponse {
ports: AgentPortRow[];
reportingServers: number;
totalServers: number;
}
export interface PortForward {
id: number;
label: string;
externalPort: number;
protocol: "tcp" | "udp";
serverId: number | null;
serverName: string | null;
destination: string | null;
internalPort: number | null;
source: string | null;
comment: string | null;
createdAt: string;
updatedAt: string;
}
export interface PortForwardInput {
label: string;
externalPort: number;
protocol: "tcp" | "udp";
serverId?: number | null;
destination?: string | null;
internalPort?: number | null;
source?: string | null;
comment?: string | null;
}
export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium"; export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium";
export interface GotifySettings { export interface GotifySettings {
@@ -951,6 +995,17 @@ export const api = {
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }), syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }), syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
}, },
ports: {
agent: () => request<AgentPortsResponse>("/api/ports/agent"),
forwards: {
list: () => request<{ forwards: PortForward[] }>("/api/ports/forwards"),
create: (data: PortForwardInput) =>
request<{ forward: PortForward }>("/api/ports/forwards", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: Partial<PortForwardInput>) =>
request<{ forward: PortForward }>(`/api/ports/forwards/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<void>(`/api/ports/forwards/${id}`, { method: "DELETE" }),
},
},
dns: { dns: {
providerFields: () => providerFields: () =>
request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"), request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"),
+2
View File
@@ -30,6 +30,7 @@ import {
IconActivityHeartbeat, IconActivityHeartbeat,
IconShieldCheck, IconShieldCheck,
IconTicket, IconTicket,
IconPlug,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
import { formatRemaining } from "../utils/duration"; import { formatRemaining } from "../utils/duration";
@@ -80,6 +81,7 @@ const NAV: NavEntry[] = [
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> }, { to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
{ to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> }, { to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> },
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> }, { to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
{ to: "/ports", label: "Ports", icon: <IconPlug size={20} /> },
{ to: "/consistency", label: "Consistency", icon: <IconListCheck size={20} /> }, { to: "/consistency", label: "Consistency", icon: <IconListCheck size={20} /> },
], ],
}, },
+431
View File
@@ -0,0 +1,431 @@
import { useEffect, useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import {
api,
type AgentPortRow,
type CurrentUser,
type PortForward,
type PortForwardInput,
type ServerRecord,
} from "../api/client";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv";
import { formatAgo } from "../utils/duration";
const emptyForm: PortForwardInput = {
label: "",
externalPort: 0,
protocol: "tcp",
serverId: null,
destination: "",
internalPort: null,
source: "",
comment: "",
};
export default function Ports({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator";
const [agentPorts, setAgentPorts] = useState<AgentPortRow[] | null>(null);
const [reportingServers, setReportingServers] = useState(0);
const [totalServers, setTotalServers] = useState(0);
const [agentError, setAgentError] = useState<string | null>(null);
const [servers, setServers] = useState<ServerRecord[] | null>(null);
const [forwards, setForwards] = useState<PortForward[] | null>(null);
const [forwardError, setForwardError] = useState<string | null>(null);
const [editing, setEditing] = useState<PortForward | null>(null);
const [adding, setAdding] = useState(false);
const [form, setForm] = useState<PortForwardInput>(emptyForm);
const [saving, setSaving] = useState(false);
function loadAgentPorts() {
api.ports
.agent()
.then((res) => {
setAgentPorts(res.ports);
setReportingServers(res.reportingServers);
setTotalServers(res.totalServers);
})
.catch((err) => setAgentError(err instanceof Error ? err.message : String(err)));
}
function loadForwards() {
api.ports.forwards
.list()
.then((res) => setForwards(res.forwards))
.catch((err) => setForwardError(err instanceof Error ? err.message : String(err)));
}
useEffect(() => {
loadAgentPorts();
loadForwards();
api.servers
.list()
.then((res) => setServers(res.servers))
.catch(() => {});
}, []);
const { sorted: sortedAgentPorts, sortKey: agentSortKey, sortDir: agentSortDir, requestSort: requestAgentSort } = useSortable(
agentPorts,
"serverName",
);
const { pageItems: agentPageItems, page: agentPage, setPage: setAgentPage, pageCount: agentPageCount, totalCount: agentTotalCount } = usePagination(
sortedAgentPorts,
);
const { sorted: sortedForwards, sortKey: forwardSortKey, sortDir: forwardSortDir, requestSort: requestForwardSort } = useSortable(
forwards,
"externalPort",
);
function exportAgentCsv() {
downloadCsv(
"agent-reported-ports.csv",
["Server", "Hostname", "Protocol", "Port", "Addresses", "Process", "Local only"],
(sortedAgentPorts ?? []).map((p) => [
p.serverName,
p.serverHostname ?? "",
p.protocol,
p.port,
p.addresses.join(", "),
p.process ?? "",
p.localOnly ? "yes" : "no",
]),
);
}
function exportForwardsCsv() {
downloadCsv(
"port-forwards.csv",
["Label", "External port", "Protocol", "Server", "Destination", "Internal port", "Source", "Comment"],
(sortedForwards ?? []).map((f) => [
f.label,
f.externalPort,
f.protocol,
f.serverName ?? "",
f.destination ?? "",
f.internalPort ?? "",
f.source ?? "",
f.comment ?? "",
]),
);
}
function startAdd() {
setAdding(true);
setEditing(null);
setForm(emptyForm);
}
function startEdit(fwd: PortForward) {
setEditing(fwd);
setAdding(false);
setForm({
label: fwd.label,
externalPort: fwd.externalPort,
protocol: fwd.protocol,
serverId: fwd.serverId,
destination: fwd.destination ?? "",
internalPort: fwd.internalPort,
source: fwd.source ?? "",
comment: fwd.comment ?? "",
});
}
function cancelForm() {
setAdding(false);
setEditing(null);
setForm(emptyForm);
}
async function submit(e: FormEvent) {
e.preventDefault();
setForwardError(null);
setSaving(true);
try {
if (editing) {
await api.ports.forwards.update(editing.id, form);
} else {
await api.ports.forwards.create(form);
}
cancelForm();
loadForwards();
} catch (err) {
setForwardError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function remove(fwd: PortForward) {
if (!confirm(`Delete port opening "${fwd.label}"?`)) return;
setForwardError(null);
try {
await api.ports.forwards.remove(fwd.id);
loadForwards();
} catch (err) {
setForwardError(err instanceof Error ? err.message : String(err));
}
}
const showForm = adding || editing;
return (
<>
<h2 className="page-title mb-3">Ports</h2>
<div className="row g-3 mb-3">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Ports reported by agents</h3>
<div className="card-actions">
<span className="text-secondary me-3">
{reportingServers} of {totalServers} server{totalServers !== 1 ? "s" : ""} reporting
</span>
<button className="btn btn-sm btn-outline-secondary" onClick={exportAgentCsv} disabled={!sortedAgentPorts?.length}>
Export CSV
</button>
<button className="btn btn-sm btn-outline-secondary" onClick={loadAgentPorts}>
Refresh
</button>
</div>
</div>
{agentError && <div className="alert alert-danger m-3">{agentError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<AgentPortRow> label="Server" sortKeyName="serverName" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
<SortableTh<AgentPortRow> label="Protocol" sortKeyName="protocol" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
<SortableTh<AgentPortRow> label="Port" sortKeyName="port" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
<th>Address</th>
<th>Process</th>
<SortableTh<AgentPortRow> label="Last report" sortKeyName="lastSeenAt" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
</tr>
</thead>
<tbody>
{(agentPageItems ?? []).map((p) => (
<tr key={`${p.serverId}-${p.protocol}-${p.port}`}>
<td>
<Link to={`/servers/${p.serverId}`}>{p.serverName}</Link>
{p.serverHostname && <div className="text-secondary small">{p.serverHostname}</div>}
</td>
<td className="text-uppercase text-secondary">{p.protocol}</td>
<td>{p.port}</td>
<td className="text-secondary">
{p.addresses.join(", ")}
{p.localOnly && <span className="badge bg-secondary-lt text-secondary ms-2">local only</span>}
</td>
<td className="text-secondary">{p.process ?? "—"}</td>
<td className="text-secondary">{p.lastSeenAt ? formatAgo(p.lastSeenAt) : "—"}</td>
</tr>
))}
{agentPorts !== null && agentPorts.length === 0 && (
<tr>
<td colSpan={6} className="text-secondary text-center">
No servers have reported listening ports yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={agentPage} pageCount={agentPageCount} totalCount={agentTotalCount} onPageChange={setAgentPage} />
</div>
</div>
</div>
{canEdit && showForm && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">{editing ? `Edit "${editing.label}"` : "Add a manual port opening"}</h3>
</div>
<form onSubmit={submit}>
<div className="card-body row g-3">
<div className="col-md-3">
<label className="form-label">Label</label>
<input
className="form-control"
required
placeholder="Plex remote access"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
/>
</div>
<div className="col-md-2">
<label className="form-label">External port</label>
<input
type="number"
className="form-control"
required
min={1}
max={65535}
value={form.externalPort || ""}
onChange={(e) => setForm({ ...form, externalPort: Number(e.target.value) })}
/>
</div>
<div className="col-md-2">
<label className="form-label">Protocol</label>
<select
className="form-select"
value={form.protocol}
onChange={(e) => setForm({ ...form, protocol: e.target.value as "tcp" | "udp" })}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div className="col-md-2">
<label className="form-label">Internal port</label>
<input
type="number"
className="form-control"
min={1}
max={65535}
placeholder="if different"
value={form.internalPort ?? ""}
onChange={(e) => setForm({ ...form, internalPort: e.target.value ? Number(e.target.value) : null })}
/>
</div>
<div className="col-md-3">
<label className="form-label">Server (optional)</label>
<select
className="form-select"
value={form.serverId ?? ""}
onChange={(e) => setForm({ ...form, serverId: e.target.value ? Number(e.target.value) : null })}
>
<option value="">— none —</option>
{(servers ?? []).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Destination</label>
<input
className="form-control"
placeholder="IP/host, if not a tracked server"
value={form.destination ?? ""}
onChange={(e) => setForm({ ...form, destination: e.target.value })}
/>
</div>
<div className="col-md-3">
<label className="form-label">Source</label>
<input
className="form-control"
placeholder="Home router, OPNsense, Cloudflare Tunnel…"
value={form.source ?? ""}
onChange={(e) => setForm({ ...form, source: e.target.value })}
/>
</div>
<div className="col-md-3">
<label className="form-label">Comment</label>
<input
className="form-control"
value={form.comment ?? ""}
onChange={(e) => setForm({ ...form, comment: e.target.value })}
/>
</div>
</div>
<div className="card-footer d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{editing ? "Save changes" : "Add port opening"}
</button>
<button type="button" className="btn" onClick={cancelForm}>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card">
<div className="card-header">
<h3 className="card-title">Manual port openings</h3>
<p className="text-secondary mb-0 me-auto">
Router port forwards, edge firewall rules, cloud security groups — anything opened further out on the
network that this app can't see on its own.
</p>
<div className="card-actions">
{canEdit && !showForm && (
<button className="btn btn-primary btn-sm" onClick={startAdd}>
Add port opening
</button>
)}
<button className="btn btn-sm btn-outline-secondary" onClick={exportForwardsCsv} disabled={!sortedForwards?.length}>
Export CSV
</button>
</div>
</div>
{forwardError && <div className="alert alert-danger m-3">{forwardError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<PortForward> label="Label" sortKeyName="label" activeKey={forwardSortKey} direction={forwardSortDir} onSort={requestForwardSort} />
<SortableTh<PortForward>
label="External port"
sortKeyName="externalPort"
activeKey={forwardSortKey}
direction={forwardSortDir}
onSort={requestForwardSort}
/>
<th>Destination</th>
<SortableTh<PortForward> label="Source" sortKeyName="source" activeKey={forwardSortKey} direction={forwardSortDir} onSort={requestForwardSort} />
<th>Comment</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{(sortedForwards ?? []).map((f) => (
<tr key={f.id}>
<td>{f.label}</td>
<td>
<span className="text-uppercase text-secondary me-1">{f.protocol}</span>
{f.externalPort}
{f.internalPort && f.internalPort !== f.externalPort && (
<span className="text-secondary"> → {f.internalPort}</span>
)}
</td>
<td className="text-secondary">
{f.serverId ? <Link to={`/servers/${f.serverId}`}>{f.serverName}</Link> : f.destination || "—"}
{f.serverId && f.destination && <div className="small">{f.destination}</div>}
</td>
<td className="text-secondary">{f.source ?? "—"}</td>
<td className="text-secondary">{f.comment ?? "—"}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => startEdit(f)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(f)}>
Delete
</button>
</div>
</td>
)}
</tr>
))}
{forwards !== null && forwards.length === 0 && (
<tr>
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
No manual port openings recorded yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}