From 236b1da0dc93632ce21675610fdf60d58aa205c1 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 29 Sep 2026 23:48:35 +0200 Subject: [PATCH] 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 --- README.md | 14 + ROLES.md | 3 + server/drizzle/0014_empty_gabe_jones.sql | 14 + server/drizzle/meta/0014_snapshot.json | 1695 ++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 27 + server/src/index.ts | 2 + server/src/routes/ports.ts | 180 +++ server/src/routes/serverPorts.ts | 40 +- server/src/services/agentPorts.ts | 53 + web/src/App.tsx | 2 + web/src/api/client.ts | 55 + web/src/layout/AppShell.tsx | 2 + web/src/pages/Ports.tsx | 431 ++++++ 14 files changed, 2486 insertions(+), 39 deletions(-) create mode 100644 server/drizzle/0014_empty_gabe_jones.sql create mode 100644 server/drizzle/meta/0014_snapshot.json create mode 100644 server/src/routes/ports.ts create mode 100644 server/src/services/agentPorts.ts create mode 100644 web/src/pages/Ports.tsx diff --git a/README.md b/README.md index 2a40009..452555d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROLES.md b/ROLES.md index bc8c8b3..0112e60 100644 --- a/ROLES.md +++ b/ROLES.md @@ -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. diff --git a/server/drizzle/0014_empty_gabe_jones.sql b/server/drizzle/0014_empty_gabe_jones.sql new file mode 100644 index 0000000..0629e8e --- /dev/null +++ b/server/drizzle/0014_empty_gabe_jones.sql @@ -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 +); diff --git a/server/drizzle/meta/0014_snapshot.json b/server/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..8188796 --- /dev/null +++ b/server/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1695 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3530a429-a70f-467e-ba03-13b46896e0ca", + "prevId": "cfc111bc-750b-462b-a59d-7a5bce844a8f", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "consistency_ignores": { + "name": "consistency_ignores", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "consistency_ignores_key_unique": { + "name": "consistency_ignores_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "diag_log": { + "name": "diag_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_providers": { + "name": "dns_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "dns_providers_credential_id_integration_credentials_id_fk": { + "name": "dns_providers_credential_id_integration_credentials_id_fk", + "tableFrom": "dns_providers", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_records_cache": { + "name": "dns_records_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl": { + "name": "ttl", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxied": { + "name": "proxied", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dns_records_cache_provider_id_dns_providers_id_fk": { + "name": "dns_records_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_records_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_zones_cache": { + "name": "dns_zones_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "dns_zones_cache_provider_zone_idx": { + "name": "dns_zones_cache_provider_zone_idx", + "columns": [ + "provider_id", + "zone_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dns_zones_cache_provider_id_dns_providers_id_fk": { + "name": "dns_zones_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_zones_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domains": { + "name": "domains", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registrar": { + "name": "registrar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lookup_source": { + "name": "lookup_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_ok_at": { + "name": "last_checked_ok_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "domains_name_unique": { + "name": "domains_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_credentials": { + "name": "integration_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrations": { + "name": "integrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_credential_id_integration_credentials_id_fk": { + "name": "integrations_credential_id_integration_credentials_id_fk", + "tableFrom": "integrations", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ipam_entries": { + "name": "ipam_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "ipam_entries_ip_address_unique": { + "name": "ipam_entries_ip_address_unique", + "columns": [ + "ip_address" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_windows": { + "name": "maintenance_windows", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_queue": { + "name": "notification_queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "port_forwards": { + "name": "port_forwards", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_port": { + "name": "external_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tcp'" + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_port": { + "name": "internal_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "port_forwards_server_id_servers_id_fk": { + "name": "port_forwards_server_id_servers_id_fk", + "tableFrom": "port_forwards", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_expression": { + "name": "schedule_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_stale": { + "name": "is_stale", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "scheduled_tasks_server_id_servers_id_fk": { + "name": "scheduled_tasks_server_id_servers_id_fk", + "tableFrom": "scheduled_tasks", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secrets": { + "name": "secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "warn_days": { + "name": "warn_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_host": { + "name": "check_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_port": { + "name": "check_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_links": { + "name": "server_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "server_links_server_id_servers_id_fk": { + "name": "server_links_server_id_servers_id_fk", + "tableFrom": "server_links", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_ports": { + "name": "server_ports", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tcp'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open": { + "name": "open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_seen_open_at": { + "name": "last_seen_open_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "server_ports_unique": { + "name": "server_ports_unique", + "columns": [ + "server_id", + "port", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "server_ports_server_id_servers_id_fk": { + "name": "server_ports_server_id_servers_id_fk", + "tableFrom": "server_ports", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "os_type": { + "name": "os_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'linux'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_token_hash": { + "name": "api_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token_prefix": { + "name": "api_token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_model": { + "name": "cpu_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_cores": { + "name": "cpu_cores", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_load_percent": { + "name": "cpu_load_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_total_bytes": { + "name": "mem_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_used_bytes": { + "name": "mem_used_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disks": { + "name": "disks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "listening_ports": { + "name": "listening_ports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_port_scan": { + "name": "last_port_scan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_integration_id": { + "name": "proxmox_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_node": { + "name": "proxmox_node", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_guest_type": { + "name": "proxmox_guest_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_vmid": { + "name": "proxmox_vmid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_proxmox_link": { + "name": "hide_proxmox_link", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "servers_proxmox_integration_id_integrations_id_fk": { + "name": "servers_proxmox_integration_id_integrations_id_fk", + "tableFrom": "servers", + "tableTo": "integrations", + "columnsFrom": [ + "proxmox_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag_definitions": { + "name": "tag_definitions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "tag_definitions_name_unique": { + "name": "tag_definitions_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_login_at": { + "name": "last_login_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_oidc_sub_unique": { + "name": "users_oidc_sub_unique", + "columns": [ + "oidc_sub" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 1b17d43..6bd264b 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 61aab7e..779d2f8 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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; diff --git a/server/src/index.ts b/server/src/index.ts index 5b093a0..83e3ded 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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)); diff --git a/server/src/routes/ports.ts b/server/src/routes/ports.ts new file mode 100644 index 0000000..262333b --- /dev/null +++ b/server/src/routes/ports.ts @@ -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(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(); +})); diff --git a/server/src/routes/serverPorts.ts b/server/src/routes/serverPorts.ts index 4a9ef97..10e6864 100644 --- a/server/src/routes/serverPorts.ts +++ b/server/src/routes/serverPorts.ts @@ -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(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 { - const grouped = new Map; process: string | null }>(); - for (const p of raw) { - const key = `${p.protocol}:${p.port}`; - const entry = grouped.get(key) ?? { addresses: new Set(), process: null }; - entry.addresses.add(p.address); - if (!entry.process && p.process) entry.process = p.process; - grouped.set(key, entry); - } - const out = new Map(); - 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; diff --git a/server/src/services/agentPorts.ts b/server/src/services/agentPorts.ts new file mode 100644 index 0000000..ca09681 --- /dev/null +++ b/server/src/services/agentPorts.ts @@ -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(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 { + const grouped = new Map; process: string | null }>(); + for (const p of raw) { + const key = `${p.protocol}:${p.port}`; + const entry = grouped.get(key) ?? { addresses: new Set(), process: null }; + entry.addresses.add(p.address); + if (!entry.process && p.process) entry.process = p.process; + grouped.set(key, entry); + } + const out = new Map(); + 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) }; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 2c8d168..6bf1617 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 89aead8..846efc1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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("/api/ipam/sync-proxmox", { method: "POST" }), syncPhpIpam: () => request("/api/ipam/sync-phpipam", { method: "POST" }), }, + ports: { + agent: () => request("/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) => + request<{ forward: PortForward }>(`/api/ports/forwards/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + remove: (id: number) => request(`/api/ports/forwards/${id}`, { method: "DELETE" }), + }, + }, dns: { providerFields: () => request<{ fields: Record }>("/api/dns/provider-fields"), diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index d7ba322..7307e42 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -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: }, { to: "/domains", label: "Domains", icon: }, { to: "/ipam", label: "IP Addresses", icon: }, + { to: "/ports", label: "Ports", icon: }, { to: "/consistency", label: "Consistency", icon: }, ], }, diff --git a/web/src/pages/Ports.tsx b/web/src/pages/Ports.tsx new file mode 100644 index 0000000..4774f33 --- /dev/null +++ b/web/src/pages/Ports.tsx @@ -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(null); + const [reportingServers, setReportingServers] = useState(0); + const [totalServers, setTotalServers] = useState(0); + const [agentError, setAgentError] = useState(null); + + const [servers, setServers] = useState(null); + const [forwards, setForwards] = useState(null); + const [forwardError, setForwardError] = useState(null); + + const [editing, setEditing] = useState(null); + const [adding, setAdding] = useState(false); + const [form, setForm] = useState(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 ( + <> +

Ports

+ +
+
+
+
+

Ports reported by agents

+
+ + {reportingServers} of {totalServers} server{totalServers !== 1 ? "s" : ""} reporting + + + +
+
+ {agentError &&
{agentError}
} +
+ + + + label="Server" sortKeyName="serverName" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} /> + label="Protocol" sortKeyName="protocol" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} /> + label="Port" sortKeyName="port" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} /> + + + label="Last report" sortKeyName="lastSeenAt" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} /> + + + + {(agentPageItems ?? []).map((p) => ( + + + + + + + + + ))} + {agentPorts !== null && agentPorts.length === 0 && ( + + + + )} + +
AddressProcess
+ {p.serverName} + {p.serverHostname &&
{p.serverHostname}
} +
{p.protocol}{p.port} + {p.addresses.join(", ")} + {p.localOnly && local only} + {p.process ?? "—"}{p.lastSeenAt ? formatAgo(p.lastSeenAt) : "—"}
+ No servers have reported listening ports yet. +
+
+ +
+
+
+ + {canEdit && showForm && ( +
+
+

{editing ? `Edit "${editing.label}"` : "Add a manual port opening"}

+
+
+
+
+ + setForm({ ...form, label: e.target.value })} + /> +
+
+ + setForm({ ...form, externalPort: Number(e.target.value) })} + /> +
+
+ + +
+
+ + setForm({ ...form, internalPort: e.target.value ? Number(e.target.value) : null })} + /> +
+
+ + +
+
+ + setForm({ ...form, destination: e.target.value })} + /> +
+
+ + setForm({ ...form, source: e.target.value })} + /> +
+
+ + setForm({ ...form, comment: e.target.value })} + /> +
+
+
+ + +
+
+
+ )} + +
+
+

Manual port openings

+

+ 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. +

+
+ {canEdit && !showForm && ( + + )} + +
+
+ {forwardError &&
{forwardError}
} +
+ + + + label="Label" sortKeyName="label" activeKey={forwardSortKey} direction={forwardSortDir} onSort={requestForwardSort} /> + + label="External port" + sortKeyName="externalPort" + activeKey={forwardSortKey} + direction={forwardSortDir} + onSort={requestForwardSort} + /> + + label="Source" sortKeyName="source" activeKey={forwardSortKey} direction={forwardSortDir} onSort={requestForwardSort} /> + + {canEdit && } + + + + {(sortedForwards ?? []).map((f) => ( + + + + + + + {canEdit && ( + + )} + + ))} + {forwards !== null && forwards.length === 0 && ( + + + + )} + +
DestinationCommentActions
{f.label} + {f.protocol} + {f.externalPort} + {f.internalPort && f.internalPort !== f.externalPort && ( + → {f.internalPort} + )} + + {f.serverId ? {f.serverName} : f.destination || "—"} + {f.serverId && f.destination &&
{f.destination}
} +
{f.source ?? "—"}{f.comment ?? "—"} +
+ + +
+
+ No manual port openings recorded yet. +
+
+
+ + ); +}