Files
felhom-agent/internal/hub/report.go
T
admin bf8e3be3f4 agent: report served local-API leaf fingerprint (hub re-key detection, Part A) v0.48.0
HostReport.LeafFingerprint rides the served fp (from EnsureLeaf) on every report; empty when local
API disabled. Collector.SetLeafFingerprint threads it like Capabilities. Hub watches it for a re-key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
2026-06-29 23:14:52 +02:00

318 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package hub
import (
"encoding/json"
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
)
// HostReport is the wire contract shared with the hub's ingest
// (felhom.eu TASK-slice3-hub-ingest). Field NAMES must match the hub
// field-for-field. Encoding is ordinary encoding/json (no canonicalization —
// nothing signs this report; canonical JSON is a slice-10 signing concern).
//
// The report IS the heartbeat: one periodic POST /api/v1/host-report, whose
// server-side received_at is the hub's dead-man's-switch liveness signal. There is
// no separate heartbeat endpoint.
type HostReport struct {
HostID string `json:"host_id"` // echoes config.Hub.HostID
ReportedAt string `json:"reported_at"` // RFC3339, agent clock
AgentVersion string `json:"agent_version"`
Host HostMetrics `json:"host"`
Guests []Guest `json:"guests"`
// Defined now as the stable contract; emitted EMPTY (non-nil) this slice.
StorageTargets []StorageTarget `json:"storage_targets"` // slice 5 (storage manifest)
Backups []Backup `json:"backups"` // slice 6
RestoreTests []RestoreTest `json:"restore_tests"` // slice 6
PBSSnapshots []PBSSnapshot `json:"pbs_snapshots"` // slice 6
Cloudflared Cloudflared `json:"cloudflared"`
AuditTail []AuditEntry `json:"audit_tail"` // populated by a later slice
// Capabilities is the agent's privileged-capability self-check snapshot (v0.44.0): per required
// `sudo -n` grant, whether it is permitted + the binary exists. The hub keys its operator alert
// on a Critical capability flipping to "degraded". Non-nil so it marshals as [].
Capabilities []capability.Status `json:"capabilities"`
// LeafFingerprint is the SHA-256 of the local-API leaf the agent CURRENTLY serves (v0.48.0). The
// hub records the first value per host as the baseline and raises `host_leaf_changed` if it ever
// changes — a proactive, fleet-wide agent-re-key alert independent of any controller's channel
// check. Empty when the local API is disabled (no leaf) → the hub treats "" as unknown, never an
// alert. Not a secret (the fp is public; the token is never reported).
LeafFingerprint string `json:"leaf_fingerprint"`
// DR recipe — the agent (storage/guest/PBS) half of the secret-free reconstruction recipe
// (SPIKE-dr-recipe-2026-06-16). Derived from the facts above; carries ONLY identifiers/intents/
// sizes/coordinates, never a secret. The hub assembles it with the controller's app half.
DRRecipe *DRRecipeHostHalf `json:"dr_recipe"`
}
// HostMetrics is the host block, sourced from proxmox NodeStatus.
type HostMetrics struct {
Node string `json:"node"`
CPUPercent float64 `json:"cpu_percent"` // 0100
MemoryTotalBytes int64 `json:"memory_total_bytes"`
MemoryUsedBytes int64 `json:"memory_used_bytes"`
MemoryPercent float64 `json:"memory_percent"`
DiskTotalBytes int64 `json:"disk_total_bytes"` // host root fs
DiskUsedBytes int64 `json:"disk_used_bytes"`
DiskPercent float64 `json:"disk_percent"`
LoadAvg []string `json:"loadavg"` // array of STRINGS (PVE shape)
UptimeSeconds int64 `json:"uptime_seconds"`
// CPUTempC is the host CPU/chassis temperature in whole °C, or null when the hardware
// exposes no usable sensor (a headless VM, an unsupported board, or any read error all
// degrade to null — a missing sensor never fails the report). Same nullable contract as
// the per-disk SmartSummary.TemperatureC. Sourced from sysfs (hwmon / thermal zones).
// Cross-repo wire field (slice 9) — the hub's HostMetrics copy + golden carry it too.
CPUTempC *int `json:"cpu_temp_c"`
}
// Guest is one LXC. The agent reports vmid; the hub derives the guest PK
// "<host_id>/<vmid>" (keeping the id scheme hub-side — locked decision 4).
type Guest struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"` // running | stopped | unknown
ControllerVersion string `json:"controller_version"` // "" this slice (slice 8 fills)
Spec *GuestSpec `json:"spec,omitempty"` // omitted when status unknown
}
// GuestSpec is the provisioned guest sizing.
type GuestSpec struct {
Cores int `json:"cores"`
MemoryBytes int64 `json:"memory_bytes"`
DiskBytes int64 `json:"disk_bytes"`
}
// Cloudflared is the tunnel service health (read-only probe this slice).
type Cloudflared struct {
Status string `json:"status"` // active | inactive | failed | unknown
}
// The following element types are declared now so the empty collections above are
// typed and slices 5/6 only fill them.
// StorageTarget is one observed host storage target (doc 03 §7). It is the REPORTED
// shape — what the agent observes and tells the hub. The hub holds the AUTHORITATIVE
// manifest (desired class/role/policy/creds, slice 10); the agent reports what it sees
// and reconciles toward the hub's manifest. So `class_hint` is a rotational HINT, never
// authoritative class, and `role` is set only when derivable from an existing Proxmox
// storage definition (else empty — the hub owns it).
//
// This is a cross-repo contract DUPLICATED in felhom.eu/hub (no shared module yet);
// testdata/host-report.golden.json must stay byte-identical with the hub's copy and the
// bidirectional key-set test (contract_test.go) guards drift.
type StorageTarget struct {
Name string `json:"name"` // the Proxmox storage id (its name)
Type string `json:"type"` // local-dir | lvmthin | usb | nfs | cifs | pbs | local
DurableID string `json:"durable_id"` // fs-UUID (usb/local-dir) | server:export (nfs/cifs) | repo+fingerprint (pbs)
// State is the observed lifecycle state. attached (present & usable) | disconnected
// (a KNOWN target whose backing device/mount/reachability dropped) | decommissioned
// (hub-manifest state — built but not served until slice 10).
State string `json:"state"`
Reachable bool `json:"reachable"` // backing device present + mounted (local) / reachable (network)
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
AvailBytes int64 `json:"avail_bytes"`
UsedFraction float64 `json:"used_fraction"`
Content string `json:"content"` // Proxmox content list, e.g. "rootdir,images" / "backup,vztmpl"
MountPath string `json:"mount_path"` // host mountpoint (dir/usb); "" for network/lvm
BackingDevice string `json:"backing_device"` // resolved block device (e.g. /dev/sdb1); "" for network
// ClassHint is a fast|slow HINT derived from the backing disk's rotational flag — a
// hint only; the authoritative class is hub-owned (locked decision). "" when not
// derivable (network targets have no local rotational flag).
ClassHint string `json:"class_hint"`
// Role is primary|vzdump-target|pbs-offsite|bulk-data when derivable from the existing
// storage definition; else "" (the manifest role is hub-owned, slice 10).
Role string `json:"role"`
// ThinPool carries the lvmthin DATA fill prominently — a full thin-pool corrupts every
// guest on it (the storage analog of single-node OOM). Present ONLY for lvmthin targets;
// metadata fill is null until Phase B's privileged `lvs` read.
ThinPool *ThinPoolFill `json:"thin_pool,omitempty"`
// Smart is the disk-health summary, populated in Phase B via smartctl (sudoers). In
// Phase A it is {health:"UNKNOWN", <counters null>} — the keys are committed now so the
// contract is stable across both phases.
Smart SmartSummary `json:"smart"`
}
// ThinPoolFill is the lvmthin pool fill (doc 03 §7). DataUsedFraction is the live data
// fill (used/total of the lvmthin store); MetadataUsedFraction needs the privileged
// `lvs` read (Phase B) and is null until then.
type ThinPoolFill struct {
DataUsedFraction float64 `json:"data_used_fraction"`
MetadataUsedFraction *float64 `json:"metadata_used_fraction"`
}
// SmartSummary is a read-only disk-health summary. Health is PASSED|FAILING|UNKNOWN.
// Counters are pointers so "unknown / not-applicable for this device type" (e.g. a USB
// bridge that exposes no SMART, or NVMe counters on a SATA disk) is null, distinct from a
// real zero. The SATA set (reallocated/pending/offline-uncorrectable) and the NVMe set
// (critical_warning/media_errors/percentage_used) are both carried; a device populates
// only its own set. Filled in Phase B.
type SmartSummary struct {
Health string `json:"health"`
TemperatureC *int `json:"temperature_c"`
PowerOnHours *int `json:"power_on_hours"`
// SATA attributes.
ReallocatedSectors *int `json:"reallocated_sectors"`
PendingSectors *int `json:"pending_sectors"`
OfflineUncorrectable *int `json:"offline_uncorrectable"`
// NVMe attributes.
CriticalWarning *int `json:"critical_warning"`
MediaErrors *int `json:"media_errors"`
PercentageUsed *int `json:"percentage_used"`
}
// SMART health constants (the reported vocabulary).
const (
SmartPassed = "PASSED"
SmartFailing = "FAILING"
SmartUnknown = "UNKNOWN"
)
// Storage target type + state constants (the reported vocabulary; doc 03 §7).
const (
StorageTypeLocalDir = "local-dir"
StorageTypeLVMThin = "lvmthin"
StorageTypeUSB = "usb"
StorageTypeNFS = "nfs"
StorageTypeCIFS = "cifs"
StorageTypePBS = "pbs"
StorageTypeLocal = "local" // builtin dir storage (PVE "local")
StorageStateAttached = "attached"
StorageStateDisconnected = "disconnected"
StorageStateDecommissioned = "decommissioned"
)
// Backup is the latest guest-vzdump result per target (doc 03 §8, slice 6 Phase A). An
// agent-initiated vzdump is CRASH-CONSISTENT only (no fsfreeze; app-consistency needs the
// controller quiesce, slice 8) — marked so here. UncoveredVolumes lists the guest's
// backup=0 (or backup-unset) mountpoints excluded from the vzdump — the bulk-volume DR gap
// (the bulk-backup mechanism is slice 10). Cross-repo contract: keep byte-identical with
// felhom.eu/hub and the bidirectional golden key-set test.
type Backup struct {
TargetID string `json:"target_id"` // backup storage name (e.g. "local")
VMID int `json:"vmid"` // source guest
Archive string `json:"archive"` // produced vzdump volid
Mode string `json:"mode"` // snapshot | stop
CrashConsistent bool `json:"crash_consistent"` // always true this slice
SizeBytes int64 `json:"size_bytes"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
StartedAt string `json:"started_at"` // RFC3339
DurationSeconds float64 `json:"duration_seconds"`
UncoveredVolumes []string `json:"uncovered_volumes"` // backup=0/unset mountpoints (bulk gap)
}
// RestoreTest is the latest self-restore-test result (doc 03 §8). This slice verifies
// boot+running only (deep app-health is slice 8). SourceTier is "local" here; PBS is Phase B.
type RestoreTest struct {
SourceArchive string `json:"source_archive"`
SourceTier string `json:"source_tier"` // "local" (pbs = Phase B)
ScratchVMID int `json:"scratch_vmid"`
Pass bool `json:"pass"`
Verified string `json:"verified"` // "boot+running" this slice
Error string `json:"error,omitempty"`
TestedAt string `json:"tested_at"` // RFC3339
DurationSeconds float64 `json:"duration_seconds"`
// Warnings are the guest-start task's warning line(s) (e.g. the systemd-nesting advisory).
// Present on a PASS that emitted warnings; pass/fail itself is liveness-only, so a passed
// restore-test can carry warnings. Omitted when there are none.
Warnings []string `json:"warnings,omitempty"`
// WarningsRecognized is true iff every Warnings line is the known-benign anchor. Omitted
// (⇒ false) when absent — and false is the SAFE default: the hub then treats it as an
// unrecognized warning (louder), so a missing flag can only over-notice, never hide.
WarningsRecognized bool `json:"warnings_recognized,omitempty"`
}
// PBSSnapshot is one PBS (offsite) snapshot's inventory + integrity state (doc 03 §8, slice
// 6 Phase B). Sourced from the PBS API (internal/pbs). `verify_state` is the load-bearing
// field — "none" until a verify runs, then "ok"/"failed" (a failed verify is the loudest
// offsite-DR signal). `encrypted` is derived from the snapshot's data crypt-mode
// (zero-knowledge: the PBS server can't read it). Cross-repo contract — byte-identical golden
// + bidirectional key-set test, the slice-5/6 pattern.
type PBSSnapshot struct {
Namespace string `json:"namespace"` // "root" = default ns
BackupType string `json:"backup_type"` // ct | vm
BackupID string `json:"backup_id"`
BackupTime string `json:"backup_time"` // RFC3339
SizeBytes int64 `json:"size_bytes"`
Owner string `json:"owner"`
Protected bool `json:"protected"`
Encrypted bool `json:"encrypted"`
VerifyState string `json:"verify_state"` // ok | failed | none
VerifyUPID string `json:"verify_upid,omitempty"`
}
type AuditEntry struct{} // audit-log tail entry fields TBD
// ControlEnvelope is the hub's 200 response to a host-report — the "Down" channel (slice 10A).
// It is a cheap change-notification on every heartbeat: the agent adopts PollIntervalSeconds,
// and when DesiredGeneration ADVANCES past its cached one it fetches the full desired-state from
// GET /hosts/{id}/desired-state (the heavy state moves only on change). HasSignedOps flags a
// non-empty signed-jobs queue (the agent fetches/executes them in 10B). Blocked stays reserved.
type ControlEnvelope struct {
Status string `json:"status"`
// PollIntervalSeconds is a pointer so a missing field (keep current interval) is
// distinguishable from an explicit 0.
PollIntervalSeconds *int `json:"poll_interval_seconds"`
Blocked bool `json:"blocked"` // reserved — ignored
DesiredGeneration int64 `json:"desired_generation"` // slice 10A: the cached-vs-current change signal
HasSignedOps bool `json:"has_signed_ops"` // slice 10A: signed-jobs queue non-empty (exec 10B)
}
// DesiredStateResponse is GET /hosts/{host_id}/desired-state (slice 10A — the "Down" channel's
// heavy payload, fetched only when the envelope's generation advances). Generation is the
// generation this state corresponds to, so the agent caches state+generation atomically. This is
// a cross-repo wire contract (DUPLICATED in felhom.eu/hub until a shared module exists); the
// desired-state golden stays byte-identical across the two repos.
type DesiredStateResponse struct {
Generation int64 `json:"generation"`
DesiredState WireDesiredState `json:"desired_state"`
}
// WireDesiredState is the hub's authoritative per-host target (slice 10A). The agent reconciles the
// parts it can today (guests: benign deltas reconciled, an explicit decommission gated
// pending_signature); the rest are FORWARD-COMPAT — carried + cached, NOT acted on in 10A. The
// restore_directive is consumed in 10D (host/guest-loss DR); storage_manifest / backup_policy /
// pbs_namespace are placeholders kept opaque so the wire is stable as those land.
type WireDesiredState struct {
Guests []WireDesiredGuest `json:"guests"`
StorageManifest json.RawMessage `json:"storage_manifest,omitempty"`
BackupPolicy json.RawMessage `json:"backup_policy,omitempty"`
PBSNamespace string `json:"pbs_namespace,omitempty"`
RestoreDirective *WireRestoreDirective `json:"restore_directive,omitempty"` // slice 10D (forward-compat)
}
// WireDesiredGuest is one guest's target (slice 10A). Every field is optional ("unmanaged"); the
// agent's planner acts only on the fields that are set. Run is running|stopped|""; Spec reuses
// GuestSpec (cores/memory_bytes/disk_bytes); Decommission is the EXPLICIT destructive delta (gated
// pending_signature in 10A — executor is 10B).
type WireDesiredGuest struct {
VMID int `json:"vmid"`
Run string `json:"run,omitempty"`
Spec *GuestSpec `json:"spec,omitempty"`
Description *string `json:"description,omitempty"`
Decommission bool `json:"decommission,omitempty"`
}
// WireRestoreDirective is the forward-compat restore directive (slice 10D — host/guest-loss DR).
// Defined now so the wire contract is stable; 10A carries it through to the cache but does NOT
// consume it (no restore is initiated from desired-state in 10A).
type WireRestoreDirective struct {
Mode string `json:"mode,omitempty"` // guest_loss | host_loss (10D vocabulary)
Archive string `json:"archive,omitempty"` // source archive/snapshot to restore from
VMID int `json:"vmid,omitempty"`
}