diff --git a/docs/client-api.md b/docs/client-api.md index f34b1ef..24a5532 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -98,6 +98,7 @@ V2 clients register a locally generated installation identity: { "schema_version": 1, "installation_id": "123e4567-e89b-42d3-a456-426614174000", + "hostname": "pve01", "credential": "64-lowercase-hexadecimal-characters", "proxmenu_version": "2026.7.26-7", "git_commit": "40-lowercase-hexadecimal-characters", @@ -134,8 +135,10 @@ 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. +The fleet API stores the reported hostname as a limited operational identifier. +It deliberately does not accept or store machine IDs, MAC addresses, usernames, +VM/container inventory, deployment tokens, credentials, or command output. The +portal shows hostname, random installation ID, first and last activity, +verification status, software/platform versions, cluster mode, and the latest +structured result. A record becomes verified only after the same installation +ID is included in a successful deployment-code exchange. diff --git a/internal/app/fleet.go b/internal/app/fleet.go index 7e7fc2a..e7e4361 100644 --- a/internal/app/fleet.go +++ b/internal/app/fleet.go @@ -23,12 +23,14 @@ var ( ) hexCredentialPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) fleetValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+~,:/() -]*$`) + fleetHostnamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$`) errorCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) ) type fleetRequest struct { SchemaVersion int `json:"schema_version"` InstallationID string `json:"installation_id"` + Hostname string `json:"hostname,omitempty"` Credential string `json:"credential,omitempty"` Event string `json:"event,omitempty"` Result string `json:"result,omitempty"` @@ -45,6 +47,7 @@ type fleetRequest struct { type fleetHostRecord struct { InstallationID string + Hostname string VerifiedAt sql.NullTime FirstSeenAt time.Time LastSeenAt time.Time @@ -163,12 +166,14 @@ func decodeFleetRequest(w http.ResponseWriter, r *http.Request, registration boo return request, false } request.InstallationID = strings.ToLower(strings.TrimSpace(request.InstallationID)) + request.Hostname = strings.TrimSpace(request.Hostname) 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) || + (request.Hostname != "" && !fleetHostnamePattern.MatchString(request.Hostname)) || (registration && !hexCredentialPattern.MatchString(request.Credential)) || !validFleetMetadata(request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid host data"}) @@ -233,11 +238,11 @@ func (s *Server) insertFleetHost( if _, err := tx.ExecContext( ctx, `INSERT INTO fleet_hosts - (installation_id, credential_hash, registration_source_hash, + (installation_id, hostname, credential_hash, registration_source_hash, proxmenu_version, git_commit, pve_version, os_version, kernel_version, architecture, clustered) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - request.InstallationID, credentialHash, sourceHash, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + request.InstallationID, request.Hostname, credentialHash, sourceHash, request.ProxMenuVersion, request.GitCommit, request.PVEVersion, request.OSVersion, request.KernelVersion, request.Architecture, request.Clustered, @@ -272,6 +277,7 @@ func (s *Server) updateFleetHost(ctx context.Context, request fleetRequest, reco 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, + hostname = CASE WHEN ? <> '' THEN ? ELSE hostname END, proxmenu_version = ?, git_commit = ?, pve_version = ?, @@ -285,6 +291,7 @@ func (s *Server) updateFleetHost(ctx context.Context, request fleetRequest, reco recordEvent, request.Event, recordEvent, request.Result, recordEvent, request.ErrorCode, + request.Hostname, request.Hostname, request.ProxMenuVersion, request.GitCommit, request.PVEVersion, request.OSVersion, request.KernelVersion, request.Architecture, request.Clustered, request.InstallationID, @@ -338,7 +345,7 @@ func (s *Server) verifyFleetInstallation(ctx context.Context, installationID str 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, + `SELECT installation_id, hostname, 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 @@ -354,7 +361,7 @@ func (s *Server) listFleetHosts(r *http.Request) ([]fleetHostRecord, error) { for rows.Next() { var record fleetHostRecord if err := rows.Scan( - &record.InstallationID, &record.VerifiedAt, &record.FirstSeenAt, + &record.InstallationID, &record.Hostname, &record.VerifiedAt, &record.FirstSeenAt, &record.LastSeenAt, &record.LastSuccessAt, &record.LastEvent, &record.LastResult, &record.LastErrorCode, &record.ProxMenuVersion, &record.GitCommit, &record.PVEVersion, &record.OSVersion, diff --git a/internal/app/fleet_test.go b/internal/app/fleet_test.go index 4ecd6e4..82c3ce8 100644 --- a/internal/app/fleet_test.go +++ b/internal/app/fleet_test.go @@ -24,12 +24,17 @@ func newFleetTestServer(t *testing.T) *Server { } 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) + for _, migrationPath := range []string{ + "../../migrations/002_fleet_registry.sql", + "../../migrations/003_fleet_hostname.sql", + } { + migration, err := os.ReadFile(migrationPath) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(string(migration)); err != nil { + t.Fatal(err) + } } return &Server{ db: db, @@ -62,6 +67,7 @@ func TestFleetRegistrationAndAuthenticatedEvents(t *testing.T) { registration := `{ "schema_version": 1, "installation_id": "` + testInstallationID + `", + "hostname": "pve01", "credential": "` + testFleetCredential + `", "proxmenu_version": "2026.7.26-7", "git_commit": "0123456789abcdef0123456789abcdef01234567", @@ -79,6 +85,7 @@ func TestFleetRegistrationAndAuthenticatedEvents(t *testing.T) { event := `{ "schema_version": 1, "installation_id": "` + testInstallationID + `", + "hostname": "pve01-renamed", "event": "run_completed", "result": "success", "proxmenu_version": "2026.7.26-7", @@ -95,14 +102,17 @@ func TestFleetRegistrationAndAuthenticatedEvents(t *testing.T) { t.Fatalf("event status = %d, body = %s", response.Code, response.Body.String()) } - var lastEvent, lastResult string + var hostname, lastEvent, lastResult string var eventCount int if err := server.db.QueryRow( - `SELECT last_event, last_result FROM fleet_hosts WHERE installation_id = ?`, + `SELECT hostname, last_event, last_result FROM fleet_hosts WHERE installation_id = ?`, testInstallationID, - ).Scan(&lastEvent, &lastResult); err != nil { + ).Scan(&hostname, &lastEvent, &lastResult); err != nil { t.Fatal(err) } + if hostname != "pve01-renamed" { + t.Fatalf("hostname = %q, want updated hostname", hostname) + } if lastEvent != "run_completed" || lastResult != "success" { t.Fatalf("unexpected host state: event=%q result=%q", lastEvent, lastResult) } @@ -158,7 +168,21 @@ func TestFleetRequestRejectsUnexpectedSensitiveFields(t *testing.T) { "schema_version": 1, "installation_id": "` + testInstallationID + `", "credential": "` + testFleetCredential + `", - "hostname": "customer-pve01" + "machine_id": "customer-machine-id" + }` + response := fleetRequestRecorder(t, server.handleFleetRegister, registration, "") + if response.Code != http.StatusBadRequest { + t.Fatalf("registration status = %d, want %d", response.Code, http.StatusBadRequest) + } +} + +func TestFleetRequestRejectsInvalidHostname(t *testing.T) { + server := newFleetTestServer(t) + registration := `{ + "schema_version": 1, + "installation_id": "` + testInstallationID + `", + "credential": "` + testFleetCredential + `", + "hostname": "customer name with spaces" }` response := fleetRequestRecorder(t, server.handleFleetRegister, registration, "") if response.Code != http.StatusBadRequest { diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 251395e..da323f4 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -92,6 +92,7 @@ func TestFleetTemplateExecutes(t *testing.T) { CurrentView: "hosts", FleetHosts: []fleetHostRecord{{ InstallationID: "123e4567-e89b-42d3-a456-426614174000", + Hostname: "pve01", FirstSeenAt: time.Now(), LastSeenAt: time.Now(), LastEvent: "run_completed", diff --git a/internal/app/templates/hosts.html b/internal/app/templates/hosts.html index 628a3e4..1f82fec 100644 --- a/internal/app/templates/hosts.html +++ b/internal/app/templates/hosts.html @@ -14,7 +14,8 @@

Privacy-minimized fleet registry

ProxMenu hosts.

-

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

+

Each installation reports its hostname and a random installation ID. No machine ID, MAC address, username, VM inventory, credential, or command output is collected.

+

Verified means the same installation ID has completed a successful deployment-code exchange.

@@ -29,7 +30,8 @@ {{range .FleetHosts}}
- {{shortInstallationID .InstallationID}} + {{if .Hostname}}{{.Hostname}}{{else}}Unknown hostname{{end}} + Installation {{shortInstallationID .InstallationID}} {{if .VerifiedAt.Valid}}Verified{{else}}Unverified{{end}} First seen {{formatTime .FirstSeenAt}}
diff --git a/migrations/003_fleet_hostname.sql b/migrations/003_fleet_hostname.sql new file mode 100644 index 0000000..3ea3b47 --- /dev/null +++ b/migrations/003_fleet_hostname.sql @@ -0,0 +1,2 @@ +ALTER TABLE fleet_hosts + ADD COLUMN hostname TEXT NOT NULL DEFAULT '';