Add a Ports card to server pages: scan for open ports, find free ones, and keep notes

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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-26 02:39:43 +02:00
1 parent aae4f0d74f
commit 4c11158e98
14 files changed
+2521 -1

No files matched your search

+20 -1
View File
@@ -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
}')
}