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:
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -30,6 +30,7 @@ page's own top-level link.
|
||||
| DNS | `/dns` | ✅ | ✅ | ✅ |
|
||||
| Domains | `/domains` | ✅ | ✅ | ✅ |
|
||||
| IP Addresses | `/ipam` | ✅ | ✅ | ✅ |
|
||||
| Ports | `/ports` | ✅ | ✅ | ✅ |
|
||||
| Consistency | `/consistency` | ✅ | ✅ | ✅ |
|
||||
| **Automation** | | | | |
|
||||
| Semaphore | `/semaphore` | ✅ | ✅ | ✅ |
|
||||
@@ -71,6 +72,8 @@ even further, to admin only:
|
||||
linking/unlinking it to a Proxmox guest just need operator.
|
||||
- **Tags**: creating, renaming, recoloring, or deleting a tag is
|
||||
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,
|
||||
display prefs, log retention, backup/restore) is admin-only, matching
|
||||
the page itself being admin-only.
|
||||
|
||||
@@ -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
@@ -99,6 +99,13 @@
|
||||
"when": 1790449897833,
|
||||
"tag": "0013_sturdy_bloodstorm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1790718056783,
|
||||
"tag": "0014_empty_gabe_jones",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -331,6 +331,33 @@ export const serverPorts = sqliteTable(
|
||||
(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 ───────────────────────────────────────────────────
|
||||
|
||||
export const domainOrigins = ["manual", "zone"] as const;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { domainsRouter } from "./routes/domains.js";
|
||||
import { consistencyRouter } from "./routes/consistency.js";
|
||||
import { privacyRouter } from "./routes/privacy.js";
|
||||
import { tagsRouter } from "./routes/tags.js";
|
||||
import { portsRouter } from "./routes/ports.js";
|
||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
||||
@@ -102,6 +103,7 @@ app.use("/api/domains", domainsRouter);
|
||||
app.use("/api/consistency", consistencyRouter);
|
||||
app.use("/api/privacy", privacyRouter);
|
||||
app.use("/api/tags", tagsRouter);
|
||||
app.use("/api/ports", portsRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -6,18 +6,12 @@ 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 { type AgentPort, groupAgentPorts, parseJson } from "../services/agentPorts.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;
|
||||
@@ -45,38 +39,6 @@ export interface PortEntry {
|
||||
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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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} /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user