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

+2
View File
@@ -9,6 +9,7 @@ import AuditLog from "./pages/AuditLog";
import DiagLog from "./pages/DiagLog";
import Secrets from "./pages/Secrets";
import Ipam from "./pages/Ipam";
import Ports from "./pages/Ports";
import Dns from "./pages/Dns";
import Servers from "./pages/Servers";
import ServerDetail from "./pages/ServerDetail";
@@ -93,6 +94,7 @@ export default function App() {
<Route path="/servers/:id" element={<ServerDetail user={user} />} />
<Route path="/dns" element={<Dns 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="/integrations" element={<Integrations user={user} />} />
<Route path="/generator" element={<Generator />} />
+55
View File
@@ -140,6 +140,50 @@ export interface IpamInput {
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 interface GotifySettings {
@@ -951,6 +995,17 @@ export const api = {
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { 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: {
providerFields: () =>
request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"),
+2
View File
@@ -30,6 +30,7 @@ import {
IconActivityHeartbeat,
IconShieldCheck,
IconTicket,
IconPlug,
} from "@tabler/icons-react";
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
import { formatRemaining } from "../utils/duration";
@@ -80,6 +81,7 @@ const NAV: NavEntry[] = [
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
{ to: "/domains", label: "Domains", icon: <IconWorldWww 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} /> },
],
},
+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>
</>
);
}