update security
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user