407 lines
12 KiB
Go
407 lines
12 KiB
Go
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]
|
|
}
|