From 81f65b5173b97ba1f6794ebe0864aeb3cfc0fee1 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 16:41:25 -0500 Subject: [PATCH] update security --- README.md | 1 + compose.yaml | 19 ++ deploy/nginx/templates/tls.conf.template | 49 ++- docs/client-api.md | 56 ++++ docs/deployment.md | 19 +- internal/app/download.go | 3 + internal/app/fleet.go | 406 +++++++++++++++++++++++ internal/app/fleet_test.go | 167 ++++++++++ internal/app/server.go | 9 + internal/app/server_test.go | 27 ++ internal/app/static/app.css | 16 + internal/app/templates/components.html | 1 + internal/app/templates/hosts.html | 61 ++++ migrations/002_fleet_registry.sql | 40 +++ 14 files changed, 869 insertions(+), 5 deletions(-) create mode 100644 internal/app/fleet.go create mode 100644 internal/app/fleet_test.go create mode 100644 internal/app/templates/hosts.html create mode 100644 migrations/002_fleet_registry.sql diff --git a/README.md b/README.md index 652015d..a6ec52c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The production layout is a self-contained, single-VM Docker deployment: - Nginx reverse proxy for both public domains - Certbot and Let's Encrypt HTTP-01 certificate management - explicit runtime storage under `config/` beside the Compose files +- privacy-minimized ProxMenu installation and run-status registry No external database or load balancer is required. The datacenter edge only needs to forward public TCP 80 and 443 to the configured Docker VM ports. diff --git a/compose.yaml b/compose.yaml index 56475a5..0d28352 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,11 @@ name: tapm +x-default-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "5" + services: broker: container_name: tapm-broker @@ -10,6 +16,8 @@ services: required: false restart: unless-stopped init: true + pids_limit: 128 + logging: *default-logging read_only: true networks: - edge @@ -32,6 +40,8 @@ services: container_name: tapm-gitea image: docker.gitea.com/gitea:1.26.4-rootless restart: unless-stopped + pids_limit: 512 + logging: *default-logging networks: - edge volumes: @@ -61,6 +71,9 @@ services: container_name: tapm-nginx image: nginx:1.28-alpine restart: unless-stopped + read_only: true + pids_limit: 128 + logging: *default-logging networks: - edge ports: @@ -76,10 +89,16 @@ services: SSL_CERTIFICATE_KEY_FILE: ${SSL_CERTIFICATE_KEY_FILE:-privkey.pem} security_opt: - no-new-privileges:true + tmpfs: + - /etc/nginx/conf.d:size=1m,mode=0755 + - /var/cache/nginx:size=32m,mode=0755 + - /var/run:size=1m,mode=0755 certbot: image: certbot/certbot:v5.7.0 profiles: ["tools"] + pids_limit: 128 + logging: *default-logging volumes: - ./config/letsencrypt:/etc/letsencrypt - ./config/certbot-webroot:/var/www/certbot diff --git a/deploy/nginx/templates/tls.conf.template b/deploy/nginx/templates/tls.conf.template index bdd45f4..162b966 100644 --- a/deploy/nginx/templates/tls.conf.template +++ b/deploy/nginx/templates/tls.conf.template @@ -1,5 +1,20 @@ resolver 127.0.0.11 valid=30s; +server_tokens off; +reset_timedout_connection on; +client_header_timeout 15s; +client_body_timeout 60s; +keepalive_timeout 30s; +send_timeout 60s; + +limit_conn_zone $binary_remote_addr zone=per_address_connections:10m; +limit_req_zone $binary_remote_addr zone=broker_requests:10m rate=20r/s; +map $uri $gitea_login_client { + default ""; + "/user/login" $binary_remote_addr; +} +limit_req_zone $gitea_login_client zone=gitea_login_requests:10m rate=10r/m; + upstream broker_backend { zone broker_backend 64k; server broker:8080 resolve; @@ -10,6 +25,12 @@ upstream gitea_backend { server gitea:3000 resolve; } +server { + listen 80 default_server; + server_name _; + return 444; +} + server { listen 80; server_name ${BROKER_DOMAIN} ${GITEA_DOMAIN}; @@ -23,6 +44,12 @@ server { } } +server { + listen 443 ssl default_server; + server_name _; + ssl_reject_handshake on; +} + server { listen 443 ssl; http2 on; @@ -32,16 +59,25 @@ server { ssl_certificate_key /etc/nginx/ssl/${SSL_CERTIFICATE_KEY_FILE}; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; + ssl_session_tickets off; ssl_protocols TLSv1.2 TLSv1.3; client_max_body_size 1100m; + limit_conn per_address_connections 30; + limit_req zone=broker_requests burst=40 nodelay; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; location / { proxy_pass http://broker_backend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto https; proxy_connect_timeout 30s; proxy_send_timeout 30m; @@ -60,16 +96,25 @@ server { ssl_certificate_key /etc/nginx/ssl/${SSL_CERTIFICATE_KEY_FILE}; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; + ssl_session_tickets off; ssl_protocols TLSv1.2 TLSv1.3; client_max_body_size 1100m; + limit_conn per_address_connections 50; + limit_req zone=gitea_login_requests burst=10 nodelay; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; location / { proxy_pass http://gitea_backend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto https; proxy_read_timeout 300s; } diff --git a/docs/client-api.md b/docs/client-api.md index 9f079ff..f34b1ef 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -12,6 +12,7 @@ credentials. "code": "TAPM-ABCDE-FGHIJ", "host_fingerprint": "sha256-of-the-host-machine-id", "hostname": "pve01", + "installation_id": "123e4567-e89b-42d3-a456-426614174000", "requested_action": "install-rmm", "requested_package": "" } @@ -50,6 +51,9 @@ authorization code. When `requested_action` or `requested_package` is present, the broker validates that entitlement before enrolling the host. Older clients may omit both fields, but current clients should always identify what they intend to use. +Current V2 clients also send their random `installation_id`. A successful +code exchange marks the matching fleet record as verified; it does not expose +or replace the host's fleet credential. An installer must proceed only when its required package slug or action slug is present in this response. The current action slugs are: @@ -83,3 +87,55 @@ The server also returns the expected value in `X-TAPM-SHA256`. Codes and sessions expire at the same authorization deadline. Revoking an authorization immediately invalidates all of its sessions. + +## Fleet registration + +V2 clients register a locally generated installation identity: + +`POST https://tapm.example.com/api/v1/hosts/register` + +```json +{ + "schema_version": 1, + "installation_id": "123e4567-e89b-42d3-a456-426614174000", + "credential": "64-lowercase-hexadecimal-characters", + "proxmenu_version": "2026.7.26-7", + "git_commit": "40-lowercase-hexadecimal-characters", + "pve_version": "pve-manager/9.0.3/abc~1", + "os_version": "Debian GNU/Linux 13 (trixie)", + "kernel_version": "6.14.11-2-pve", + "architecture": "amd64", + "clustered": false +} +``` + +The first request returns `201`; subsequent authenticated registrations return +`200`. The broker stores a SHA-256 digest of the 256-bit random credential, not +the credential itself. New registrations are limited per privacy-preserving +HMAC of the source address to reduce anonymous registry abuse. + +## Fleet lifecycle events + +`POST https://tapm.example.com/api/v1/hosts/events` + +The request uses `Authorization: Bearer HOST_CREDENTIAL`. Its body has the same +schema and platform metadata as registration, omits `credential`, and adds: + +```json +{ + "event": "run_completed", + "result": "success", + "error_code": "", + "duration_seconds": 19 +} +``` + +Accepted events are `run_started`, `run_completed`, `run_failed`, and +`upgraded`. Events are best-effort and are sent only while TA-ProxMenu is +running; there is no resident monitoring service. + +The fleet API deliberately does not accept or store hostnames, machine IDs, +MAC addresses, usernames, customer names, VM/container inventory, deployment +tokens, or command output. The portal shows random installation IDs, first and +last activity, verification status, software/platform versions, cluster mode, +and the latest structured result. diff --git a/docs/deployment.md b/docs/deployment.md index 67dc819..c043014 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,14 +20,27 @@ into the Gitea container, preventing them from overriding its internal listener. ## 1. VM and network Install Docker Engine with Compose v2. Permit the selected HTTP and HTTPS ports -and forward public TCP 80 and 443 from the datacenter edge to them. The -Create public DNS A/AAAA records for both application names before requesting -the certificate. +and forward public TCP 80 and 443 from the datacenter edge to them. Create +public DNS A/AAAA records for both application names before requesting the +certificate. Public port 80 must reach the container's port 80 for HTTP-01 certificate renewal. For example, if `HTTP_PORT=8080`, the edge must forward public port 80 to VM port 8080. If IPv6 is published, it must reach this same VM. +The company firewall should provide volumetric DDoS protection and permit only +the required forwarded ports. Nginx supplies the application-edge controls: +unknown HTTP hostnames are dropped, unknown TLS SNI names are rejected, +TLS is restricted to 1.2/1.3, session tickets and version disclosure are +disabled, broker requests and per-address connections are limited, slow-client +timeouts are bounded, Gitea login bursts are throttled, and HSTS plus browser +security headers are returned. Container PID ceilings and Docker log rotation +reduce the impact of local resource exhaustion. +Nginx also runs with a read-only container filesystem and narrowly scoped +temporary filesystems. The broker trusts proxy headers because Nginx is its +only production ingress; Nginx replaces rather than appends client-supplied +forwarding headers. + ## 2. Install the repository ```sh diff --git a/internal/app/download.go b/internal/app/download.go index 2714990..222a1d4 100644 --- a/internal/app/download.go +++ b/internal/app/download.go @@ -18,6 +18,7 @@ type exchangeRequest struct { Hostname string `json:"hostname"` RequestedAction string `json:"requested_action,omitempty"` RequestedPackage string `json:"requested_package,omitempty"` + InstallationID string `json:"installation_id,omitempty"` } type exchangePackage struct { @@ -59,6 +60,7 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) { request.Hostname = strings.TrimSpace(request.Hostname) request.RequestedAction = strings.TrimSpace(request.RequestedAction) request.RequestedPackage = strings.TrimSpace(request.RequestedPackage) + request.InstallationID = strings.TrimSpace(request.InstallationID) if request.Code == "" || len(request.HostFingerprint) < 16 || request.Hostname == "" || len(request.Hostname) > 255 || (request.RequestedAction != "" && !validSlug(request.RequestedAction)) || @@ -90,6 +92,7 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) { sourceIP, "requested_action="+request.RequestedAction, ) + s.verifyFleetInstallation(r.Context(), request.InstallationID) writeJSON(w, http.StatusOK, response) } diff --git a/internal/app/fleet.go b/internal/app/fleet.go new file mode 100644 index 0000000..7e7fc2a --- /dev/null +++ b/internal/app/fleet.go @@ -0,0 +1,406 @@ +package app + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +const fleetCredentialPrefix = "Bearer " + +var ( + installationIDPattern = regexp.MustCompile( + `^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, + ) + hexCredentialPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + fleetValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+~,:/() -]*$`) + errorCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) +) + +type fleetRequest struct { + SchemaVersion int `json:"schema_version"` + InstallationID string `json:"installation_id"` + Credential string `json:"credential,omitempty"` + Event string `json:"event,omitempty"` + Result string `json:"result,omitempty"` + ProxMenuVersion string `json:"proxmenu_version,omitempty"` + GitCommit string `json:"git_commit,omitempty"` + PVEVersion string `json:"pve_version,omitempty"` + OSVersion string `json:"os_version,omitempty"` + KernelVersion string `json:"kernel_version,omitempty"` + Architecture string `json:"architecture,omitempty"` + Clustered bool `json:"clustered"` + ErrorCode string `json:"error_code,omitempty"` + DurationSeconds int `json:"duration_seconds,omitempty"` +} + +type fleetHostRecord struct { + InstallationID string + VerifiedAt sql.NullTime + FirstSeenAt time.Time + LastSeenAt time.Time + LastSuccessAt sql.NullTime + LastEvent string + LastResult string + LastErrorCode string + ProxMenuVersion string + GitCommit string + PVEVersion string + OSVersion string + KernelVersion string + Architecture string + Clustered bool +} + +func (s *Server) handleFleetRegister(w http.ResponseWriter, r *http.Request) { + request, ok := decodeFleetRequest(w, r, true) + if !ok { + return + } + sourceHash := s.fleetSourceHash(s.clientIP(r)) + var recent int + if err := s.db.QueryRowContext( + r.Context(), + `SELECT COUNT(*) FROM fleet_hosts + WHERE registration_source_hash = ? + AND first_seen_at > datetime('now', '-1 day')`, + sourceHash, + ).Scan(&recent); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to register host"}) + return + } + + credentialHash := hashValue(request.Credential) + var existingHash []byte + err := s.db.QueryRowContext( + r.Context(), + `SELECT credential_hash FROM fleet_hosts WHERE installation_id = ?`, + request.InstallationID, + ).Scan(&existingHash) + switch { + case errors.Is(err, sql.ErrNoRows): + if recent >= 25 { + w.Header().Set("Retry-After", "86400") + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "registration limit reached"}) + return + } + if err := s.insertFleetHost(r.Context(), request, credentialHash[:], sourceHash); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to register host"}) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"}) + case err != nil: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to register host"}) + case !hmac.Equal(existingHash, credentialHash[:]): + writeJSON(w, http.StatusForbidden, map[string]string{"error": "host credential is invalid"}) + default: + if err := s.updateFleetHost(r.Context(), request, false); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to update host"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "registered"}) + } +} + +func (s *Server) handleFleetEvent(w http.ResponseWriter, r *http.Request) { + request, ok := decodeFleetRequest(w, r, false) + if !ok { + return + } + authorization := strings.TrimSpace(r.Header.Get("Authorization")) + if !strings.HasPrefix(authorization, fleetCredentialPrefix) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "host credential required"}) + return + } + credential := strings.TrimSpace(strings.TrimPrefix(authorization, fleetCredentialPrefix)) + if !hexCredentialPattern.MatchString(credential) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "host credential required"}) + return + } + credentialHash := hashValue(credential) + var authorized int + if err := s.db.QueryRowContext( + r.Context(), + `SELECT COUNT(*) FROM fleet_hosts + WHERE installation_id = ? AND credential_hash = ?`, + request.InstallationID, + credentialHash[:], + ).Scan(&authorized); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to authenticate host"}) + return + } + if authorized != 1 { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "host credential is invalid"}) + return + } + if err := s.updateFleetHost(r.Context(), request, true); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to record event"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func decodeFleetRequest(w http.ResponseWriter, r *http.Request, registration bool) (fleetRequest, bool) { + var request fleetRequest + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return request, false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return request, false + } + request.InstallationID = strings.ToLower(strings.TrimSpace(request.InstallationID)) + request.Credential = strings.ToLower(strings.TrimSpace(request.Credential)) + request.Event = strings.TrimSpace(request.Event) + request.Result = strings.TrimSpace(request.Result) + request.ErrorCode = strings.TrimSpace(request.ErrorCode) + if request.SchemaVersion != 1 || + !installationIDPattern.MatchString(request.InstallationID) || + (registration && !hexCredentialPattern.MatchString(request.Credential)) || + !validFleetMetadata(request) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid host data"}) + return request, false + } + if registration { + request.Event = "installed" + request.Result = "success" + } else if !validFleetEvent(request.Event, request.Result, request.ErrorCode) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid event data"}) + return request, false + } + return request, true +} + +func validFleetMetadata(request fleetRequest) bool { + values := []struct { + value string + limit int + }{ + {request.ProxMenuVersion, 64}, + {request.GitCommit, 64}, + {request.PVEVersion, 128}, + {request.OSVersion, 128}, + {request.KernelVersion, 128}, + {request.Architecture, 32}, + } + for _, candidate := range values { + if len(candidate.value) > candidate.limit || + (candidate.value != "" && !fleetValuePattern.MatchString(candidate.value)) { + return false + } + } + return request.DurationSeconds >= 0 && request.DurationSeconds <= 31*24*60*60 +} + +func validFleetEvent(event, result, errorCode string) bool { + switch event { + case "run_started", "run_completed", "run_failed", "upgraded": + default: + return false + } + switch result { + case "started", "success", "warning", "failure": + default: + return false + } + return errorCode == "" || (len(errorCode) <= 64 && errorCodePattern.MatchString(errorCode)) +} + +func (s *Server) insertFleetHost( + ctx context.Context, + request fleetRequest, + credentialHash []byte, + sourceHash []byte, +) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext( + ctx, + `INSERT INTO fleet_hosts + (installation_id, credential_hash, registration_source_hash, + proxmenu_version, git_commit, pve_version, os_version, + kernel_version, architecture, clustered) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + request.InstallationID, credentialHash, sourceHash, + request.ProxMenuVersion, request.GitCommit, request.PVEVersion, + request.OSVersion, request.KernelVersion, request.Architecture, + request.Clustered, + ); err != nil { + return err + } + if _, err := tx.ExecContext( + ctx, + `INSERT INTO fleet_events + (installation_id, event_type, result, proxmenu_version) + VALUES (?, 'installed', 'success', ?)`, + request.InstallationID, request.ProxMenuVersion, + ); err != nil { + return err + } + return tx.Commit() +} + +func (s *Server) updateFleetHost(ctx context.Context, request fleetRequest, recordEvent bool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext( + ctx, + `UPDATE fleet_hosts SET + last_seen_at = CURRENT_TIMESTAMP, + last_success_at = CASE + WHEN ? = 'run_completed' AND ? = 'success' THEN CURRENT_TIMESTAMP + ELSE last_success_at END, + last_event = CASE WHEN ? THEN ? ELSE last_event END, + last_result = CASE WHEN ? THEN ? ELSE last_result END, + last_error_code = CASE WHEN ? THEN ? ELSE last_error_code END, + proxmenu_version = ?, + git_commit = ?, + pve_version = ?, + os_version = ?, + kernel_version = ?, + architecture = ?, + clustered = ?, + updated_at = CURRENT_TIMESTAMP + WHERE installation_id = ?`, + request.Event, request.Result, + recordEvent, request.Event, + recordEvent, request.Result, + recordEvent, request.ErrorCode, + request.ProxMenuVersion, request.GitCommit, request.PVEVersion, + request.OSVersion, request.KernelVersion, request.Architecture, + request.Clustered, request.InstallationID, + ) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected != 1 { + return sql.ErrNoRows + } + if recordEvent { + if _, err := tx.ExecContext( + ctx, + `INSERT INTO fleet_events + (installation_id, event_type, result, proxmenu_version, + error_code, duration_seconds) + VALUES (?, ?, ?, ?, ?, ?)`, + request.InstallationID, request.Event, request.Result, + request.ProxMenuVersion, request.ErrorCode, request.DurationSeconds, + ); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Server) fleetSourceHash(sourceIP string) []byte { + mac := hmac.New(sha256.New, s.cfg.CookieSecret) + _, _ = mac.Write([]byte("fleet-registration:")) + _, _ = mac.Write([]byte(sourceIP)) + return mac.Sum(nil) +} + +func (s *Server) verifyFleetInstallation(ctx context.Context, installationID string) { + installationID = strings.ToLower(strings.TrimSpace(installationID)) + if !installationIDPattern.MatchString(installationID) { + return + } + _, _ = s.db.ExecContext( + ctx, + `UPDATE fleet_hosts + SET verified_at = COALESCE(verified_at, CURRENT_TIMESTAMP), + last_seen_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE installation_id = ?`, + installationID, + ) +} + +func (s *Server) listFleetHosts(r *http.Request) ([]fleetHostRecord, error) { + rows, err := s.db.QueryContext( + r.Context(), + `SELECT installation_id, verified_at, first_seen_at, last_seen_at, + last_success_at, last_event, last_result, last_error_code, + proxmenu_version, git_commit, pve_version, os_version, + kernel_version, architecture, clustered + FROM fleet_hosts + ORDER BY last_seen_at DESC + LIMIT 500`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var records []fleetHostRecord + for rows.Next() { + var record fleetHostRecord + if err := rows.Scan( + &record.InstallationID, &record.VerifiedAt, &record.FirstSeenAt, + &record.LastSeenAt, &record.LastSuccessAt, &record.LastEvent, + &record.LastResult, &record.LastErrorCode, &record.ProxMenuVersion, + &record.GitCommit, &record.PVEVersion, &record.OSVersion, + &record.KernelVersion, &record.Architecture, &record.Clustered, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func (s *Server) handleFleetPortal(w http.ResponseWriter, r *http.Request) { + tech, err := s.currentTechnician(r) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + hosts, err := s.listFleetHosts(r) + if err != nil { + http.Error(w, "unable to list hosts", http.StatusInternalServerError) + return + } + s.render(w, "hosts.html", pageData{ + Title: "ProxMenu Hosts", + Technician: tech, + CSRFToken: tech.CSRFToken, + FleetHosts: hosts, + CurrentView: "hosts", + }) +} + +func shortInstallationID(value string) string { + if len(value) <= 8 { + return value + } + return value[:8] +} + +func shortCommit(value string) string { + value = strings.TrimSpace(value) + if len(value) <= 12 { + return value + } + if _, err := hex.DecodeString(value[:12]); err != nil { + return value + } + return value[:12] +} diff --git a/internal/app/fleet_test.go b/internal/app/fleet_test.go new file mode 100644 index 0000000..4ecd6e4 --- /dev/null +++ b/internal/app/fleet_test.go @@ -0,0 +1,167 @@ +package app + +import ( + "bytes" + "database/sql" + "net/http" + "net/http/httptest" + "os" + "testing" + + _ "modernc.org/sqlite" +) + +const ( + testInstallationID = "123e4567-e89b-42d3-a456-426614174000" + testFleetCredential = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) + +func newFleetTestServer(t *testing.T) *Server { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + migration, err := os.ReadFile("../../migrations/002_fleet_registry.sql") + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(string(migration)); err != nil { + t.Fatal(err) + } + return &Server{ + db: db, + cfg: Config{ + CookieSecret: []byte("0123456789abcdef0123456789abcdef"), + }, + } +} + +func fleetRequestRecorder( + t *testing.T, + handler http.HandlerFunc, + body string, + credential string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body)) + request.RemoteAddr = "192.0.2.10:54321" + request.Header.Set("Content-Type", "application/json") + if credential != "" { + request.Header.Set("Authorization", "Bearer "+credential) + } + response := httptest.NewRecorder() + handler(response, request) + return response +} + +func TestFleetRegistrationAndAuthenticatedEvents(t *testing.T) { + server := newFleetTestServer(t) + registration := `{ + "schema_version": 1, + "installation_id": "` + testInstallationID + `", + "credential": "` + testFleetCredential + `", + "proxmenu_version": "2026.7.26-7", + "git_commit": "0123456789abcdef0123456789abcdef01234567", + "pve_version": "pve-manager/9.0.3/abc~1", + "os_version": "Debian GNU/Linux 13 (trixie)", + "kernel_version": "6.14.11-2-pve", + "architecture": "amd64", + "clustered": true + }` + response := fleetRequestRecorder(t, server.handleFleetRegister, registration, "") + if response.Code != http.StatusCreated { + t.Fatalf("registration status = %d, body = %s", response.Code, response.Body.String()) + } + + event := `{ + "schema_version": 1, + "installation_id": "` + testInstallationID + `", + "event": "run_completed", + "result": "success", + "proxmenu_version": "2026.7.26-7", + "git_commit": "0123456789abcdef0123456789abcdef01234567", + "pve_version": "pve-manager/9.0.3/abc~1", + "os_version": "Debian GNU/Linux 13 (trixie)", + "kernel_version": "6.14.11-2-pve", + "architecture": "amd64", + "clustered": true, + "duration_seconds": 19 + }` + response = fleetRequestRecorder(t, server.handleFleetEvent, event, testFleetCredential) + if response.Code != http.StatusNoContent { + t.Fatalf("event status = %d, body = %s", response.Code, response.Body.String()) + } + + var lastEvent, lastResult string + var eventCount int + if err := server.db.QueryRow( + `SELECT last_event, last_result FROM fleet_hosts WHERE installation_id = ?`, + testInstallationID, + ).Scan(&lastEvent, &lastResult); err != nil { + t.Fatal(err) + } + if lastEvent != "run_completed" || lastResult != "success" { + t.Fatalf("unexpected host state: event=%q result=%q", lastEvent, lastResult) + } + if err := server.db.QueryRow( + `SELECT COUNT(*) FROM fleet_events WHERE installation_id = ?`, + testInstallationID, + ).Scan(&eventCount); err != nil { + t.Fatal(err) + } + if eventCount != 2 { + t.Fatalf("event count = %d, want 2", eventCount) + } + + server.verifyFleetInstallation(httptest.NewRequest(http.MethodGet, "/", nil).Context(), testInstallationID) + var verified bool + if err := server.db.QueryRow( + `SELECT verified_at IS NOT NULL FROM fleet_hosts WHERE installation_id = ?`, + testInstallationID, + ).Scan(&verified); err != nil { + t.Fatal(err) + } + if !verified { + t.Fatal("deployment-code exchange did not verify installation") + } +} + +func TestFleetEventRejectsWrongCredential(t *testing.T) { + server := newFleetTestServer(t) + registration := `{ + "schema_version": 1, + "installation_id": "` + testInstallationID + `", + "credential": "` + testFleetCredential + `" + }` + if response := fleetRequestRecorder(t, server.handleFleetRegister, registration, ""); response.Code != http.StatusCreated { + t.Fatalf("registration status = %d", response.Code) + } + event := `{ + "schema_version": 1, + "installation_id": "` + testInstallationID + `", + "event": "run_started", + "result": "started" + }` + wrongCredential := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + response := fleetRequestRecorder(t, server.handleFleetEvent, event, wrongCredential) + if response.Code != http.StatusForbidden { + t.Fatalf("event status = %d, want %d", response.Code, http.StatusForbidden) + } +} + +func TestFleetRequestRejectsUnexpectedSensitiveFields(t *testing.T) { + server := newFleetTestServer(t) + registration := `{ + "schema_version": 1, + "installation_id": "` + testInstallationID + `", + "credential": "` + testFleetCredential + `", + "hostname": "customer-pve01" + }` + response := fleetRequestRecorder(t, server.handleFleetRegister, registration, "") + if response.Code != http.StatusBadRequest { + t.Fatalf("registration status = %d, want %d", response.Code, http.StatusBadRequest) + } +} diff --git a/internal/app/server.go b/internal/app/server.go index a44214e..b0cd6a2 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -100,6 +100,7 @@ type pageData struct { AuditEventTypes []string Packages []packageRecord Actions []actionRecord + FleetHosts []fleetHostRecord NewCode string DefaultHostLimit int DefaultDuration string @@ -175,6 +176,11 @@ func parseTemplates(displayTimeZone *time.Location) (*template.Template, error) "isActive": func(record authorizationRecord) bool { return !record.RevokedAt.Valid && time.Now().Before(record.ExpiresAt) }, + "isStale": func(value time.Time) bool { + return value.Before(time.Now().Add(-30 * 24 * time.Hour)) + }, + "shortInstallationID": shortInstallationID, + "shortCommit": shortCommit, }).ParseFS(webFiles, "templates/*.html") } @@ -192,11 +198,14 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /portal", s.requireTechnician(s.handlePortal)) mux.HandleFunc("GET /portal/packages", s.requireTechnician(s.handlePackagesPortal)) mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal)) + mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal)) mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization)) mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization)) mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage)) mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage)) mux.HandleFunc("POST /api/v1/exchange", s.handleExchange) + mux.HandleFunc("POST /api/v1/hosts/register", s.handleFleetRegister) + mux.HandleFunc("POST /api/v1/hosts/events", s.handleFleetEvent) mux.HandleFunc("GET /api/v1/packages/{slug}", s.handlePackageDownload) mux.HandleFunc("GET /", s.handleHome) mux.Handle("GET /static/", http.FileServerFS(webFiles)) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 34ce0f2..251395e 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -80,6 +80,33 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) { } } +func TestFleetTemplateExecutes(t *testing.T) { + t.Parallel() + templates, err := parseTemplates(time.UTC) + if err != nil { + t.Fatal(err) + } + err = templates.ExecuteTemplate(io.Discard, "hosts.html", pageData{ + Title: "Test", + Technician: &technician{DisplayName: "Technician"}, + CurrentView: "hosts", + FleetHosts: []fleetHostRecord{{ + InstallationID: "123e4567-e89b-42d3-a456-426614174000", + FirstSeenAt: time.Now(), + LastSeenAt: time.Now(), + LastEvent: "run_completed", + LastResult: "success", + ProxMenuVersion: "2026.7.26-7", + PVEVersion: "pve-manager/9.0.3", + OSVersion: "Debian GNU/Linux 13 (trixie)", + Architecture: "amd64", + }}, + }) + if err != nil { + t.Fatal(err) + } +} + func TestAuditTemplateUsesConfiguredTimeZone(t *testing.T) { t.Parallel() location, err := time.LoadLocation("America/Chicago") diff --git a/internal/app/static/app.css b/internal/app/static/app.css index 951be74..eed7c31 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -325,3 +325,19 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .code-reveal { align-items: stretch; flex-direction: column; } .panel-heading-action { align-items: flex-start; flex-direction: column; } } +.fleet-table { display: grid; gap: .75rem; } +.fleet-row { + display: grid; + grid-template-columns: 1fr 1fr 1.5fr 1.2fr; + gap: 1rem; + align-items: start; + padding: 1rem; + border: 1px solid var(--line); + border-radius: .75rem; +} +.fleet-row > div { display: flex; flex-direction: column; gap: .25rem; } +.fleet-header { font-weight: 700; background: var(--panel-2); } +@media (max-width: 850px) { + .fleet-header { display: none; } + .fleet-row { grid-template-columns: 1fr; } +} diff --git a/internal/app/templates/components.html b/internal/app/templates/components.html index b1d8f71..04cbaaf 100644 --- a/internal/app/templates/components.html +++ b/internal/app/templates/components.html @@ -11,6 +11,7 @@
diff --git a/internal/app/templates/hosts.html b/internal/app/templates/hosts.html new file mode 100644 index 0000000..628a3e4 --- /dev/null +++ b/internal/app/templates/hosts.html @@ -0,0 +1,61 @@ +{{define "hosts.html"}} + + + + + + {{.Title}} · TAPM + + + + {{template "topbar" .}} +
+
+
+

Privacy-minimized fleet registry

+

ProxMenu hosts.

+

Installations are identified by a random ID. No hostname, machine ID, username, VM inventory, or credential is collected.

+
+
+ +
+
+ + {{range .FleetHosts}} +
+
+ {{shortInstallationID .InstallationID}} + {{if .VerifiedAt.Valid}}Verified{{else}}Unverified{{end}} + First seen {{formatTime .FirstSeenAt}} +
+
+ {{if .ProxMenuVersion}}{{.ProxMenuVersion}}{{else}}Unknown version{{end}} + {{if .GitCommit}}Commit {{shortCommit .GitCommit}}{{end}} +
+
+ {{if .PVEVersion}}{{.PVEVersion}}{{else}}Unknown PVE{{end}} + {{.OSVersion}}{{if .Architecture}} · {{.Architecture}}{{end}} + {{if .KernelVersion}}Kernel {{.KernelVersion}}{{end}} + {{if .Clustered}}Clustered{{else}}Standalone{{end}} +
+
+ {{.LastEvent}} · {{.LastResult}} + {{if isStale .LastSeenAt}}Stale{{else}}Current{{end}} + {{formatTime .LastSeenAt}} + {{if .LastErrorCode}}Error: {{.LastErrorCode}}{{end}} +
+
+ {{else}} +

No ProxMenu installations have registered yet.

+ {{end}} +
+
+
+ + +{{end}} diff --git a/migrations/002_fleet_registry.sql b/migrations/002_fleet_registry.sql new file mode 100644 index 0000000..dabe57a --- /dev/null +++ b/migrations/002_fleet_registry.sql @@ -0,0 +1,40 @@ +CREATE TABLE fleet_hosts ( + installation_id TEXT PRIMARY KEY, + credential_hash BLOB NOT NULL, + registration_source_hash BLOB NOT NULL, + verified_at DATETIME, + first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_success_at DATETIME, + last_event TEXT NOT NULL DEFAULT 'installed', + last_result TEXT NOT NULL DEFAULT 'success', + last_error_code TEXT NOT NULL DEFAULT '', + proxmenu_version TEXT NOT NULL DEFAULT '', + git_commit TEXT NOT NULL DEFAULT '', + pve_version TEXT NOT NULL DEFAULT '', + os_version TEXT NOT NULL DEFAULT '', + kernel_version TEXT NOT NULL DEFAULT '', + architecture TEXT NOT NULL DEFAULT '', + clustered INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_fleet_hosts_last_seen ON fleet_hosts(last_seen_at); +CREATE INDEX idx_fleet_hosts_version ON fleet_hosts(proxmenu_version); +CREATE INDEX idx_fleet_hosts_registration_source + ON fleet_hosts(registration_source_hash, first_seen_at); + +CREATE TABLE fleet_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + installation_id TEXT NOT NULL, + event_type TEXT NOT NULL, + result TEXT NOT NULL, + proxmenu_version TEXT NOT NULL DEFAULT '', + error_code TEXT NOT NULL DEFAULT '', + duration_seconds INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (installation_id) REFERENCES fleet_hosts(installation_id) + ON DELETE CASCADE +); +CREATE INDEX idx_fleet_events_host_created + ON fleet_events(installation_id, created_at); +CREATE INDEX idx_fleet_events_created ON fleet_events(created_at);