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,
"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.
+12 -5
View File
@@ -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,
+34 -10
View File
@@ -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 {
+1
View File
@@ -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",
+4 -2
View File
@@ -14,7 +14,8 @@
<div>
<p class="eyebrow">Privacy-minimized fleet registry</p>
<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>
</section>
@@ -29,7 +30,8 @@
{{range .FleetHosts}}
<article class="fleet-row">
<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>
<small>First seen {{formatTime .FirstSeenAt}}</small>
</div>
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE fleet_hosts
ADD COLUMN hostname TEXT NOT NULL DEFAULT '';