update security

This commit is contained in:
2026-07-26 16:41:25 -05:00
parent 90b7b2c597
commit 81f65b5173
14 changed files with 869 additions and 5 deletions
+3
View File
@@ -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)
}
+406
View File
@@ -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]
}
+167
View File
@@ -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)
}
}
+9
View File
@@ -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))
+27
View File
@@ -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")
+16
View File
@@ -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; }
}
+1
View File
@@ -11,6 +11,7 @@
<nav class="portal-nav" aria-label="Portal">
<a href="/portal" {{if eq .CurrentView "codes"}}class="active" aria-current="page"{{end}}>Codes</a>
<a href="/portal/packages" {{if eq .CurrentView "packages"}}class="active" aria-current="page"{{end}}>Packages</a>
<a href="/portal/hosts" {{if eq .CurrentView "hosts"}}class="active" aria-current="page"{{end}}>Hosts</a>
<a href="/portal/audit" {{if eq .CurrentView "audit"}}class="active" aria-current="page"{{end}}>Audit</a>
</nav>
<div class="operator">
+61
View File
@@ -0,0 +1,61 @@
{{define "hosts.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · TAPM</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
{{template "topbar" .}}
<main class="page">
<section class="view-heading">
<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>
</div>
</section>
<section class="panel">
<div class="fleet-table">
<div class="fleet-row fleet-header" aria-hidden="true">
<span>Installation</span>
<span>Software</span>
<span>Platform</span>
<span>Last activity</span>
</div>
{{range .FleetHosts}}
<article class="fleet-row">
<div>
<strong>{{shortInstallationID .InstallationID}}</strong>
<span class="status {{if .VerifiedAt.Valid}}enabled{{end}}">{{if .VerifiedAt.Valid}}Verified{{else}}Unverified{{end}}</span>
<small>First seen {{formatTime .FirstSeenAt}}</small>
</div>
<div>
<strong>{{if .ProxMenuVersion}}{{.ProxMenuVersion}}{{else}}Unknown version{{end}}</strong>
{{if .GitCommit}}<small>Commit {{shortCommit .GitCommit}}</small>{{end}}
</div>
<div>
<span>{{if .PVEVersion}}{{.PVEVersion}}{{else}}Unknown PVE{{end}}</span>
<small>{{.OSVersion}}{{if .Architecture}} · {{.Architecture}}{{end}}</small>
{{if .KernelVersion}}<small>Kernel {{.KernelVersion}}</small>{{end}}
<small>{{if .Clustered}}Clustered{{else}}Standalone{{end}}</small>
</div>
<div>
<strong>{{.LastEvent}} · {{.LastResult}}</strong>
<span class="status {{if isStale .LastSeenAt}}closed{{else}}enabled{{end}}">{{if isStale .LastSeenAt}}Stale{{else}}Current{{end}}</span>
<small>{{formatTime .LastSeenAt}}</small>
{{if .LastErrorCode}}<small>Error: {{.LastErrorCode}}</small>{{end}}
</div>
</article>
{{else}}
<p class="empty">No ProxMenu installations have registered yet.</p>
{{end}}
</div>
</section>
</main>
</body>
</html>
{{end}}