Compare commits

...
2 Commits
Author SHA1 Message Date
bobbanandClaude Sonnet 5 236b1da0dc 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>
2026-09-29 23:48:35 +02:00
bobbanandClaude Sonnet 5 4e48377348 Document every notification this app sends in NOTIFICATIONS.md
Covers all 16 notification types (daily reminders, state-based health/
automation alerts, real-time DNS and integration-down alerts, and the
quiet-hours digest), what triggers each, and what does and doesn't
respect quiet hours and maintenance windows. Every trigger condition
and threshold was cross-checked against the actual scheduler/monitor
code, not just the settings labels.

Also fixes the "Daily reminder time" hint text in NotificationSettings,
which had gone stale — it listed only 4 of the 6 checks that actually
share that schedule (missing domain expiry and PBS verification).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 23:48:19 +02:00
16 changed files with 2602 additions and 40 deletions
+112
View File
@@ -0,0 +1,112 @@
# Notifications
Every notification in Homelab Manager is sent through the same pipe
(`server/src/services/notify.ts`) to whichever channels are enabled under
**Settings → Notifications**: Gotify, ntfy, SMTP (email), and a generic
JSON webhook. All four fire for every notification below — there's no
per-event channel routing, only a per-event on/off toggle (the "Notify
on" list on that same page) and, for the daily ones, one shared
time/timezone.
Two settings apply to *every* notification regardless of what triggered
it:
- **Quiet hours** (Settings → Notifications → Quiet hours): while
enabled and inside the configured window, a notification is held
instead of sent immediately, then delivered as a single digest at the
window's end time. This matters most for the real-time alerts below —
the daily reminders already fire once at a time you pick, usually
outside the window anyway.
- **Maintenance windows** (the Maintenance page): opening one for a
server, integration, or DNS provider silences the alerts that target
it specifically (offline/disk-full for a server; storage, backup, and
automation-failure alerts for an integration; "integration down" for
that whole service type) — see the Maintenance page's own "What gets
silenced" panel for the exact list.
Where a trigger has a configurable threshold, that's noted in its row
below; several are fixed and can't be changed from the UI.
## Daily reminders
These six checks share one schedule: **Settings → Notifications → Daily
reminder time** (default 08:00) and **Timezone** (default UTC). Unlike
the state-based alerts further down, these **re-send every day the
condition is still true** — there's no "only once" de-duplication, so an
expired secret you haven't renewed yet will be mentioned again at the
next day's check, and the day after that.
Each one also runs once at server startup if it hasn't already run
today (e.g. after an upgrade or a period offline), so you're not waiting
until the next scheduled time to catch up.
| Check | "Notify on" toggle | Fires when | Configured at |
|---|---|---|---|
| Secret expiry | *Secret expiry reminder* | Any tracked secret (API token, SSL cert, password, generic) is expired or within its own configured warning window. SSL certificates with a host:port are re-checked live first, so this reflects the real current expiry, not a stale saved date. A certificate that couldn't be re-checked live is mentioned separately, so the expiry shown may be stale. | Per-secret warning threshold, set when adding/editing that secret |
| Domain expiry | *Domain registration expiring or expired (daily reminder)* | Any tracked domain registration is expired or within the warning window; every domain is re-looked-up against its registry first. A domain whose registry lookup has been failing for 3+ days is also mentioned, separately, as "may be stale." Registries that don't publish an expiry (e.g. `.de`, `.eu`) are tracked but never trigger this. | Settings → Notifications → Health checks → **Domain expiry warning** (default 30 days) |
| Tailscale key expiry | *Tailscale key expiry reminder* | Any device's node key (across every enabled Tailscale integration) is within 30 days of expiring, or already expired. Devices with key expiry disabled are skipped. | Fixed at 30 days |
| Docker image updates | *Docker image update available* | Any container (across every enabled Dockhand integration) has an image update available, per Dockhand's own cached update-check results — this doesn't trigger a fresh registry lookup, just reads what the Docker page itself would show. | Not configurable (reflects Dockhand's own check interval) |
| Proxmox backups | *Proxmox backup failed or a guest has no coverage* | Two independent conditions, both under this one toggle: (1) a node's most recent `vzdump` backup task (across every enabled Proxmox integration) didn't succeed; (2) a VM/LXC isn't covered by any enabled backup job at all. | Not configurable |
| Proxmox Backup Server verification | *Proxmox Backup Server snapshot failed verification* | Across every enabled PBS integration: any datastore has at least one stored snapshot whose verification state is "failed", or a datastore couldn't be read at all (e.g. a permissions problem). This is distinct from the Proxmox check above — PVE only knows a backup *ran*, PBS is the only place that knows whether the stored data still verifies. | Not configurable |
## State-based alerts
These two run on a fixed **15-minute interval** (matching the agent's
own default report interval) — not the daily reminder time above, and
not user-configurable. Unlike the daily reminders, these are
**state-based**: a problem is announced once when it first appears, and
once more when it clears — not repeated on every 15-minute pass while it
continues. A condition that can't currently be read (an integration
that's down, a server whose agent hasn't reported) is held exactly as it
was rather than cleared or re-announced, so a temporary read failure
can't fake a recovery. On first-ever run (or right after upgrading to
this feature), whatever's already failing is recorded silently as the
starting baseline rather than announced all at once.
| Check | "Notify on" toggle | Fires when | Configured at |
|---|---|---|---|
| Health — server offline | *Server offline, disk nearly full, or Synology volume/disk problem* | A server's agent hasn't reported for longer than the offline threshold. Held for the first 20 minutes after this app restarts, since agents haven't had a chance to report yet. | Settings → Notifications → Health checks → **Server offline after** (default 60 min) |
| Health — disk usage | *(same toggle)* | A server disk, a Proxmox node's root filesystem or any of its storages, or a Synology volume, is at or above the usage threshold. A server that's currently offline isn't also judged on disk usage (its figures are stale). | Settings → Notifications → Health checks → **Disk usage alert at** (default 90%) |
| Health — Synology status | *(same toggle)* | A Synology volume's status isn't "normal", or a disk's status/SMART result isn't normal, or it's over the bad-sector or under the remaining-life threshold. | Not configurable |
| Automation — failed run | *Semaphore template or Gitea workflow run failed* | A Semaphore template's, or a Gitea repo's, most recent run status is a clear failure. A run that's still going, was cancelled, or was stopped by hand doesn't count as failed or as a recovery — it's left exactly as it was. For Gitea this follows the repo's most recent run on any workflow or branch. | Not configurable |
## Real-time alerts
These fire immediately, as the triggering action happens — not on any
schedule.
| Event | "Notify on" toggle | Fires when |
|---|---|---|
| DNS record added | *DNS record added* | A DNS record is created against any DNS provider through this app (manually, or via any automated action that writes one). |
| DNS record updated | *DNS record updated* | An existing DNS record is edited. |
| DNS record deleted | *DNS record deleted* | A DNS record is deleted. |
| Integration/DNS provider down | *Integration/DNS provider failing repeatedly* | Any integration or DNS provider adapter's calls fail a configurable number of times **in a row**. Tracked per adapter *type* (e.g. "proxmox"), not per individual integration row — with two Proxmox integrations, a streak of failures on either one counts toward the same total. Only fires once per failure streak (not on every failure past the threshold), and is skipped entirely if that type is currently in a maintenance window. | Settings → Notifications → **Alert after** (default 3 consecutive failures) |
| Integration/DNS provider recovered | *(same toggle)* | The next call for that source succeeds, after a "down" alert was already sent for the current streak. |
## Digest
| Notification | Fires when |
|---|---|
| "Notifications from quiet hours" | Once, at quiet hours' configured end time, **only if enabled and only if at least one notification was held** during the window. Bundles every held notification's title and message into one message, then clears the queue. |
## What does *not* send a notification
Worth calling out explicitly, since it's easy to assume everything in
the app alerts on something:
- **osTicket** — the integration lists open tickets, but there is no
scheduled check or alert wired up for it (e.g. nothing pings you about
a newly-overdue ticket). It's a read-only dashboard/page today.
- **phpIPAM sync**, **Uptime Kuma monitor/maintenance import**, and any
other manual "Sync from X" action — these run when you click the
button and report their result on the page itself, not via a
notification.
- **Test buttons** (Settings → Notifications → each channel's "Send
test") send a one-off message through that one channel only, to
confirm it's wired up correctly — not a real event and not affected by
quiet hours.
- **Consistency reports**, **Diagnostic Log**, and **Audit Log** are all
read-only views you check yourself; none of them push a notification
on their own (the Diagnostic Log's failures are what feed the
integration-down alert above, but reading the log itself never
triggers anything).
+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
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
+3
View File
@@ -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.
+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,
"tag": "0013_sturdy_bloodstorm",
"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)],
);
// 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;
+2
View File
@@ -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));
+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 { 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;
+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 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>
</>
);
}
@@ -633,7 +633,10 @@ export default function NotificationSettings() {
disabled={!dailyChecksEnabled}
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
/>
<div className="form-hint">Shared by the secret, Tailscale key, Docker update, and Proxmox backup reminders above.</div>
<div className="form-hint">
Shared by the secret, Tailscale key, Docker update, Proxmox backup, Proxmox Backup Server, and
domain expiry reminders above.
</div>
</div>
<div className="col-6">
<label className="form-label">Timezone</label>