From 4c11158e989ddf3622040caa6ea93b6a3f0d401d Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sat, 26 Sep 2026 02:39:43 +0200 Subject: [PATCH] Add a Ports card to server pages: scan for open ports, find free ones, and keep notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each server's detail page now has a Ports card. "Scan…" runs a TCP connect scan of a chosen range from the app and shows what's open, along with the ranges that were actually confirmed free; clicking a free range starts a reservation. Any port can carry a service name and a comment, so the page also answers "what is this port for". A port with a note counts as taken even when nothing is listening, which is what makes a reservation work. Operators can scan and edit; everyone can read. Scans and note changes are audit-logged. Details that matter for correctness: - "Free" means the host actively refused the connection AND nobody has claimed the port. A port that never answers (firewall drop, host down) is reported as not answering, not as free. - A scan from elsewhere can't see services bound to localhost only, so the agent now also reports what is bound on the host (ss -tulnp) and those ports are treated as taken. They show as "local only". Existing agents keep working; re-run the install one-liner to add this. The field is validated leniently so one odd line can never cost an agent its whole report, tasks included. - If nothing answers at all during a scan, existing results are left alone instead of being marked all-closed. - Scan targets are limited to private addresses (RFC1918, Tailscale 100.64/10, link-local, IPv6 ULA/link-local); loopback and public addresses are refused. Ranges are capped at 20,000 ports, and only one scan runs per server at a time. - Rows exist only while they carry information: an open port, or one with a note. A closed port with no note disappears on the next scan; one with a note stays as "reserved". New table server_ports plus two columns on servers (migration 0009). Verified with 76 backend checks (scanner open/refused/filtered, address rules, agent report leniency, note/reserve/clear semantics, free-range calculation including the localhost-only case, roles, concurrency lock, no-response guard, audit entries, cascade delete) and by driving the real component against the real router in a browser. Real dev database mtime untouched. Not verified: the agent's ss/awk/jq pipeline on a real host — the awk step was checked against sample ss output and the script passes bash -n, but jq isn't available here to run the whole thing. Co-Authored-By: Claude Sonnet 5 --- README.md | 11 + agent/linux/report-tasks.sh | 21 +- server/drizzle/0009_sharp_magus.sql | 16 + server/drizzle/meta/0009_snapshot.json | 1386 ++++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 25 + server/src/routes/agentReport.ts | 17 + server/src/routes/serverPorts.ts | 338 ++++++ server/src/routes/servers.ts | 4 + server/src/services/portScan.ts | 136 +++ server/src/services/taskSync.ts | 3 + web/src/api/client.ts | 46 + web/src/components/ServerPorts.tsx | 505 +++++++++ web/src/pages/ServerDetail.tsx | 7 + 14 files changed, 2521 insertions(+), 1 deletion(-) create mode 100644 server/drizzle/0009_sharp_magus.sql create mode 100644 server/drizzle/meta/0009_snapshot.json create mode 100644 server/src/routes/serverPorts.ts create mode 100644 server/src/services/portScan.ts create mode 100644 web/src/components/ServerPorts.tsx diff --git a/README.md b/README.md index 15a2c8c..c62f0d4 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,17 @@ a problem that began during a window and is still present when it ends alerts then — a forgotten window can't hide an outage. A banner shows what's currently silenced to every signed-in user. +**Ports** — each server's detail page has a Ports card for finding free ports +and remembering what each one is for. "Scan…" runs a TCP scan of a port range +from the app against the server's address (private addresses only, up to +20,000 ports at a time) and lists what's open plus the ranges that were +confirmed free; a free range can be clicked to reserve a port. Any port can +carry a service name and a comment, and a port with a note counts as taken +even when nothing is listening. The agent also reports what is bound on the +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. + 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/agent/linux/report-tasks.sh b/agent/linux/report-tasks.sh index 17981e2..6582212 100644 --- a/agent/linux/report-tasks.sh +++ b/agent/linux/report-tasks.sh @@ -226,8 +226,26 @@ collect_system_info() { ') fi + # Everything bound to a port on this host, including services listening on localhost only (which a network + # scan from elsewhere can't see). `ss -p` needs root to name the process; without it the process is blank. + # One row per socket — the server groups them per port. + local ports_json="[]" + if command -v ss >/dev/null 2>&1; then + ports_json=$(ss -H -tulnp 2>/dev/null \ + | awk '{ + local = $5; port = local; sub(/.*:/, "", port); addr = local; sub(/:[0-9]+$/, "", addr); + proc = ""; if (match($0, /users:\(\("[^"]+"/)) { proc = substr($0, RSTART + 9, RLENGTH - 10) } + if (port ~ /^[0-9]+$/) print $1 "\t" port "\t" addr "\t" proc + }' \ + | jq -R -s -c ' + split("\n") | map(select(length > 0) | split("\t")) | + map({protocol: .[0], port: (.[1]|tonumber), address: .[2], process: (.[3] // "")}) + ') + fi + SYSTEM_JSON=$(jq -n \ --argjson ip_addresses "$ip_json" \ + --argjson listening_ports "$ports_json" \ --arg cpu_model "$cpu_model" \ --argjson cpu_cores "${cpu_cores:-0}" \ --argjson cpu_load_percent "$cpu_load_percent" \ @@ -238,7 +256,8 @@ collect_system_info() { ip_addresses: $ip_addresses, cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent }, memory: { total_bytes: $mem_total_bytes, used_bytes: $mem_used_bytes }, - disks: $disks + disks: $disks, + listening_ports: $listening_ports }') } diff --git a/server/drizzle/0009_sharp_magus.sql b/server/drizzle/0009_sharp_magus.sql new file mode 100644 index 0000000..7834660 --- /dev/null +++ b/server/drizzle/0009_sharp_magus.sql @@ -0,0 +1,16 @@ +CREATE TABLE `server_ports` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `server_id` integer NOT NULL, + `port` integer NOT NULL, + `protocol` text DEFAULT 'tcp' NOT NULL, + `label` text, + `comment` text, + `open` integer DEFAULT false NOT NULL, + `last_seen_open_at` text, + `updated_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `server_ports_unique` ON `server_ports` (`server_id`,`port`,`protocol`);--> statement-breakpoint +ALTER TABLE `servers` ADD `listening_ports` text;--> statement-breakpoint +ALTER TABLE `servers` ADD `last_port_scan` text; \ No newline at end of file diff --git a/server/drizzle/meta/0009_snapshot.json b/server/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..0f29f13 --- /dev/null +++ b/server/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1386 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6a9e331e-4576-4063-9306-6e8daf2468f4", + "prevId": "47ef2c5a-3706-45ff-93ec-6b8a25eed11e", + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "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": {} + }, + "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 cfd797a..06c4f71 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1790381917814, "tag": "0008_thin_boom_boom", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1790382571470, + "tag": "0009_sharp_magus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 79afc99..70a3010 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -220,6 +220,8 @@ export const servers = sqliteTable("servers", { memTotalBytes: integer("mem_total_bytes"), memUsedBytes: integer("mem_used_bytes"), disks: text("disks"), // JSON string: {mount, sizeBytes, usedBytes}[] + listeningPorts: text("listening_ports"), // JSON string: {protocol, port, address, process}[] — what the agent sees bound on the host + lastPortScan: text("last_port_scan"), // JSON string: summary of the most recent network scan from this app // Optional link to a Proxmox VM/LXC — set by an admin, not the agent. proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, { @@ -300,3 +302,26 @@ export const integrations = sqliteTable("integrations", { .notNull() .default(sql`(current_timestamp)`), }); + +// A port on a server that's either been seen open (by a scan or the agent) or that someone wrote a note about. +// Rows exist only while they carry information: an open port, or one with a label/comment ("reserved"). +export const serverPorts = sqliteTable( + "server_ports", + { + id: integer("id").primaryKey({ autoIncrement: true }), + serverId: integer("server_id") + .notNull() + .references(() => servers.id, { onDelete: "cascade" }), + port: integer("port").notNull(), + protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"), + label: text("label"), + comment: text("comment"), + // True when the last network scan connected to it. The agent's view is stored on the server row instead. + open: integer("open", { mode: "boolean" }).notNull().default(false), + lastSeenOpenAt: text("last_seen_open_at"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)], +); diff --git a/server/src/routes/agentReport.ts b/server/src/routes/agentReport.ts index c9f561b..5ea29db 100644 --- a/server/src/routes/agentReport.ts +++ b/server/src/routes/agentReport.ts @@ -7,11 +7,28 @@ import { asyncHandler } from "../utils/asyncHandler.js"; export const agentReportRouter = Router(); +const listeningPortSchema = z.object({ + protocol: z.enum(["tcp", "udp"]), + port: z.number().int().min(1).max(65535), + address: z.string().max(100), + process: z.string().max(100).optional(), +}); + const systemSchema = z.object({ ip_addresses: z.array(z.string()).optional(), cpu: z.object({ model: z.string().optional(), cores: z.number().optional(), load_percent: z.number().nullable().optional() }).optional(), memory: z.object({ total_bytes: z.number().optional(), used_bytes: z.number().optional() }).optional(), disks: z.array(z.object({ mount: z.string(), size_bytes: z.number(), used_bytes: z.number() })).optional(), + // Deliberately lenient: one odd line from `ss` must never cost the agent its whole report (tasks included), + // so entries are validated one by one and bad ones dropped rather than failing the request. + listening_ports: z + .array(z.unknown()) + .max(5000) + .optional() + .transform((entries) => entries?.flatMap((e) => { + const parsed = listeningPortSchema.safeParse(e); + return parsed.success ? [parsed.data] : []; + })), }); const reportSchema = z.object({ diff --git a/server/src/routes/serverPorts.ts b/server/src/routes/serverPorts.ts new file mode 100644 index 0000000..4a9ef97 --- /dev/null +++ b/server/src/routes/serverPorts.ts @@ -0,0 +1,338 @@ +import { Router } from "express"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/client.js"; +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 { 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; + from: number; + to: number; + open: number; + refused: number; + filtered: number; + responded: boolean; +} + +export interface PortEntry { + /** Null for a port that's only known from the agent and has no note yet. */ + id: number | null; + port: number; + protocol: "tcp" | "udp"; + label: string | null; + comment: string | null; + /** The last scan from this app connected to it. */ + scanOpen: boolean; + lastSeenOpenAt: string | null; + /** What the agent sees bound on the host, when it reports listening ports. */ + agent: { addresses: string[]; process: string | null; localOnly: boolean } | null; + /** "open" if anything is using it; "reserved" if it only has a note. */ + 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; + const rows = await db.select().from(serverPorts).where(eq(serverPorts.serverId, serverId)); + const agentRaw = parseJson(server.listeningPorts, null); + const agent = groupAgentPorts(agentRaw ?? []); + + const entries = new Map(); + for (const row of rows) { + const key = `${row.protocol}:${row.port}`; + entries.set(key, { + id: row.id, + port: row.port, + protocol: row.protocol, + label: row.label, + comment: row.comment, + scanOpen: row.open, + lastSeenOpenAt: row.lastSeenOpenAt, + agent: agent.get(key) ?? null, + state: "reserved", + }); + } + for (const [key, info] of agent) { + if (entries.has(key)) continue; + const [protocol, port] = key.split(":"); + entries.set(key, { + id: null, + port: Number(port), + protocol: protocol as "tcp" | "udp", + label: null, + comment: null, + scanOpen: false, + lastSeenOpenAt: null, + agent: info, + state: "reserved", + }); + } + for (const entry of entries.values()) { + if (entry.scanOpen || entry.agent) entry.state = "open"; + } + + const ports = [...entries.values()].sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol)); + return { + server, + ports, + agentReporting: agentRaw !== null, + agentReportedAt: agentRaw !== null ? server.lastSeenAt : null, + lastScan: parseJson(server.lastPortScan, null), + }; +} + +function serverIdOf(req: { params: Record }): number | null { + const id = Number(req.params.id); + return Number.isInteger(id) && id > 0 ? id : null; +} + +serverPortsRouter.get("/", asyncHandler(async (req, res) => { + const id = serverIdOf(req); + if (!id) return res.status(400).json({ error: "invalid_id" }); + const list = await buildPortList(id); + if (!list) return res.status(404).json({ error: "not_found" }); + const { server: _server, ...out } = list; + res.json(out); +})); + +const scanSchema = z + .object({ + address: z.string().min(1).max(255), + from: z.number().int().min(1).max(65535), + to: z.number().int().min(1).max(65535), + }) + .refine((d) => d.to >= d.from, { message: "The end of the range is before the start." }) + .refine((d) => d.to - d.from + 1 <= MAX_SCAN_SPAN, { message: `Scan at most ${MAX_SCAN_SPAN} ports at a time.` }); + +serverPortsRouter.post("/scan", requireRole("operator"), asyncHandler(async (req, res) => { + const serverId = serverIdOf(req); + if (!serverId) return res.status(400).json({ error: "invalid_id" }); + const parsed = scanSchema.safeParse(req.body); + if (!parsed.success) { + const message = parsed.error.issues[0]?.message ?? "Invalid scan request."; + return res.status(400).json({ error: "invalid_body", message, details: parsed.error.flatten() }); + } + const { address, from, to } = parsed.data; + + const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1); + if (!server) return res.status(404).json({ error: "not_found" }); + + let target: string; + try { + target = await resolveScanTarget(address); + } catch (err) { + return res.status(400).json({ error: "invalid_address", message: err instanceof Error ? err.message : String(err) }); + } + + if (!beginScan(serverId)) { + return res.status(409).json({ error: "scan_in_progress", message: "A scan of this server is already running." }); + } + + let scan; + try { + scan = await scanPorts(target, from, to); + } finally { + endScan(serverId); + } + + const now = new Date().toISOString(); + // If nothing at all answered, the host is probably down or dropping everything — that says nothing about + // which ports are open, so leave what we knew before rather than marking it all closed. + const responded = scan.open.length + scan.refused.length > 0; + + if (responded) { + const existing = await db + .select() + .from(serverPorts) + .where(and(eq(serverPorts.serverId, serverId), eq(serverPorts.protocol, "tcp"))); + const inRange = existing.filter((r) => r.port >= from && r.port <= to); + const openSet = new Set(scan.open); + const known = new Set(existing.map((r) => r.port)); + + const newlyFound = scan.open.filter((p) => !known.has(p)); + for (let i = 0; i < newlyFound.length; i += 50) { + await db.insert(serverPorts).values( + newlyFound.slice(i, i + 50).map((port) => ({ serverId, port, protocol: "tcp" as const, open: true, lastSeenOpenAt: now })), + ); + } + const stillOpen = inRange.filter((r) => openSet.has(r.port)).map((r) => r.id); + if (stillOpen.length > 0) { + await db.update(serverPorts).set({ open: true, lastSeenOpenAt: now }).where(inArray(serverPorts.id, stillOpen)); + } + // No longer open: keep it if someone wrote a note about it (it's now "reserved"), otherwise it carries no information. + const gone = inRange.filter((r) => r.open && !openSet.has(r.port)); + const keep = gone.filter((r) => r.label || r.comment).map((r) => r.id); + const drop = gone.filter((r) => !(r.label || r.comment)).map((r) => r.id); + if (keep.length > 0) await db.update(serverPorts).set({ open: false }).where(inArray(serverPorts.id, keep)); + if (drop.length > 0) await db.delete(serverPorts).where(inArray(serverPorts.id, drop)); + } + + const summary: StoredScan = { + at: now, + address: target, + from, + to, + open: scan.open.length, + refused: scan.refused.length, + filtered: scan.filtered, + responded, + }; + await db.update(servers).set({ lastPortScan: JSON.stringify(summary) }).where(eq(servers.id, serverId)); + + // "Free" is what the host actively refused AND nobody has claimed — by a note, or by the agent seeing it bound + // (which catches services listening only on localhost, invisible to a scan from elsewhere). + const list = (await buildPortList(serverId))!; + const taken = new Set(list.ports.filter((p) => p.protocol === "tcp").map((p) => p.port)); + const free = scan.refused.filter((p) => !taken.has(p)); + + await recordAudit({ + actor: req.currentUser!, + category: "server", + action: "scan_ports", + targetType: "server", + targetId: serverId, + detail: { name: server.name, address: target, from, to, open: scan.open.length }, + }); + + res.json({ + scan: summary, + freeCount: free.length, + freeRanges: toRanges(free), + ports: list.ports, + agentReporting: list.agentReporting, + agentReportedAt: list.agentReportedAt, + }); +})); + +const noteSchema = z.object({ + port: z.number().int().min(1).max(65535), + protocol: z.enum(["tcp", "udp"]).default("tcp"), + label: z.string().trim().max(100).nullish(), + comment: z.string().trim().max(500).nullish(), +}); + +serverPortsRouter.put("/", requireRole("operator"), asyncHandler(async (req, res) => { + const serverId = serverIdOf(req); + if (!serverId) return res.status(400).json({ error: "invalid_id" }); + const parsed = noteSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", message: "Invalid port note.", details: parsed.error.flatten() }); + } + const { port, protocol } = parsed.data; + const label = parsed.data.label || null; + const comment = parsed.data.comment || null; + + const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1); + if (!server) return res.status(404).json({ error: "not_found" }); + + const [existing] = await db + .select() + .from(serverPorts) + .where(and(eq(serverPorts.serverId, serverId), eq(serverPorts.port, port), eq(serverPorts.protocol, protocol))) + .limit(1); + + if (!existing && !label && !comment) { + return res.status(400).json({ error: "invalid_body", message: "Add a label or a comment to reserve a port." }); + } + + const now = new Date().toISOString(); + if (existing) { + if (!label && !comment && !existing.open) { + await db.delete(serverPorts).where(eq(serverPorts.id, existing.id)); + } else { + await db.update(serverPorts).set({ label, comment, updatedAt: now }).where(eq(serverPorts.id, existing.id)); + } + } else { + await db.insert(serverPorts).values({ serverId, port, protocol, label, comment, updatedAt: now }); + } + + await recordAudit({ + actor: req.currentUser!, + category: "server", + action: "set_port_note", + targetType: "server", + targetId: serverId, + detail: { name: server.name, port, protocol, label }, + }); + + const list = (await buildPortList(serverId))!; + res.json({ ports: list.ports }); +})); + +serverPortsRouter.delete("/:portId", requireRole("operator"), asyncHandler(async (req, res) => { + const serverId = serverIdOf(req); + const portId = Number(req.params.portId); + if (!serverId || !Number.isInteger(portId)) return res.status(400).json({ error: "invalid_id" }); + + const [row] = await db + .select() + .from(serverPorts) + .where(and(eq(serverPorts.id, portId), eq(serverPorts.serverId, serverId))) + .limit(1); + if (!row) return res.status(404).json({ error: "not_found" }); + + // A port that's currently open stays listed — removing its note just blanks it. A reserved-only port disappears. + if (row.open) { + await db.update(serverPorts).set({ label: null, comment: null }).where(eq(serverPorts.id, portId)); + } else { + await db.delete(serverPorts).where(eq(serverPorts.id, portId)); + } + + await recordAudit({ + actor: req.currentUser!, + category: "server", + action: "remove_port_note", + targetType: "server", + targetId: serverId, + detail: { port: row.port, protocol: row.protocol, label: row.label }, + }); + + res.status(204).end(); +})); diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 95be6a9..ff67e77 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -9,9 +9,11 @@ import { recordAudit } from "../services/audit.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js"; +import { serverPortsRouter } from "./serverPorts.js"; export const serversRouter = Router(); serversRouter.use(requireAuth); +serversRouter.use("/:id/ports", serverPortsRouter); const createServerSchema = z.object({ name: z.string().min(1).max(100), @@ -238,6 +240,8 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { cpuLoadPercent: _cpuLoadPercent, memTotalBytes: _memTotalBytes, memUsedBytes: _memUsedBytes, + listeningPorts: _listeningPorts, + lastPortScan: _lastPortScan, ...serverOut } = server; diff --git a/server/src/services/portScan.ts b/server/src/services/portScan.ts new file mode 100644 index 0000000..f0db452 --- /dev/null +++ b/server/src/services/portScan.ts @@ -0,0 +1,136 @@ +import * as net from "node:net"; +import * as dns from "node:dns/promises"; + +export const MAX_SCAN_SPAN = 20_000; + +export interface ScanResult { + /** Ports that accepted a connection. */ + open: number[]; + /** Ports that actively refused — the host answered "nothing here", so they are genuinely free on the scanned address. */ + refused: number[]; + /** Ports that never answered (a firewall dropping packets, or a host that's down) — can't be said to be free or in use. */ + filtered: number; +} + +type Probe = "open" | "refused" | "filtered"; + +function probe(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host, port }); + let done = false; + const finish = (result: Probe) => { + if (done) return; + done = true; + socket.destroy(); + resolve(result); + }; + socket.setTimeout(timeoutMs, () => finish("filtered")); + socket.on("connect", () => finish("open")); + socket.on("error", (err: NodeJS.ErrnoException) => finish(err.code === "ECONNREFUSED" ? "refused" : "filtered")); + }); +} + +/** TCP connect scan of an inclusive port range, with a bounded number of connections in flight. */ +export async function scanPorts( + host: string, + from: number, + to: number, + options: { timeoutMs?: number; concurrency?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 700; + const concurrency = options.concurrency ?? 400; + const open: number[] = []; + const refused: number[] = []; + let filtered = 0; + let next = from; + + async function worker() { + while (next <= to) { + const port = next++; + const result = await probe(host, port, timeoutMs); + if (result === "open") open.push(port); + else if (result === "refused") refused.push(port); + else filtered++; + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, to - from + 1) }, worker)); + open.sort((a, b) => a - b); + refused.sort((a, b) => a - b); + return { open, refused, filtered }; +} + +function isPrivateIPv4(ip: string): boolean { + const [a, b] = ip.split(".").map(Number); + return ( + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || // CGNAT — where Tailscale addresses live + (a === 169 && b === 254) + ); +} + +function isPrivateIPv6(ip: string): boolean { + const first = parseInt(ip.split(":")[0] || "0", 16); + return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80; // unique-local fc00::/7, link-local fe80::/10 +} + +function isPrivateAddress(ip: string): boolean { + const family = net.isIP(ip); + if (family === 4) return isPrivateIPv4(ip); + if (family === 6) return isPrivateIPv6(ip); + return false; +} + +/** + * Resolves an address (IP literal or hostname) to the IP to scan, refusing anything that isn't on a private + * network. A port scanner that will probe any address the caller types is a tool for scanning other people's + * machines, and this one exists to look at the homelab — loopback is refused too, since it would only ever + * describe the machine the app itself runs on. + */ +export async function resolveScanTarget(address: string): Promise { + const trimmed = address.trim(); + if (!trimmed) throw new Error("No address to scan."); + let ips: string[]; + if (net.isIP(trimmed)) { + ips = [trimmed]; + } else { + try { + ips = (await dns.lookup(trimmed, { all: true })).map((r) => r.address); + } catch { + throw new Error(`Couldn't resolve "${trimmed}".`); + } + } + const ip = ips.find(isPrivateAddress); + if (!ip) { + throw new Error( + `"${trimmed}" isn't on a private network. Scanning is limited to homelab addresses (10.x, 172.16–31.x, 192.168.x, Tailscale 100.64–127.x, and IPv6 unique-local/link-local).`, + ); + } + return ip; +} + +/** Collapses a sorted list of ports into inclusive [start, end] ranges. */ +export function toRanges(ports: number[]): [number, number][] { + const ranges: [number, number][] = []; + for (const port of ports) { + const last = ranges[ranges.length - 1]; + if (last && port === last[1] + 1) last[1] = port; + else ranges.push([port, port]); + } + return ranges; +} + +/** One scan at a time per server — a full range holds hundreds of sockets open, and two overlapping scans would just fight over the results. */ +const scansInProgress = new Set(); + +export function beginScan(serverId: number): boolean { + if (scansInProgress.has(serverId)) return false; + scansInProgress.add(serverId); + return true; +} + +export function endScan(serverId: number): void { + scansInProgress.delete(serverId); +} diff --git a/server/src/services/taskSync.ts b/server/src/services/taskSync.ts index c2520d9..2401861 100644 --- a/server/src/services/taskSync.ts +++ b/server/src/services/taskSync.ts @@ -18,6 +18,7 @@ export interface IncomingSystemInfo { cpu?: { model?: string; cores?: number; load_percent?: number | null }; memory?: { total_bytes?: number; used_bytes?: number }; disks?: { mount: string; size_bytes: number; used_bytes: number }[]; + listening_ports?: { protocol: "tcp" | "udp"; port: number; address: string; process?: string }[]; } export interface AgentReport { @@ -112,6 +113,8 @@ export async function syncServerTasks(serverId: number, report: AgentReport) { ), } : {}), + // Only stored when the agent reported it, so an agent that predates this doesn't wipe the field. + ...(system?.listening_ports ? { listeningPorts: JSON.stringify(system.listening_ports) } : {}), }) .where(eq(servers.id, serverId)); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 75d238a..b4d4acd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -346,6 +346,44 @@ export interface ServerDetail { links: ServerLink[]; } +export interface PortEntry { + /** Null for a port only known from the agent that has no note yet. */ + id: number | null; + port: number; + protocol: "tcp" | "udp"; + label: string | null; + comment: string | null; + /** The last scan from this app connected to it. */ + scanOpen: boolean; + lastSeenOpenAt: string | null; + agent: { addresses: string[]; process: string | null; localOnly: boolean } | null; + state: "open" | "reserved"; +} + +export interface PortScanSummary { + at: string; + address: string; + from: number; + to: number; + open: number; + refused: number; + filtered: number; + responded: boolean; +} + +export interface PortList { + ports: PortEntry[]; + agentReporting: boolean; + agentReportedAt: string | null; + lastScan: PortScanSummary | null; +} + +export interface PortScanResult extends Omit { + scan: PortScanSummary; + freeCount: number; + freeRanges: [number, number][]; +} + export interface ServerUpdateInput { name?: string; hostname?: string; @@ -803,6 +841,14 @@ export const api = { request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }), removeLink: (id: number, linkId: number) => request(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }), + ports: { + list: (id: number) => request(`/api/servers/${id}/ports`), + scan: (id: number, data: { address: string; from: number; to: number }) => + request(`/api/servers/${id}/ports/scan`, { method: "POST", body: JSON.stringify(data) }), + save: (id: number, data: { port: number; protocol: "tcp" | "udp"; label?: string; comment?: string }) => + request<{ ports: PortEntry[] }>(`/api/servers/${id}/ports`, { method: "PUT", body: JSON.stringify(data) }), + remove: (id: number, portId: number) => request(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }), + }, }, tasks: { list: ( diff --git a/web/src/components/ServerPorts.tsx b/web/src/components/ServerPorts.tsx new file mode 100644 index 0000000..ca6f964 --- /dev/null +++ b/web/src/components/ServerPorts.tsx @@ -0,0 +1,505 @@ +import { useEffect, useMemo, useState } from "react"; +import { api, type PortEntry, type PortList, type PortScanResult } from "../api/client"; +import { sortItems, type SortDirection } from "../hooks/useSortable"; +import { usePagination } from "../hooks/usePagination"; +import { formatDateTime } from "../utils/date"; +import { downloadCsv } from "../utils/csv"; +import { readableError } from "../utils/errors"; +import Pagination from "./Pagination"; +import SortableTh from "./SortableTh"; + +const PRESETS: { key: string; label: string; from: number; to: number }[] = [ + { key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 }, + { key: "common", label: "1–10000 (most services)", from: 1, to: 10000 }, + { key: "high1", label: "10001–30000", from: 10001, to: 30000 }, + { key: "high2", label: "30001–50000", from: 30001, to: 50000 }, + { key: "high3", label: "50001–65535", from: 50001, to: 65535 }, + { key: "custom", label: "Custom range…", from: 8000, to: 8999 }, +]; + +const MAX_SHOWN_RANGES = 24; + +interface NoteForm { + editingId: number | null; + port: string; + protocol: "tcp" | "udp"; + label: string; + comment: string; +} + +function rangeText([from, to]: [number, number]) { + return from === to ? String(from) : `${from}–${to}`; +} + +function StatusBadges({ entry }: { entry: PortEntry }) { + if (entry.state === "reserved") return Reserved; + return ( +
+ {entry.scanOpen && ( + + Reachable + + )} + {entry.agent && ( + + Listening + + )} + {entry.agent?.localOnly && ( + + local only + + )} +
+ ); +} + +export default function ServerPorts({ + serverId, + addresses, + canEdit, +}: { + serverId: number; + /** Addresses this server is known by, offered as scan targets. */ + addresses: string[]; + canEdit: boolean; +}) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + const [sortKey, setSortKey] = useState("port"); + const [sortDir, setSortDir] = useState("asc"); + + const [showScan, setShowScan] = useState(false); + const [address, setAddress] = useState(""); + const [presetKey, setPresetKey] = useState("common"); + const [customFrom, setCustomFrom] = useState("8000"); + const [customTo, setCustomTo] = useState("8999"); + const [scanning, setScanning] = useState(false); + const [result, setResult] = useState(null); + const [showAllRanges, setShowAllRanges] = useState(false); + + const [note, setNote] = useState(null); + const [savingNote, setSavingNote] = useState(false); + + useEffect(() => { + api.servers.ports + .list(serverId) + .then(setData) + .catch((err) => setError(readableError(err))); + }, [serverId]); + + // Default the scan target to the server's first known address once we have one. + useEffect(() => { + if (!address && addresses.length > 0) setAddress(addresses[0]); + }, [addresses, address]); + + const visible = useMemo(() => { + if (!data) return null; + const q = filter.trim().toLowerCase(); + const rows = q + ? data.ports.filter((p) => + [String(p.port), p.protocol, p.label, p.comment, p.agent?.process].some((v) => v?.toLowerCase().includes(q)), + ) + : data.ports; + return sortItems(rows, sortKey, sortDir); + }, [data, filter, sortKey, sortDir]); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(visible); + + function requestSort(key: keyof PortEntry) { + if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { + setSortKey(key); + setSortDir("asc"); + } + } + + const preset = PRESETS.find((p) => p.key === presetKey)!; + const from = presetKey === "custom" ? Number(customFrom) : preset.from; + const to = presetKey === "custom" ? Number(customTo) : preset.to; + + async function runScan(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setScanning(true); + setResult(null); + setShowAllRanges(false); + try { + const res = await api.servers.ports.scan(serverId, { address: address.trim(), from, to }); + setResult(res); + setData({ ports: res.ports, agentReporting: res.agentReporting, agentReportedAt: res.agentReportedAt, lastScan: res.scan }); + } catch (err) { + setError(readableError(err)); + } finally { + setScanning(false); + } + } + + function openNote(initial: Partial = {}) { + setError(null); + setNote({ editingId: null, port: "", protocol: "tcp", label: "", comment: "", ...initial }); + } + + function editEntry(entry: PortEntry) { + openNote({ + editingId: entry.id ?? -1, // -1: a port with no row yet — a note creates one + port: String(entry.port), + protocol: entry.protocol, + label: entry.label ?? "", + comment: entry.comment ?? "", + }); + } + + async function saveNote(e: React.FormEvent) { + e.preventDefault(); + if (!note) return; + setSavingNote(true); + setError(null); + try { + const res = await api.servers.ports.save(serverId, { + port: Number(note.port), + protocol: note.protocol, + label: note.label.trim(), + comment: note.comment.trim(), + }); + setData((prev) => (prev ? { ...prev, ports: res.ports } : prev)); + // A port that was just reserved is no longer "available" in the last scan's result. + setResult((prev) => { + if (!prev) return prev; + const port = Number(note.port); + const ranges = prev.freeRanges.flatMap(([a, b]): [number, number][] => { + if (port < a || port > b) return [[a, b]]; + const out: [number, number][] = []; + if (port > a) out.push([a, port - 1]); + if (port < b) out.push([port + 1, b]); + return out; + }); + const removed = prev.freeRanges.some(([a, b]) => port >= a && port <= b); + return { ...prev, freeRanges: ranges, freeCount: prev.freeCount - (removed && note.protocol === "tcp" ? 1 : 0) }; + }); + setNote(null); + } catch (err) { + setError(readableError(err)); + } finally { + setSavingNote(false); + } + } + + async function removeNote(entry: PortEntry) { + if (entry.id === null) return; + const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`; + if (!confirm(what)) return; + try { + await api.servers.ports.remove(serverId, entry.id); + setData(await api.servers.ports.list(serverId)); + } catch (err) { + setError(readableError(err)); + } + } + + function exportCsv() { + if (!visible) return; + downloadCsv( + "ports.csv", + ["Port", "Protocol", "Status", "Service", "Process", "Comment"], + visible.map((p) => [p.port, p.protocol, p.state, p.label ?? "", p.agent?.process ?? "", p.comment ?? ""]), + ); + } + + const shownRanges = result ? (showAllRanges ? result.freeRanges : result.freeRanges.slice(0, MAX_SHOWN_RANGES)) : []; + const editing = note !== null && note.editingId !== null; + + return ( +
+
+

Ports

+
+ {data && data.ports.length > 0 && ( + + )} + {canEdit && ( + <> + + + + )} +
+
+ +
+ {error &&
{error}
} + + {data && ( +
+ {data.agentReporting ? ( + <>Listening ports reported by the agent{data.agentReportedAt ? ` as of ${formatDateTime(new Date(data.agentReportedAt))}` : ""}. + ) : ( + <> + The agent hasn't reported listening ports (update it to see services bound to localhost only, which a scan can't + detect).{" "} + + )} + {data.lastScan ? ( + <> + Last scan {formatDateTime(new Date(data.lastScan.at))}: {data.lastScan.address}, ports {data.lastScan.from}– + {data.lastScan.to}. + + ) : ( + <>Not scanned yet. + )} +
+ )} + + {canEdit && showScan && ( +
+
+ + setAddress(e.target.value)} + /> + + {addresses.map((a) => ( + +
+
+ + +
+ {presetKey === "custom" && ( +
+ setCustomFrom(e.target.value)} + /> + – + setCustomTo(e.target.value)} + /> +
+ )} +
+ +
+
+ A TCP scan from this app: it can see what's reachable from here, not services bound to localhost or blocked by a + firewall. Only private addresses can be scanned, up to 20,000 ports at a time. +
+
+ )} + + {result && ( +
+ {!result.scan.responded ? ( +
+ Nothing answered on {result.scan.address} — the host may be down, or a firewall is dropping everything. Existing + results were left as they were. +
+ ) : ( + <> +
+ {result.scan.open} open in {result.scan.from}–{result.scan.to} on {result.scan.address}.{" "} + {result.freeCount} available + {result.scan.filtered > 0 && ( + + {" "} + ({result.scan.filtered} didn't answer at all, so can't be called available) + + )} + . +
+ {result.freeRanges.length > 0 ? ( +
+ {shownRanges.map((r) => ( + + ))} + {result.freeRanges.length > MAX_SHOWN_RANGES && ( + + )} +
+ ) : ( +
No port in this range was confirmed free.
+ )} + {result.freeRanges.length > 0 && canEdit && ( +
Click a range to reserve a port from it with a note.
+ )} + + )} +
+ )} + + {canEdit && note && ( +
+
+ + setNote({ ...note, port: e.target.value })} + /> +
+
+ + +
+
+ + setNote({ ...note, label: e.target.value })} + /> +
+
+ + setNote({ ...note, comment: e.target.value })} + /> +
+
+ + + {!editing && A port with a note counts as taken, even when nothing is listening.} +
+
+ )} + + {data && data.ports.length > 0 && ( + setFilter(e.target.value)} + /> + )} +
+ + {data && data.ports.length === 0 && ( +
+ No ports recorded yet. {canEdit ? "Run a scan to find what's open, or add a note to reserve a port." : ""} +
+ )} + + {pageItems && pageItems.length > 0 && ( +
+ + + + label="Port" sortKeyName="port" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Proto" sortKeyName="protocol" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Service" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Comment" sortKeyName="comment" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + {canEdit && } + + + + {pageItems.map((p) => ( + + + + + + + {canEdit && ( + + )} + + ))} + +
Actions
+ {p.port} + {p.protocol} + + + {p.label ?? —} + {p.agent?.process &&
{p.agent.process}
} +
{p.comment ?? "—"} +
+ + {(p.label || p.comment) && ( + + )} +
+
+
+ )} + {visible && visible.length === 0 && data && data.ports.length > 0 && ( +
Nothing matches that filter.
+ )} + +
+ ); +} diff --git a/web/src/pages/ServerDetail.tsx b/web/src/pages/ServerDetail.tsx index e217dc7..1ea2fee 100644 --- a/web/src/pages/ServerDetail.tsx +++ b/web/src/pages/ServerDetail.tsx @@ -11,6 +11,7 @@ import { type ServerLink, type TaskRecord, } from "../api/client"; +import ServerPorts from "../components/ServerPorts"; import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable"; import { formatDateTime } from "../utils/date"; @@ -685,6 +686,12 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { + +

Tasks

{canEditTasks && (