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