1c8a67eece
Both defects were live on both demo boxes: the recipe said namespace "root" while storage.cfg said demo-felhom/demo-hp, and it never named which of two content=backup dir storages holds the local archives. R-106: the namespace came from the listed snapshot, but PBS omits `ns` per item once the list is namespace-scoped, so it was always empty and normalised to "root". It now resolves from the pbs STORAGE (storage.cfg's `namespace`) — the same field vzdump makes PVE read, so the recipe cannot disagree with the backup. R-109: backup_target resolves from the primary tier of cfg.Backup.BackupTiers(), the function the scheduler consults, and carries the mountpoint that separates /mnt/hdd_1 from /var/lib/vz. The resolver reports the tier IN EFFECT (daemon-start config), not agent.json on disk — a target move rewrites the file and deliberately does not restart. Unresolvable is recorded as unresolvable: resolved|unknown plus a distinct reason, never a default, an empty string, or a placeholder. Needs hub v0.83.0 — AssembleDRRecipe allow-lists top-level keys, so backup_target would otherwise be stored intact and dropped before any operator saw it. 9 tests, 4 red-proofs (each mutation asserted to have landed). Suite rc=0, 29 ok.
583 lines
34 KiB
Go
583 lines
34 KiB
Go
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"`
|
||
|
||
// Wireguard is the offsite-tunnel status stanza (S3, doc 06 §4.6). Present only when the
|
||
// wg_tunnel feature is enabled. The report is stored opaquely hub-side, so no hub change is
|
||
// needed; the pubkey here is the operator's revocation-recovery handle (re-add the peer with
|
||
// it). Carries NO secret — the pubkey is public by definition.
|
||
Wireguard *WireguardStatus `json:"wireguard,omitempty"`
|
||
|
||
// SelfUpdatePending is true when an operator-signed agent self-update has flipped the binary
|
||
// but the new binary has not yet committed (TASK D1). SelfUpdatePendingVersion names the
|
||
// awaited version when pending. A runs-but-never-commits binary reports pending=true every
|
||
// heartbeat → the operator sees WHY the version isn't advancing; a crash-loop is auto-rolled
|
||
// back by systemd and this flips back to false when the good binary re-commits/clears. The
|
||
// report is stored opaquely hub-side, so these additive fields need no hub-schema change.
|
||
// Both are `omitempty` (the Wireguard precedent): in the steady state (no update in flight)
|
||
// they are absent — which keeps the cross-repo host-report golden contract byte-stable without
|
||
// a hub change. They appear only while an update is pending. The hub reads an absent field as
|
||
// pending=false, the correct default.
|
||
SelfUpdatePending bool `json:"selfupdate_pending,omitempty"`
|
||
SelfUpdatePendingVersion string `json:"selfupdate_pending_version,omitempty"`
|
||
|
||
// MgmtPlane is the management-plane health stanza (TASK G1). It reports whether OpenSSH's SHARED
|
||
// privilege-separation dir (/run/sshd) is present and whether the stock sshd listener answers, plus
|
||
// whether the agent-independent watchdog auto-healed a missing dir since boot (and when). This is
|
||
// the visibility half of the break-glass system: the dumb watchdog fixes /run/sshd with no login,
|
||
// and this stanza surfaces a RECURRING clobber to the operator BEFORE it becomes a lockout —
|
||
// complementing host_staleness (which only catches a box gone silent). `omitempty` (the
|
||
// SelfUpdatePending precedent): stored opaquely hub-side, so these additive fields need no
|
||
// hub-schema change and are absent when the reporter is not wired.
|
||
MgmtPlane *MgmtPlaneStatus `json:"mgmt_plane,omitempty"`
|
||
|
||
// PBSDR is the PBS-DR-tier bridge status stanza (slice 2). Present only when the pbsdr
|
||
// consumer is wired. `consumed_failed` is the LOUD persistent state: the one-time token
|
||
// secret was consumed but the apply failed afterwards — the secret is burned, the bridge
|
||
// will NOT silently retry, the operator must Re-issue on the hub. Stored opaquely hub-side
|
||
// (the Wireguard precedent) — additive, no hub-schema change; hub rendering joins in slice 3.
|
||
// Carries NO secret.
|
||
PBSDR *PBSDRStatus `json:"pbs_dr,omitempty"`
|
||
|
||
// GuestNet is the per-guest network-watchdog stanza (R-54). Present only when the guestnet
|
||
// watchdog is wired. Same additive/opaque contract as PBSDR and Wireguard above — no
|
||
// hub-schema change, absent when the reporter is not wired. Carries NO secret: addresses,
|
||
// route/liveness booleans, heal timestamps and counters only.
|
||
//
|
||
// NAMING NOTE (deliberate deviation from TASK-D, which called this block `WireGuestNet`):
|
||
// in this repo `Wire*` types are the DOWN direction (WireDesiredState/WirePBSDR — what the
|
||
// hub sends the agent), while UP-direction report stanzas are `*Status`. A `WireGuestNet`
|
||
// on HostReport would have been the only report block named against that convention.
|
||
GuestNet *GuestNetStatus `json:"guest_net,omitempty"`
|
||
|
||
// LogTail is the agent's on-demand debug-ring tail (v0.83.0 observability) — the agent
|
||
// mirror of the controller's report log_tails channel. Present ONLY on the heartbeat
|
||
// right after the control envelope requested it (log_tail_requested); consume-once on
|
||
// both ends (the hub clears its pending request on arrival). Newest lines kept, byte-
|
||
// capped loop-side. Carries log lines only — the logging conventions forbid secrets in
|
||
// any log line, and the hub's bundle gate re-checks before storing. `omitempty`: absent
|
||
// in the steady state, so the cross-repo host-report golden stays byte-stable.
|
||
LogTail *LogTail `json:"log_tail,omitempty"`
|
||
|
||
// OOB is the operator-access health stanza (TASK H1). It answers the operator's question — "can I
|
||
// get into this box right now, and if not, why" — from the hub: felhom-sshd up + on which port,
|
||
// locally reachable, the tunnel handshake age (the OOB path rides wg-felhom), whether the operator
|
||
// peer/key is configured, and whether the config is invalid. The heartbeat reaches the hub over
|
||
// HTTPS even when felhom-sshd or the tunnel is DOWN (channel independence). `omitempty`: absent
|
||
// when the feature is not wired (pre-H1) — additive, no hub-schema change.
|
||
OOB *OOBStatus `json:"oob,omitempty"`
|
||
}
|
||
|
||
// PBSDRStatus is the per-heartbeat PBS-DR-tier bridge state (slice 2). States:
|
||
// "adopted" (existing entry verified + reconciled, no consume), "applied" (fresh entry created,
|
||
// K born), "waiting_secret" (verified but no unconsumed secret staged — retrying),
|
||
// "verify_failed" (fingerprint/reachability pre-consume check failing — retrying, NOTHING
|
||
// consumed), "consumed_failed" (LOUD: secret burned, apply failed, no auto-retry — operator
|
||
// re-issue required), "disabled" (descriptor enabled:false). Carries no secret.
|
||
// GuestNetStatus is the R-54 guest-network watchdog stanza. `guests` carries one entry per owned
|
||
// RUNNING guest that has been probed at least once; an empty list with a fresh `checked_at` means
|
||
// the watchdog ran and found nothing to report, which is deliberately distinguishable from the
|
||
// stanza being absent (= the watchdog is not wired at all).
|
||
type GuestNetStatus struct {
|
||
CheckedAt string `json:"checked_at"` // RFC3339, the sweep this snapshot came from
|
||
Guests []GuestNetGuest `json:"guests,omitempty"`
|
||
}
|
||
|
||
// GuestNetGuest mirrors guestnet.GuestReport on the wire. The two structs are deliberately separate:
|
||
// internal/hub owns the wire contract and imports no feature package (the consumer-side seam rule).
|
||
type GuestNetGuest struct {
|
||
VMID int `json:"vmid"`
|
||
State string `json:"state"` // healthy | unhealthy | static_fault | unknown
|
||
Mode string `json:"mode"` // dhcp | static | unknown
|
||
IP string `json:"ip,omitempty"`
|
||
HasRoute bool `json:"has_route"`
|
||
DHClientAlive bool `json:"dhclient_alive"`
|
||
CheckedAt string `json:"checked_at,omitempty"`
|
||
Healed bool `json:"healed,omitempty"`
|
||
HealSucceeded bool `json:"heal_succeeded,omitempty"`
|
||
LastHealAt string `json:"last_heal_at,omitempty"`
|
||
HealsLastHour int `json:"heals_last_hour,omitempty"`
|
||
Damped bool `json:"damped,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
type PBSDRStatus struct {
|
||
State string `json:"state"`
|
||
StorageID string `json:"storage_id,omitempty"`
|
||
Namespace string `json:"namespace,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
ConsumedFailed bool `json:"consumed_failed,omitempty"`
|
||
AppliedAt string `json:"applied_at,omitempty"` // RFC3339; set on adopted/applied
|
||
// AuthFailed (R-39, v0.91.0) — the credential this box holds is REJECTED by PBS (401). Set by the
|
||
// verify loop's ProbeAuth, which before v0.91.0 could not run at all: the loop read the secret
|
||
// file directly as non-root and always failed with "permission denied", so an applied-and-dead
|
||
// tier was invisible to both tiers. The hub's pbsdrheal escalates state="auth_failed" to a fresh
|
||
// mint.
|
||
AuthFailed bool `json:"auth_failed,omitempty"`
|
||
}
|
||
|
||
// OOBStatus is the per-heartbeat operator-access health (TASK H1). Carries no secret.
|
||
type OOBStatus struct {
|
||
FelhomSshdActive bool `json:"felhom_sshd_active"` // the felhom-sshd unit is active
|
||
FelhomSshdPort int `json:"felhom_sshd_port"` // the claimed OOB port (0 = unclaimed)
|
||
Reachable bool `json:"reachable"` // a local TCP dial to the OOB port succeeds
|
||
ConfigInvalid bool `json:"config_invalid"` // `sshd -t` on the OOB config fails
|
||
OperatorPeerConfigured bool `json:"operator_peer_configured"` // oob_peer_ip present in desired-state
|
||
OperatorKeyConfigured bool `json:"operator_key_configured"` // operator authorized_key installed
|
||
WGHandshakeAgeS *int64 `json:"wg_handshake_age_s,omitempty"` // wg-felhom last handshake age (nil = unknown)
|
||
HealedAt string `json:"healed_at,omitempty"` // last felhom-sshd auto-heal (RFC3339)
|
||
}
|
||
|
||
// MgmtPlaneStatus is the per-heartbeat management-plane health (TASK G1). Carries no secret.
|
||
// HealedRecently is true while the watchdog's heal-marker exists (a privsep-dir heal happened this
|
||
// boot); PrivsepHealedAt is that marker's RFC3339 timestamp (absent when no heal has occurred). The
|
||
// hub raises a warning event on a PrivsepHealedAt it has not alerted on — a recurring auto-heal means
|
||
// a persistent clobber cause worth investigating before the box locks out.
|
||
type MgmtPlaneStatus struct {
|
||
PrivsepDirOK bool `json:"privsep_dir_ok"` // /run/sshd exists (the KEXINIT-reset detector)
|
||
SshdReachable bool `json:"sshd_reachable"` // the stock sshd listener accepts TCP
|
||
HealedRecently bool `json:"healed_recently"` // the watchdog heal-marker is present (this boot)
|
||
PrivsepHealedAt string `json:"privsep_healed_at,omitempty"` // marker timestamp; hub warns on a NEW value
|
||
}
|
||
|
||
// WireguardStatus is the per-heartbeat offsite-tunnel status (S3). LastHandshakeAgeS is nil when
|
||
// the handshake age is unreadable (service down, capability degraded) — nil ≠ 0.
|
||
type WireguardStatus struct {
|
||
Pubkey string `json:"pubkey"`
|
||
Registered bool `json:"registered"` // the registration marker exists
|
||
Active bool `json:"active"` // wg-quick@wg-felhom is-active
|
||
LastHandshakeAgeS *int64 `json:"last_handshake_age_s,omitempty"`
|
||
AssignedIP string `json:"assigned_ip,omitempty"` // from the marker, e.g. "10.77.0.2/32"
|
||
}
|
||
|
||
// HostMetrics is the host block, sourced from proxmox NodeStatus.
|
||
type HostMetrics struct {
|
||
Node string `json:"node"`
|
||
CPUPercent float64 `json:"cpu_percent"` // 0–100
|
||
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"`
|
||
// WrapperSHA256 is the sha256 of the installed PBS-DR apply wrapper
|
||
// (/usr/local/sbin/felhom-pbs-apply), R-50b(a), v0.91.0.
|
||
//
|
||
// That wrapper is root-owned 0755 and the pinned sudoers vector for the PBS storage verbs, yet it
|
||
// is installed from `raw/branch/main` — unversioned, unpinned and absent from the Day-0 artifact
|
||
// manifest. So "which wrapper is on this host?" had no answer: two hosts installed a week apart
|
||
// could carry different privileged code while reporting the same agent version. Reporting the hash
|
||
// does not fix the delivery channel (R-50b(b)/(c)); it makes drift VISIBLE.
|
||
//
|
||
// Empty = unreadable/absent, which the hub treats as UNKNOWN, never as drift.
|
||
WrapperSHA256 string `json:"wrapper_sha256,omitempty"`
|
||
}
|
||
|
||
// 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
|
||
// ConfigPath is the storage's CONFIGURED path from storage.cfg (proxmox.Storage.Path) — not a
|
||
// resolved mount. It is the only identity a dir storage keeps when its device is gone: MountPath
|
||
// and BackingDevice both empty out (observe.go's exactMount block) and DurableID degrades off the
|
||
// fs-UUID, so the configured path is what still says WHICH drive this row is about (R-116).
|
||
//
|
||
// `json:"-"` DELIBERATELY. This struct is a cross-repo contract duplicated in felhom.eu/hub and
|
||
// pinned by testdata/host-report.golden.json + contract_test.go's key-set comparison; a wire-visible
|
||
// field here would need a matching change in the other repo to stay non-drifting. Nothing off-box
|
||
// needs this value — its only consumer is the agent's own /disks construction, in-process.
|
||
ConfigPath string `json:"-"`
|
||
// PBSNamespace is the storage's CONFIGURED PBS namespace from storage.cfg (proxmox.Storage.Namespace)
|
||
// — "" for the root namespace, set for S4 per-customer tenancy. Present ONLY on pbs targets.
|
||
//
|
||
// It exists because storage.cfg is the ONE authority on which namespace this box's backups use:
|
||
// `vzdump --storage <pbs>` makes PVE read this exact field, and the agent's own verify client is
|
||
// built from it (`cmd/felhom-agent/main.go` → `pbs.Config{Namespace: s.Namespace}`). The DR recipe
|
||
// therefore resolves the namespace from HERE and not from a listed snapshot — a namespace-scoped
|
||
// PBS list does not echo `ns` per item, so the snapshot's own field is empty and normalising that
|
||
// empty to "root" is what made the recipe claim "root" on every per-customer box (R-106).
|
||
//
|
||
// `json:"-"` for the SAME reason as ConfigPath above: this struct is a cross-repo contract pinned by
|
||
// testdata/host-report.golden.json, and nothing off-box reads this value — its only consumer is the
|
||
// agent's own dr_recipe construction, in-process.
|
||
PBSNamespace string `json:"-"`
|
||
// 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"`
|
||
|
||
// ModelName is smartctl's own device model (v0.95.0), captured from the JSON already parsed, so
|
||
// the UI can show a human label ("TOSHIBA MQ04ABF100") instead of a raw UUID. omitempty +
|
||
// pointer: absent on an old agent or a device that reports no model.
|
||
ModelName *string `json:"model_name,omitempty"`
|
||
|
||
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"`
|
||
// MountParity ("ok"|"mismatch"; omitted on pre-v0.76.0 agents) + MountInventory (the verified
|
||
// mpN set) carry the GL-5b full-fidelity proof: the restored scratch matched the ARCHIVE's
|
||
// mount layout, not just booted. Additive — a hub that predates them ignores the unknown keys.
|
||
MountParity string `json:"mount_parity,omitempty"`
|
||
MountInventory []string `json:"mount_inventory,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)
|
||
// LogTailRequested (v0.83.0) — the operator wants this agent's debug-ring tail; the
|
||
// NEXT heartbeat carries it in log_tail (the report-channel log_tail_requests mirror).
|
||
// Absent/false on an old hub → nothing happens.
|
||
LogTailRequested bool `json:"log_tail_requested"`
|
||
}
|
||
|
||
// LogTail is the heartbeat's on-demand agent log tail (see HostReport.LogTail).
|
||
type LogTail struct {
|
||
CollectedAt string `json:"collected_at"` // RFC3339
|
||
Lines []string `json:"lines"`
|
||
}
|
||
|
||
// 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. The wireguard
|
||
// block (S3) is HUB-OWNED state merged into the served document at read time (hub S2) — consumed
|
||
// by internal/wgtunnel via the desired.Syncer raw-consumer seam.
|
||
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)
|
||
Wireguard *WireWireguard `json:"wireguard,omitempty"` // S3 (doc 06 §3.2; golden-pinned)
|
||
PBSDR *WirePBSDR `json:"pbs_dr,omitempty"` // PBS DR tier (slice 2 consumer)
|
||
}
|
||
|
||
// WirePBSDR is the hub's PBS-DR-tier descriptor (PBS DR slice 1, hub/internal/web/pbsdr.go
|
||
// pbsDRDescriptor — field-exact, cross-repo). NON-SECRET by contract: the token secret NEVER
|
||
// rides the desired-state; the agent fetches it consume-once via ConsumePBSToken. Absent/nil on
|
||
// pre-v0.44.0 hubs → the pbsdr consumer no-ops (old-hub compat).
|
||
type WirePBSDR struct {
|
||
Enabled bool `json:"enabled"`
|
||
StorageID string `json:"storage_id,omitempty"`
|
||
PBSTunnelIP string `json:"pbs_tunnel_ip,omitempty"`
|
||
Datastore string `json:"datastore,omitempty"`
|
||
Namespace string `json:"namespace,omitempty"`
|
||
TokenID string `json:"token_id,omitempty"`
|
||
Fingerprint string `json:"fingerprint,omitempty"`
|
||
// SecretGeneration (R-39, agent v0.91.0 / hub v0.68.0) is the hub's monotonic per-host counter,
|
||
// advanced by every fresh secret MINT. It carries no secret material — only the fact that one
|
||
// rotated.
|
||
//
|
||
// THIS FIELD IS THE RE-ARM SIGNAL, and it works only because descriptorHash marshals THIS STRUCT:
|
||
// an ep0 re-issue re-keys the secret of an existing token, so token_id, fingerprint, datastore and
|
||
// namespace all come back byte-identical. Without this field the descriptor never moves, the
|
||
// converged agent short-circuits, the fresh secret is never consumed, and the box serves a revoked
|
||
// credential while reporting `applied` (the N100, 2026-07-18).
|
||
//
|
||
// Corollary worth stating: an agent that does NOT carry this field drops the unknown JSON key and
|
||
// keeps today's behaviour exactly — inert, not broken. That is why hub v0.68.0 is safe to deploy
|
||
// ahead of the fleet, and why the re-arm guarantee needs agent >= 0.91.0.
|
||
SecretGeneration int64 `json:"secret_generation,omitempty"`
|
||
}
|
||
|
||
// WireWireguard is the hub-owned offsite-tunnel assignment (S3) — field-exact with the S2 golden
|
||
// (testdata/desired-state-wireguard.golden.json, byte-identical hub copy). Client-side
|
||
// AllowedIPs, PersistentKeepalive=25 and MTU 1280 are deliberately NOT wire fields — wgtunnel
|
||
// constants derived from endpoint.pbs_tunnel_ip + doc 06 §4.
|
||
type WireWireguard struct {
|
||
Endpoint WireWireguardEndpoint `json:"endpoint"`
|
||
Pubkey string `json:"pubkey"` // the box's registered pubkey
|
||
AssignedIP string `json:"assigned_ip"` // e.g. "10.77.0.2/32"
|
||
|
||
// OOBPeerIP is the operator OOB peer's tunnel address (bare IPv4, e.g. "10.77.0.250", TASK H1).
|
||
// When set, the agent appends it as a second /32 to the box's wg-felhom AllowedIPs so packets the
|
||
// endpoint forwards FROM the operator are accepted — RENDERED into the conf (not a runtime `wg
|
||
// set`) so it survives self-heal/restart/reboot ([OF-1]: a runtime widening was wiped by the
|
||
// agent's own self-heal). `omitempty`: absent = OOB off, byte-identical conf for pre-H1 hosts.
|
||
OOBPeerIP string `json:"oob_peer_ip,omitempty"`
|
||
|
||
// OOBOperatorSSHKey is the operator's SSH PUBLIC key (an authorized_keys line, TASK H1). The agent
|
||
// writes it to felhom-sshd's dedicated AuthorizedKeysFile (/etc/felhom-sshd/authorized_keys/{root,
|
||
// felhom-op}) — OUTSIDE ~/.ssh, so the customer's sshd never honours it [SF-3]. Hub-driven so it
|
||
// rotates fleet-wide. `omitempty`: absent = no operator login installed. NOT a secret (public key).
|
||
OOBOperatorSSHKey string `json:"oob_operator_ssh_key,omitempty"`
|
||
}
|
||
|
||
// WireWireguardEndpoint is the endpoint half of the wireguard block.
|
||
type WireWireguardEndpoint struct {
|
||
DNSName string `json:"dns_name"`
|
||
WGPort int `json:"wg_port"`
|
||
ServerPubkey string `json:"server_pubkey"`
|
||
PBSTunnelIP string `json:"pbs_tunnel_ip"`
|
||
}
|
||
|
||
// 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"`
|
||
}
|