This commit is contained in:
2026-07-28 19:57:39 -05:00
parent 7f24b64906
commit 8dcee4121b
6 changed files with 61 additions and 22 deletions
+8 -5
View File
@@ -98,6 +98,7 @@ V2 clients register a locally generated installation identity:
{ {
"schema_version": 1, "schema_version": 1,
"installation_id": "123e4567-e89b-42d3-a456-426614174000", "installation_id": "123e4567-e89b-42d3-a456-426614174000",
"hostname": "pve01",
"credential": "64-lowercase-hexadecimal-characters", "credential": "64-lowercase-hexadecimal-characters",
"proxmenu_version": "2026.7.26-7", "proxmenu_version": "2026.7.26-7",
"git_commit": "40-lowercase-hexadecimal-characters", "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 `upgraded`. Events are best-effort and are sent only while TA-ProxMenu is
running; there is no resident monitoring service. running; there is no resident monitoring service.
The fleet API deliberately does not accept or store hostnames, machine IDs, The fleet API stores the reported hostname as a limited operational identifier.
MAC addresses, usernames, customer names, VM/container inventory, deployment It deliberately does not accept or store machine IDs, MAC addresses, usernames,
tokens, or command output. The portal shows random installation IDs, first and VM/container inventory, deployment tokens, credentials, or command output. The
last activity, verification status, software/platform versions, cluster mode, portal shows hostname, random installation ID, first and last activity,
and the latest structured result. 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.
+12 -5
View File
@@ -23,12 +23,14 @@ var (
) )
hexCredentialPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) hexCredentialPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
fleetValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+~,:/() -]*$`) 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_-]*$`) errorCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
) )
type fleetRequest struct { type fleetRequest struct {
SchemaVersion int `json:"schema_version"` SchemaVersion int `json:"schema_version"`
InstallationID string `json:"installation_id"` InstallationID string `json:"installation_id"`
Hostname string `json:"hostname,omitempty"`
Credential string `json:"credential,omitempty"` Credential string `json:"credential,omitempty"`
Event string `json:"event,omitempty"` Event string `json:"event,omitempty"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
@@ -45,6 +47,7 @@ type fleetRequest struct {
type fleetHostRecord struct { type fleetHostRecord struct {
InstallationID string InstallationID string
Hostname string
VerifiedAt sql.NullTime VerifiedAt sql.NullTime
FirstSeenAt time.Time FirstSeenAt time.Time
LastSeenAt time.Time LastSeenAt time.Time
@@ -163,12 +166,14 @@ func decodeFleetRequest(w http.ResponseWriter, r *http.Request, registration boo
return request, false return request, false
} }
request.InstallationID = strings.ToLower(strings.TrimSpace(request.InstallationID)) request.InstallationID = strings.ToLower(strings.TrimSpace(request.InstallationID))
request.Hostname = strings.TrimSpace(request.Hostname)
request.Credential = strings.ToLower(strings.TrimSpace(request.Credential)) request.Credential = strings.ToLower(strings.TrimSpace(request.Credential))
request.Event = strings.TrimSpace(request.Event) request.Event = strings.TrimSpace(request.Event)
request.Result = strings.TrimSpace(request.Result) request.Result = strings.TrimSpace(request.Result)
request.ErrorCode = strings.TrimSpace(request.ErrorCode) request.ErrorCode = strings.TrimSpace(request.ErrorCode)
if request.SchemaVersion != 1 || if request.SchemaVersion != 1 ||
!installationIDPattern.MatchString(request.InstallationID) || !installationIDPattern.MatchString(request.InstallationID) ||
(request.Hostname != "" && !fleetHostnamePattern.MatchString(request.Hostname)) ||
(registration && !hexCredentialPattern.MatchString(request.Credential)) || (registration && !hexCredentialPattern.MatchString(request.Credential)) ||
!validFleetMetadata(request) { !validFleetMetadata(request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid host data"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid host data"})
@@ -233,11 +238,11 @@ func (s *Server) insertFleetHost(
if _, err := tx.ExecContext( if _, err := tx.ExecContext(
ctx, ctx,
`INSERT INTO fleet_hosts `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, proxmenu_version, git_commit, pve_version, os_version,
kernel_version, architecture, clustered) kernel_version, architecture, clustered)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
request.InstallationID, credentialHash, sourceHash, request.InstallationID, request.Hostname, credentialHash, sourceHash,
request.ProxMenuVersion, request.GitCommit, request.PVEVersion, request.ProxMenuVersion, request.GitCommit, request.PVEVersion,
request.OSVersion, request.KernelVersion, request.Architecture, request.OSVersion, request.KernelVersion, request.Architecture,
request.Clustered, 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_event = CASE WHEN ? THEN ? ELSE last_event END,
last_result = CASE WHEN ? THEN ? ELSE last_result END, last_result = CASE WHEN ? THEN ? ELSE last_result END,
last_error_code = CASE WHEN ? THEN ? ELSE last_error_code END, last_error_code = CASE WHEN ? THEN ? ELSE last_error_code END,
hostname = CASE WHEN ? <> '' THEN ? ELSE hostname END,
proxmenu_version = ?, proxmenu_version = ?,
git_commit = ?, git_commit = ?,
pve_version = ?, pve_version = ?,
@@ -285,6 +291,7 @@ func (s *Server) updateFleetHost(ctx context.Context, request fleetRequest, reco
recordEvent, request.Event, recordEvent, request.Event,
recordEvent, request.Result, recordEvent, request.Result,
recordEvent, request.ErrorCode, recordEvent, request.ErrorCode,
request.Hostname, request.Hostname,
request.ProxMenuVersion, request.GitCommit, request.PVEVersion, request.ProxMenuVersion, request.GitCommit, request.PVEVersion,
request.OSVersion, request.KernelVersion, request.Architecture, request.OSVersion, request.KernelVersion, request.Architecture,
request.Clustered, request.InstallationID, 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) { func (s *Server) listFleetHosts(r *http.Request) ([]fleetHostRecord, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
r.Context(), 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, last_success_at, last_event, last_result, last_error_code,
proxmenu_version, git_commit, pve_version, os_version, proxmenu_version, git_commit, pve_version, os_version,
kernel_version, architecture, clustered kernel_version, architecture, clustered
@@ -354,7 +361,7 @@ func (s *Server) listFleetHosts(r *http.Request) ([]fleetHostRecord, error) {
for rows.Next() { for rows.Next() {
var record fleetHostRecord var record fleetHostRecord
if err := rows.Scan( 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.LastSeenAt, &record.LastSuccessAt, &record.LastEvent,
&record.LastResult, &record.LastErrorCode, &record.ProxMenuVersion, &record.LastResult, &record.LastErrorCode, &record.ProxMenuVersion,
&record.GitCommit, &record.PVEVersion, &record.OSVersion, &record.GitCommit, &record.PVEVersion, &record.OSVersion,
+34 -10
View File
@@ -24,12 +24,17 @@ func newFleetTestServer(t *testing.T) *Server {
} }
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() }) t.Cleanup(func() { _ = db.Close() })
migration, err := os.ReadFile("../../migrations/002_fleet_registry.sql") for _, migrationPath := range []string{
if err != nil { "../../migrations/002_fleet_registry.sql",
t.Fatal(err) "../../migrations/003_fleet_hostname.sql",
} } {
if _, err := db.Exec(string(migration)); err != nil { migration, err := os.ReadFile(migrationPath)
t.Fatal(err) if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(string(migration)); err != nil {
t.Fatal(err)
}
} }
return &Server{ return &Server{
db: db, db: db,
@@ -62,6 +67,7 @@ func TestFleetRegistrationAndAuthenticatedEvents(t *testing.T) {
registration := `{ registration := `{
"schema_version": 1, "schema_version": 1,
"installation_id": "` + testInstallationID + `", "installation_id": "` + testInstallationID + `",
"hostname": "pve01",
"credential": "` + testFleetCredential + `", "credential": "` + testFleetCredential + `",
"proxmenu_version": "2026.7.26-7", "proxmenu_version": "2026.7.26-7",
"git_commit": "0123456789abcdef0123456789abcdef01234567", "git_commit": "0123456789abcdef0123456789abcdef01234567",
@@ -79,6 +85,7 @@ func TestFleetRegistrationAndAuthenticatedEvents(t *testing.T) {
event := `{ event := `{
"schema_version": 1, "schema_version": 1,
"installation_id": "` + testInstallationID + `", "installation_id": "` + testInstallationID + `",
"hostname": "pve01-renamed",
"event": "run_completed", "event": "run_completed",
"result": "success", "result": "success",
"proxmenu_version": "2026.7.26-7", "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()) t.Fatalf("event status = %d, body = %s", response.Code, response.Body.String())
} }
var lastEvent, lastResult string var hostname, lastEvent, lastResult string
var eventCount int var eventCount int
if err := server.db.QueryRow( 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, testInstallationID,
).Scan(&lastEvent, &lastResult); err != nil { ).Scan(&hostname, &lastEvent, &lastResult); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if hostname != "pve01-renamed" {
t.Fatalf("hostname = %q, want updated hostname", hostname)
}
if lastEvent != "run_completed" || lastResult != "success" { if lastEvent != "run_completed" || lastResult != "success" {
t.Fatalf("unexpected host state: event=%q result=%q", lastEvent, lastResult) t.Fatalf("unexpected host state: event=%q result=%q", lastEvent, lastResult)
} }
@@ -158,7 +168,21 @@ func TestFleetRequestRejectsUnexpectedSensitiveFields(t *testing.T) {
"schema_version": 1, "schema_version": 1,
"installation_id": "` + testInstallationID + `", "installation_id": "` + testInstallationID + `",
"credential": "` + testFleetCredential + `", "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, "") response := fleetRequestRecorder(t, server.handleFleetRegister, registration, "")
if response.Code != http.StatusBadRequest { if response.Code != http.StatusBadRequest {
+1
View File
@@ -92,6 +92,7 @@ func TestFleetTemplateExecutes(t *testing.T) {
CurrentView: "hosts", CurrentView: "hosts",
FleetHosts: []fleetHostRecord{{ FleetHosts: []fleetHostRecord{{
InstallationID: "123e4567-e89b-42d3-a456-426614174000", InstallationID: "123e4567-e89b-42d3-a456-426614174000",
Hostname: "pve01",
FirstSeenAt: time.Now(), FirstSeenAt: time.Now(),
LastSeenAt: time.Now(), LastSeenAt: time.Now(),
LastEvent: "run_completed", LastEvent: "run_completed",
+4 -2
View File
@@ -14,7 +14,8 @@
<div> <div>
<p class="eyebrow">Privacy-minimized fleet registry</p> <p class="eyebrow">Privacy-minimized fleet registry</p>
<h1>ProxMenu hosts.</h1> <h1>ProxMenu hosts.</h1>
<p>Installations are identified by a random ID. No hostname, machine ID, username, VM inventory, or credential is collected.</p> <p>Each installation reports its hostname and a random installation ID. No machine ID, MAC address, username, VM inventory, credential, or command output is collected.</p>
<p><small>Verified means the same installation ID has completed a successful deployment-code exchange.</small></p>
</div> </div>
</section> </section>
@@ -29,7 +30,8 @@
{{range .FleetHosts}} {{range .FleetHosts}}
<article class="fleet-row"> <article class="fleet-row">
<div> <div>
<strong>{{shortInstallationID .InstallationID}}</strong> <strong>{{if .Hostname}}{{.Hostname}}{{else}}Unknown hostname{{end}}</strong>
<small>Installation {{shortInstallationID .InstallationID}}</small>
<span class="status {{if .VerifiedAt.Valid}}enabled{{end}}">{{if .VerifiedAt.Valid}}Verified{{else}}Unverified{{end}}</span> <span class="status {{if .VerifiedAt.Valid}}enabled{{end}}">{{if .VerifiedAt.Valid}}Verified{{else}}Unverified{{end}}</span>
<small>First seen {{formatTime .FirstSeenAt}}</small> <small>First seen {{formatTime .FirstSeenAt}}</small>
</div> </div>
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE fleet_hosts
ADD COLUMN hostname TEXT NOT NULL DEFAULT '';