update
This commit is contained in:
+8
-5
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ 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{
|
||||||
|
"../../migrations/002_fleet_registry.sql",
|
||||||
|
"../../migrations/003_fleet_hostname.sql",
|
||||||
|
} {
|
||||||
|
migration, err := os.ReadFile(migrationPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(string(migration)); err != nil {
|
if _, err := db.Exec(string(migration)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return &Server{
|
return &Server{
|
||||||
db: db,
|
db: db,
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE fleet_hosts
|
||||||
|
ADD COLUMN hostname TEXT NOT NULL DEFAULT '';
|
||||||
Reference in New Issue
Block a user