update security

This commit is contained in:
David Schroeder
2026-07-26 16:41:25 -05:00
parent 90b7b2c597
commit 81f65b5173
14 changed files with 869 additions and 5 deletions
+1
View File
@@ -12,6 +12,7 @@ The production layout is a self-contained, single-VM Docker deployment:
- Nginx reverse proxy for both public domains
- Certbot and Let's Encrypt HTTP-01 certificate management
- explicit runtime storage under `config/` beside the Compose files
- privacy-minimized ProxMenu installation and run-status registry
No external database or load balancer is required. The datacenter edge only
needs to forward public TCP 80 and 443 to the configured Docker VM ports.
+19
View File
@@ -1,5 +1,11 @@
name: tapm
x-default-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
broker:
container_name: tapm-broker
@@ -10,6 +16,8 @@ services:
required: false
restart: unless-stopped
init: true
pids_limit: 128
logging: *default-logging
read_only: true
networks:
- edge
@@ -32,6 +40,8 @@ services:
container_name: tapm-gitea
image: docker.gitea.com/gitea:1.26.4-rootless
restart: unless-stopped
pids_limit: 512
logging: *default-logging
networks:
- edge
volumes:
@@ -61,6 +71,9 @@ services:
container_name: tapm-nginx
image: nginx:1.28-alpine
restart: unless-stopped
read_only: true
pids_limit: 128
logging: *default-logging
networks:
- edge
ports:
@@ -76,10 +89,16 @@ services:
SSL_CERTIFICATE_KEY_FILE: ${SSL_CERTIFICATE_KEY_FILE:-privkey.pem}
security_opt:
- no-new-privileges:true
tmpfs:
- /etc/nginx/conf.d:size=1m,mode=0755
- /var/cache/nginx:size=32m,mode=0755
- /var/run:size=1m,mode=0755
certbot:
image: certbot/certbot:v5.7.0
profiles: ["tools"]
pids_limit: 128
logging: *default-logging
volumes:
- ./config/letsencrypt:/etc/letsencrypt
- ./config/certbot-webroot:/var/www/certbot
+47 -2
View File
@@ -1,5 +1,20 @@
resolver 127.0.0.11 valid=30s;
server_tokens off;
reset_timedout_connection on;
client_header_timeout 15s;
client_body_timeout 60s;
keepalive_timeout 30s;
send_timeout 60s;
limit_conn_zone $binary_remote_addr zone=per_address_connections:10m;
limit_req_zone $binary_remote_addr zone=broker_requests:10m rate=20r/s;
map $uri $gitea_login_client {
default "";
"/user/login" $binary_remote_addr;
}
limit_req_zone $gitea_login_client zone=gitea_login_requests:10m rate=10r/m;
upstream broker_backend {
zone broker_backend 64k;
server broker:8080 resolve;
@@ -10,6 +25,12 @@ upstream gitea_backend {
server gitea:3000 resolve;
}
server {
listen 80 default_server;
server_name _;
return 444;
}
server {
listen 80;
server_name ${BROKER_DOMAIN} ${GITEA_DOMAIN};
@@ -23,6 +44,12 @@ server {
}
}
server {
listen 443 ssl default_server;
server_name _;
ssl_reject_handshake on;
}
server {
listen 443 ssl;
http2 on;
@@ -32,16 +59,25 @@ server {
ssl_certificate_key /etc/nginx/ssl/${SSL_CERTIFICATE_KEY_FILE};
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 1100m;
limit_conn per_address_connections 30;
limit_req zone=broker_requests burst=40 nodelay;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / {
proxy_pass http://broker_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 30s;
proxy_send_timeout 30m;
@@ -60,16 +96,25 @@ server {
ssl_certificate_key /etc/nginx/ssl/${SSL_CERTIFICATE_KEY_FILE};
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 1100m;
limit_conn per_address_connections 50;
limit_req zone=gitea_login_requests burst=10 nodelay;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / {
proxy_pass http://gitea_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
}
+56
View File
@@ -12,6 +12,7 @@ credentials.
"code": "TAPM-ABCDE-FGHIJ",
"host_fingerprint": "sha256-of-the-host-machine-id",
"hostname": "pve01",
"installation_id": "123e4567-e89b-42d3-a456-426614174000",
"requested_action": "install-rmm",
"requested_package": ""
}
@@ -50,6 +51,9 @@ authorization code.
When `requested_action` or `requested_package` is present, the broker validates
that entitlement before enrolling the host. Older clients may omit both fields,
but current clients should always identify what they intend to use.
Current V2 clients also send their random `installation_id`. A successful
code exchange marks the matching fleet record as verified; it does not expose
or replace the host's fleet credential.
An installer must proceed only when its required package slug or action slug is
present in this response. The current action slugs are:
@@ -83,3 +87,55 @@ The server also returns the expected value in `X-TAPM-SHA256`.
Codes and sessions expire at the same authorization deadline. Revoking an
authorization immediately invalidates all of its sessions.
## Fleet registration
V2 clients register a locally generated installation identity:
`POST https://tapm.example.com/api/v1/hosts/register`
```json
{
"schema_version": 1,
"installation_id": "123e4567-e89b-42d3-a456-426614174000",
"credential": "64-lowercase-hexadecimal-characters",
"proxmenu_version": "2026.7.26-7",
"git_commit": "40-lowercase-hexadecimal-characters",
"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": false
}
```
The first request returns `201`; subsequent authenticated registrations return
`200`. The broker stores a SHA-256 digest of the 256-bit random credential, not
the credential itself. New registrations are limited per privacy-preserving
HMAC of the source address to reduce anonymous registry abuse.
## Fleet lifecycle events
`POST https://tapm.example.com/api/v1/hosts/events`
The request uses `Authorization: Bearer HOST_CREDENTIAL`. Its body has the same
schema and platform metadata as registration, omits `credential`, and adds:
```json
{
"event": "run_completed",
"result": "success",
"error_code": "",
"duration_seconds": 19
}
```
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.
+16 -3
View File
@@ -20,14 +20,27 @@ into the Gitea container, preventing them from overriding its internal listener.
## 1. VM and network
Install Docker Engine with Compose v2. Permit the selected HTTP and HTTPS ports
and forward public TCP 80 and 443 from the datacenter edge to them. The
Create public DNS A/AAAA records for both application names before requesting
the certificate.
and forward public TCP 80 and 443 from the datacenter edge to them. Create
public DNS A/AAAA records for both application names before requesting the
certificate.
Public port 80 must reach the container's port 80 for HTTP-01 certificate
renewal. For example, if `HTTP_PORT=8080`, the edge must forward public port 80
to VM port 8080. If IPv6 is published, it must reach this same VM.
The company firewall should provide volumetric DDoS protection and permit only
the required forwarded ports. Nginx supplies the application-edge controls:
unknown HTTP hostnames are dropped, unknown TLS SNI names are rejected,
TLS is restricted to 1.2/1.3, session tickets and version disclosure are
disabled, broker requests and per-address connections are limited, slow-client
timeouts are bounded, Gitea login bursts are throttled, and HSTS plus browser
security headers are returned. Container PID ceilings and Docker log rotation
reduce the impact of local resource exhaustion.
Nginx also runs with a read-only container filesystem and narrowly scoped
temporary filesystems. The broker trusts proxy headers because Nginx is its
only production ingress; Nginx replaces rather than appends client-supplied
forwarding headers.
## 2. Install the repository
```sh
+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}}
+40
View File
@@ -0,0 +1,40 @@
CREATE TABLE fleet_hosts (
installation_id TEXT PRIMARY KEY,
credential_hash BLOB NOT NULL,
registration_source_hash BLOB NOT NULL,
verified_at DATETIME,
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_success_at DATETIME,
last_event TEXT NOT NULL DEFAULT 'installed',
last_result TEXT NOT NULL DEFAULT 'success',
last_error_code TEXT NOT NULL DEFAULT '',
proxmenu_version TEXT NOT NULL DEFAULT '',
git_commit TEXT NOT NULL DEFAULT '',
pve_version TEXT NOT NULL DEFAULT '',
os_version TEXT NOT NULL DEFAULT '',
kernel_version TEXT NOT NULL DEFAULT '',
architecture TEXT NOT NULL DEFAULT '',
clustered INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_fleet_hosts_last_seen ON fleet_hosts(last_seen_at);
CREATE INDEX idx_fleet_hosts_version ON fleet_hosts(proxmenu_version);
CREATE INDEX idx_fleet_hosts_registration_source
ON fleet_hosts(registration_source_hash, first_seen_at);
CREATE TABLE fleet_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
installation_id TEXT NOT NULL,
event_type TEXT NOT NULL,
result TEXT NOT NULL,
proxmenu_version TEXT NOT NULL DEFAULT '',
error_code TEXT NOT NULL DEFAULT '',
duration_seconds INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (installation_id) REFERENCES fleet_hosts(installation_id)
ON DELETE CASCADE
);
CREATE INDEX idx_fleet_events_host_created
ON fleet_events(installation_id, created_at);
CREATE INDEX idx_fleet_events_created ON fleet_events(created_at);