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>
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|