diff --git a/cmd/pulse-agent-helper/main_test.go b/cmd/pulse-agent-helper/main_test.go index b31a5b00fe..21dbec560a 100644 --- a/cmd/pulse-agent-helper/main_test.go +++ b/cmd/pulse-agent-helper/main_test.go @@ -340,3 +340,45 @@ func TestRunFailsClosedOutsideLinux(t *testing.T) { t.Fatalf("run error = %v", err) } } + +func TestLocalProxmoxProviderV2KeepsPartialRowsAndV1FailsClosed(t *testing.T) { + inventory := &agentshost.ProxmoxLXCInventory{ + Status: agentshost.ProxmoxLXCCollectionPartial, + Containers: []agentshost.ProxmoxLXCContainer{{VMID: 100, Name: "web", Disks: []agentshost.Disk{{Mountpoint: "/", TotalBytes: 4096}}}}, + OmittedVMIDs: []int{102}, + } + provider := localProxmoxProvider{collect: func(context.Context) hostagent.ProxmoxLXCFilesystemCollectionResult { + return hostagent.ProxmoxLXCFilesystemCollectionResult{Applicable: true, Inventory: inventory, Degraded: true, FailedContainers: 1} + }} + if data, err := provider.LXCFilesystems(t.Context()); err == nil || data != nil { + t.Fatal("v1 returned incomplete inventory") + } + data, err := provider.LXCFilesystemsV2(t.Context()) + if err != nil { + t.Fatal(err) + } + var got struct { + Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"` + } + if err := json.Unmarshal(data, &got); err != nil || got.Inventory.Status != agentshost.ProxmoxLXCCollectionPartial || len(got.Inventory.Containers) != 1 || got.Inventory.OmittedVMIDs[0] != 102 { + t.Fatalf("v2=%s, error=%v", data, err) + } + inventory.Status = agentshost.ProxmoxLXCCollectionComplete + if data, err := provider.LXCFilesystemsV2(t.Context()); err == nil || data != nil { + t.Fatal("contradictory partial inventory accepted") + } +} + +func TestLocalProxmoxProviderV1RetainsStrictLegacyShape(t *testing.T) { + inventory := &agentshost.ProxmoxLXCInventory{Status: agentshost.ProxmoxLXCCollectionComplete} + provider := localProxmoxProvider{collect: func(context.Context) hostagent.ProxmoxLXCFilesystemCollectionResult { + return hostagent.ProxmoxLXCFilesystemCollectionResult{Applicable: true, Inventory: inventory} + }} + data, err := provider.LXCFilesystems(t.Context()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), `"status"`) || strings.Contains(string(data), `"omittedVmids"`) { + t.Fatalf("v1 shape changed: %s", data) + } +} diff --git a/cmd/pulse-agent-helper/providers.go b/cmd/pulse-agent-helper/providers.go index 66a7a772c6..ddef78a552 100644 --- a/cmd/pulse-agent-helper/providers.go +++ b/cmd/pulse-agent-helper/providers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" @@ -38,18 +39,45 @@ type localProxmoxProvider struct { } func (p localProxmoxProvider) LXCFilesystems(ctx context.Context) (json.RawMessage, error) { + return p.lxcFilesystems(ctx, false) +} + +func (p localProxmoxProvider) LXCFilesystemsV2(ctx context.Context) (json.RawMessage, error) { + return p.lxcFilesystems(ctx, true) +} + +func (p localProxmoxProvider) lxcFilesystems(ctx context.Context, allowPartial bool) (json.RawMessage, error) { collect := p.collect if collect == nil { collect = hostagent.CollectProxmoxLXCFilesystemsLocalResult } collection := collect(ctx) - if collection.Degraded { + if collection.Degraded && (!allowPartial || collection.Inventory == nil) { return nil, &agenthelper.ProviderError{ Code: agenthelper.ErrorProviderUnavailable, Message: "Proxmox LXC filesystem inventory is incomplete", Retryable: true, } } + if collection.Inventory != nil { + if err := collection.Inventory.ValidateCollection(); err != nil || + (allowPartial && collection.Inventory.Status == "") || + (collection.Degraded != (collection.Inventory.Status == agentshost.ProxmoxLXCCollectionPartial)) || + collection.FailedContainers != len(collection.Inventory.OmittedVMIDs) { + return nil, &agenthelper.ProviderError{Code: agenthelper.ErrorProviderUnavailable, Message: "Proxmox LXC filesystem inventory has invalid completeness", Retryable: true} + } + } + // V1 decoders are strict: do not add even complete-status fields to their + // response. A partial result never reaches an older collector as healthy. + if !allowPartial && collection.Inventory != nil { + legacy := struct { + Containers []agentshost.ProxmoxLXCContainer `json:"containers"` + CollectedAt time.Time `json:"collectedAt"` + }{collection.Inventory.Containers, collection.Inventory.CollectedAt} + return json.Marshal(struct { + Inventory any `json:"inventory"` + }{legacy}) + } result, err := json.Marshal(struct { Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"` }{Inventory: collection.Inventory}) diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 20b5bf4cff..4151d25d51 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -179,6 +179,11 @@ snapshots, not a shell, executable path, device path, VMID, daemon endpoint, environment, or caller-selected arguments. The helper service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`, `NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`. +The private network remains required on every architecture. In particular, +systemd does not enforce `RestrictAddressFamilies` on native 32-bit x86; +`SystemCallArchitectures=native` does not change that. The address-family +property is not, by itself, proof that IP socket creation is denied. Do not +remove `PrivateNetwork` to work around a telemetry failure. It also bounds the helper cgroup to `TasksMax=64`, `LimitNOFILE=256`, and `MemoryMax=256M`. These limits leave headroom for the typed helper and its short-lived platform tools while preventing an overridden or malformed request @@ -188,6 +193,22 @@ block devices. If the helper is missing, incompatible, or rejects a request, only the affected telemetry disappears; the collector does not fall back to sudo, root, or a broader local command path. +Proxmox's `pct list` uses pmxcfs abstract Unix IPC, which is not reachable from +the helper's private network namespace. On affected PVE hosts this leaves +helper-backed LXC filesystem telemetry unavailable, while independent SMART +and API monitoring can continue. Prefer the existing Proxmox API connection +for the readings it provides; do not add a unit override or a root/sudo +fallback. A successful SMART reading does not establish LXC helper health. + +When an inventory succeeds only partly, operation v2 preserves the collected +rows and explicitly names omitted container IDs. The helper module stays +degraded, and omitted containers cannot refresh old filesystem readings. +Operation v1 remains complete-only; a newer collector uses it only when an +older helper explicitly rejects v2. An unavailable inventory is not an empty +healthy snapshot or zero disk usage. An installer-managed helper upgrade +restarts the verified helper after checking the effective isolated unit and +socket identity; a collector-only automatic update cannot replace its helper. + The installer treats the effective systemd configuration as part of the safe profile boundary. The collector, helper service, helper socket, and an installed action runner must load from the installer-owned `FragmentPath` with @@ -279,7 +300,7 @@ or generic command path. | Proxmox VE/PBS/PMG inventory, status, storage, and ordinary metrics | API-only connection with a narrowly scoped token; no host agent | No collector, helper, or runner authority is required for this data | Supported independently of the safe host-agent profile; it does not prove host-local SMART, LXC filesystem, or action parity | `agent-lifecycle`: keep API permissions and returned telemetry covered by provider tests | | Standard Linux systemd host telemetry and collector update | Unprivileged `pulse-agent` plus the root-owned typed helper | Core `/proc`, filesystem, network, RAID, and hwmon telemetry stays in the collector; signed updates cross the fixed helper protocol and commit only after an authoritative report | **Qualified on disposable Ubuntu 24.04.4 arm64 at committed main `22fd662fb794f63efb9d3ca2158de73c4e07e1b8`** across the schema-v6 twenty-scenario run. It covers install, migration, explicit/automatic rollback, helper protocol and resource boundaries, effective-unit override rejection, reporting continuity, authoritative executable-digest commit, watchdog rollback, helper-restart recovery, and last-known-good restoration. It remains opt-in pending exact-RC reproduction, representative provider/appliance parity, and external review | `deployment-installability` and `security-privacy`: reproduce the same helper transaction from trusted release artifacts on representative hosts and accept the external boundary review | | Linux SMART telemetry | `smart.snapshot` through the no-network helper; no caller-selected device or arguments | Implemented, unqualified on representative physical disks. Helper failure omits/degrades SMART only; the collector does not retry as root | Does not yet justify SMART parity or a default change | `agent-lifecycle`: record live SATA, SAS/controller, USB bridge, and NVMe evidence, including standby, permission failure, timeout, and partial-data cases | -| Proxmox node-local LXC filesystem telemetry | `proxmox.lxc_filesystems` through the no-network helper using fixed bounded `pct` operations | Implemented, unqualified on a representative PVE node. Helper failure omits/degrades this snapshot only | Does not yet justify Proxmox host-agent parity or a default change | `agent-lifecycle`: record live running/stopped LXC, mount, timeout, output-bound, and helper-loss behavior on supported PVE versions | +| Proxmox node-local LXC filesystem telemetry | `proxmox.lxc_filesystems` through the isolated helper using fixed bounded `pct` operations; API monitoring where sufficient | Unavailable on hosts where pmxcfs abstract IPC is hidden by the private namespace. V2 preserves explicitly partial results when collection can proceed; global failure omits/degrades this snapshot only | Implemented, unqualified. No PVE recovery, host-IPC exception, or default change is established | `agent-lifecycle`: repair the IPC-dependent path without weakening isolation, then record live running/stopped LXC, partial/recovery, mount, timeout, output-bound, and helper-loss behavior on supported PVE versions | | Rootful Docker or Podman inventory | No direct collector access to a root-equivalent daemon socket | Implemented as a typed-helper summary-only fallback. Migration preserves container ID/name/image/state/status/creation inventory and marks the report `typed-helper-summary`; stats, secondary inventories, update checks, and actions remain unavailable | The standalone rootless packet exercises live same-family rootful Docker and Podman helper fallback plus summary-inventory semantic parity during socket loss. It does not qualify the complete standalone rootful fresh-install, migration, restart, helper-loss/recovery, and bounds matrix. Full rootful parity remains an explicit default blocker; the legacy/root profile is not safe-profile evidence | `agent-lifecycle`: record the remaining standalone lifecycle matrix on representative rootful Docker and Podman and decide explicitly whether reduced telemetry is sufficient | | Collector-owned rootless Docker or Podman | Direct access only to one usable runtime socket owned by the `pulse-agent` UID whose daemon also attests rootless mode | Qualified locally for the exact source-bound monitoring path. Discovery is deferred until the collector account exists and accepts exactly one readable/writable, non-symlink socket owned by that UID across Docker and Podman. Cross-user, root-owned, remote, unreadable, non-rootless-daemon, or ambiguous endpoints are rejected. An installer-owned exact pin survives daemon loss and update; the running collector can move between direct rootless monitoring and rootful typed-helper summary without regaining action authority | **Qualified on distinct disposable Ubuntu 24.04/systemd 255 arm64 hosts at commit `60041ad9e60c282c892f944e04f777b874991a5d`.** All eleven canonical scenarios passed for Docker and all eleven for Podman. Receipt SHA-256: `7a116d63ab0cd1560482165055f0a8c9158ce0a8333a27bd9e8256582a52dfb5`; local attestation SHA-256: `566279ecd7d7dfa89c92f24243e9fcd5ae3e295d4284bd559d4be42f1e83b3b5`. This is local opt-in artifact-bound self-attestation, not published-RC provenance, action/update qualification, external review, or authorization for a default change | `deployment-installability`: reproduce the exact packet from published release artifacts; separately qualify any desired rootless image-update/action surface; retain the opt-in default until the remaining provider, appliance, and review conditions pass | | Separate runner package update and package-cache cleanup | Root-owned `pulse-agent-runner`, host-bound action credential, typed request, postcondition, and durable receipt | The schema-v6 committed-main systemd receipt records a real verified apt-cache mutation, stale-fingerprint refusal, replay, nonce-bound readiness, effective-unit override rejection, exact credential rotation, bodyless self-revocation, and collector/helper continuity. A separate production Router regression exercises HTTPS issuance, WSS admission, encrypted token persistence, failed-rotation rollback, exact socket invalidation, two server restarts, old-secret rejection, and durable self-revoke | Qualified for the exercised systemd fixture paths and focused production Router lifecycle. Focused installer tests also cover atomic pending cancellation and fail-closed credential retention. The evidence does not cover every runner operation, representative providers, or an exact release candidate | `agent-lifecycle` and `api-contracts`: reproduce the combined systemd and production Router path from trusted RC artifacts with representative package-update success/failure/cancellation evidence | diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e64451bebf..2fe7daa645 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -857,6 +857,8 @@ installer download and the agent's subsequent Pulse TLS connection. ## Shared Boundaries +Proxmox helper operation v2 carries the shared `ProxmoxLXCInventory` completeness and bounded omitted-VMID contract. V1 stays strict complete-only. The collector tries v1 only after an explicit unsupported-v2 response, under one unchanged operation deadline; transport, authorization and provider failures never select a broader fallback. + The shared monitor constructor also composes notification bootstrap: it loads saved alert policy and destinations, binds the alert owner's immutable quiet-hours policy provider, then activates persistent queue delivery. @@ -3163,6 +3165,8 @@ traverse an agent. ## Completion Obligations +`cmd/pulse-agent-helper/main_test.go`, `internal/agenthelper/server_test.go`, `internal/hostagent/privilege_helper_client_test.go` and `pkg/agents/host/proxmox_lxc_test.go` must cover version negotiation, successful partial rows, all-failed/global-unavailable distinction, contradictory/overbound identities, degraded health and independent operation recovery. `scripts/installtests/safe_profile_migration_test.go` covers installer-managed helper replacement after effective-unit/socket validation and fatal restart failure. These source controls do not prove an installed upgrade or PVE recovery. + ### VM command results describe the guest, not transport success A verified terminal `qm guest exec` dictionary must project its guest exit or @@ -3571,6 +3575,8 @@ Agent` secondary handoff against the live setup wizard instead of relying ## Current State +The partial-result path no longer discards every successful row when another guest fails. The helper remains in its private network on every platform; pmxcfs abstract IPC is still unavailable there on the reported PVE estate (#2511). That defect is not fixed by the partial protocol (#2513), and API monitoring remains the safe existing alternative. The earlier host-network proposal is not part of this implementation. + ### VM guest execution admission (backup precaution) `AgentRegisterPayload.guest_exec_guard_version` is optional; version 1 on a diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 703c05d3d1..8f9836a8ef 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -980,6 +980,8 @@ release-latency optimization. ## Shared Boundaries +Installer-managed helper upgrades restart the verified executable after effective isolated-unit and fixed socket-identity validation. Daemon reload or enabling an already-active socket alone does not replace an old process. Failure to restart aborts the installer and uses the existing safe-profile recovery transaction; collector-only updates still cannot replace the helper or unit. + `frontend-modern/src/utils/localStorage.ts` is a shared browser-preference key registry, not deployment state. Workload presentation preferences added there, including the Proxmox guest-memory comparison basis and per-surface workload @@ -2591,6 +2593,8 @@ artifact-selection behaviour. ## Completion Obligations +`scripts/installtests/safe_profile_migration_test.go` covers activation ordering, old-helper replacement, effective-unit failure, restart failure and fixed-target refusal in a disposable shell harness, alongside isolation rendering/admission for 386 and unknown platforms. Actual active-helper forward/reverse migration, protected state/identity and prior-active restoration still require authorized native execution; shell stubs are not that proof. + 1. Update this contract when canonical deployment or installer entry points move 2. Keep deployment runtime and shared API proof routing aligned in `registry.json` 3. Preserve explicit coverage for installer parity, update planning, and deployment bootstrap behavior when these surfaces change. Shell installer update recovery changes must keep `scripts/installtests/install_sh_test.go` covering both persisted `connection.env` recovery and legacy running-process/service recovery across Linux and FreeBSD/rc.d, including single-dash v5 agent flags, non-procfs process inspection, and the rule that upgraded service args use `--token-file` instead of raw `--token`. @@ -2716,6 +2720,8 @@ artifact-selection behaviour. ## Current State +The helper remains network-isolated on all targets, and restart activates the installer-owned replacement rather than retaining old code after reload. Published security guidance discloses the PVE IPC limitation and partial-result semantics. This is not a published release or installed PVE acceptance. + ### Demo runtime footprint qualification The demo transaction executes no general installer. It verifies the activated diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 0e47f9e058..9c44675ed7 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1671,6 +1671,8 @@ cleanup so readers cannot retain orphaned runtime or alert projections. ## Shared Boundaries +Helper-backed Proxmox inventories carry explicit complete/partial status and omitted VMIDs. Before cache mutation the monitor validates this shared contract. A complete inventory replaces only the exact linked-node/agent cache; a partial inventory removes that agent's explicitly omitted guests without renewing their data. Legacy reports retain legacy row admission and expiry. + PBS polling owns the internal `PBSInstance.NodeMetricsUnavailable` discriminator: each poll starts unavailable and only a successful non-nil node-status result clears it. A denied or failed node-status endpoint does not invalidate successful @@ -2182,6 +2184,8 @@ service-history reads plus denial/recovery without fabricated samples. ## Completion Obligations +`internal/monitoring/monitor_host_agents_test.go` and `pkg/agents/host/proxmox_lxc_test.go` must prove JSON wire-report ingestion through `ApplyHostReport`, partial/complete-empty recovery, contradictory IDs, unchanged node/agent/name/TTL protection, and no invalid-list-to-healthy-empty conversion. Omitted telemetry is never zero. Source proofs are distinct from native PVE and released availability. + ### Discovery accepts successful command evidence only Only stdout from a successful result with zero exit status and no error enters @@ -2334,6 +2338,8 @@ truthfulness, not native thaw, containing-release or workload acceptance. ## Current State +V2 partial inventories preserve usable filesystem readings while leaving typed-helper health degraded. Global inventory failure still yields no inventory. No host-network exception, guest mutation, database/retention change or temperature substitution accompanies this repair. + ### Discovery observes VM guest execution pauses Verified VM results describe the guest's terminal status and decoded output, diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 4c50bb5e03..57d46e2885 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -294,6 +294,8 @@ invisible to operators reviewing exactly what Pulse sends. ## Shared Boundaries +The generated typed helper and effective-unit admission retain `PrivateNetwork=true` on every platform, including native linux-386 and unknown architectures. `RestrictAddressFamilies=AF_UNIX` is additional hardening, not a portable proof of IP socket denial: systemd ignores this filter on native 32-bit x86. `SystemCallArchitectures=native` does not repair that ABI limitation. + - Configured local-admin synchronisation is a runtime identity replacement, not an additive role grant: changing the configured identity removes the previous bypass, and clearing it during first-run reset clears that bypass. Configurable authorizers must synchronise identity updates with concurrent authorization reads; other users still require their own policy grants. ### Notification recovery reload ownership @@ -827,12 +829,16 @@ in `docs/PRIVACY.md` and `frontend-modern/public/docs/PRIVACY.md`, which ## Forbidden Paths +Removing the helper's private network globally or for an unproved platform in order to reach pmxcfs is forbidden. Partial telemetry does not justify root/sudo fallback, caller-selected commands or paths, token exposure, wider socket families, or a healthy empty inventory after a failed list. + 1. Changing telemetry payload semantics without updating the canonical privacy disclosure. 2. Letting security-facing settings copy or privacy guarantees drift between runtime behavior and the governed docs. 3. Treating API token management, auth posture, or telemetry controls as generic settings-shell polish instead of explicit trust-surface behavior. ## Completion Obligations +`scripts/installtests/safe_profile_migration_test.go` renders every shipped helper architecture plus an unknown case for PVE/non-PVE and rejects host/unknown effective network values; `scripts/installtests/secure_runtime_platform_matrix_test.go` binds truthful shipped documentation. These are renderer/admission regressions, not kernel enforcement proof. Any future host-IPC exception requires source-bound actual generated-child IPC success and IPv4/IPv6 creation denials with unsandboxed controls, plus owned active migration/rollback and identity evidence before acceptance. + Split-port agent exposure is complete only when its exact allowlist covers report/config routes, command WebSocket admission, version/server bootstrap, and the supported installer/download endpoints while every UI and management @@ -960,6 +966,8 @@ tokens, and path-normalization variants. ## Current State +This repair preserves the existing isolation boundary and introduces no host-IPC grant. Successful partial filesystem rows retain explicit completeness and degraded health; sanitized status exposes no raw provider errors. Native PVE recovery remains unproved. No old v6/v7 receipt is reinterpreted and no v8 qualification layer is introduced. + ### Container diagnostics keeps copied material credential-free (1 October 2026) Container migration-token issuance returns its secret separately on the diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md index 20b5bf4cff..4151d25d51 100644 --- a/frontend-modern/public/docs/AGENT_SECURITY.md +++ b/frontend-modern/public/docs/AGENT_SECURITY.md @@ -179,6 +179,11 @@ snapshots, not a shell, executable path, device path, VMID, daemon endpoint, environment, or caller-selected arguments. The helper service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`, `NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`. +The private network remains required on every architecture. In particular, +systemd does not enforce `RestrictAddressFamilies` on native 32-bit x86; +`SystemCallArchitectures=native` does not change that. The address-family +property is not, by itself, proof that IP socket creation is denied. Do not +remove `PrivateNetwork` to work around a telemetry failure. It also bounds the helper cgroup to `TasksMax=64`, `LimitNOFILE=256`, and `MemoryMax=256M`. These limits leave headroom for the typed helper and its short-lived platform tools while preventing an overridden or malformed request @@ -188,6 +193,22 @@ block devices. If the helper is missing, incompatible, or rejects a request, only the affected telemetry disappears; the collector does not fall back to sudo, root, or a broader local command path. +Proxmox's `pct list` uses pmxcfs abstract Unix IPC, which is not reachable from +the helper's private network namespace. On affected PVE hosts this leaves +helper-backed LXC filesystem telemetry unavailable, while independent SMART +and API monitoring can continue. Prefer the existing Proxmox API connection +for the readings it provides; do not add a unit override or a root/sudo +fallback. A successful SMART reading does not establish LXC helper health. + +When an inventory succeeds only partly, operation v2 preserves the collected +rows and explicitly names omitted container IDs. The helper module stays +degraded, and omitted containers cannot refresh old filesystem readings. +Operation v1 remains complete-only; a newer collector uses it only when an +older helper explicitly rejects v2. An unavailable inventory is not an empty +healthy snapshot or zero disk usage. An installer-managed helper upgrade +restarts the verified helper after checking the effective isolated unit and +socket identity; a collector-only automatic update cannot replace its helper. + The installer treats the effective systemd configuration as part of the safe profile boundary. The collector, helper service, helper socket, and an installed action runner must load from the installer-owned `FragmentPath` with @@ -279,7 +300,7 @@ or generic command path. | Proxmox VE/PBS/PMG inventory, status, storage, and ordinary metrics | API-only connection with a narrowly scoped token; no host agent | No collector, helper, or runner authority is required for this data | Supported independently of the safe host-agent profile; it does not prove host-local SMART, LXC filesystem, or action parity | `agent-lifecycle`: keep API permissions and returned telemetry covered by provider tests | | Standard Linux systemd host telemetry and collector update | Unprivileged `pulse-agent` plus the root-owned typed helper | Core `/proc`, filesystem, network, RAID, and hwmon telemetry stays in the collector; signed updates cross the fixed helper protocol and commit only after an authoritative report | **Qualified on disposable Ubuntu 24.04.4 arm64 at committed main `22fd662fb794f63efb9d3ca2158de73c4e07e1b8`** across the schema-v6 twenty-scenario run. It covers install, migration, explicit/automatic rollback, helper protocol and resource boundaries, effective-unit override rejection, reporting continuity, authoritative executable-digest commit, watchdog rollback, helper-restart recovery, and last-known-good restoration. It remains opt-in pending exact-RC reproduction, representative provider/appliance parity, and external review | `deployment-installability` and `security-privacy`: reproduce the same helper transaction from trusted release artifacts on representative hosts and accept the external boundary review | | Linux SMART telemetry | `smart.snapshot` through the no-network helper; no caller-selected device or arguments | Implemented, unqualified on representative physical disks. Helper failure omits/degrades SMART only; the collector does not retry as root | Does not yet justify SMART parity or a default change | `agent-lifecycle`: record live SATA, SAS/controller, USB bridge, and NVMe evidence, including standby, permission failure, timeout, and partial-data cases | -| Proxmox node-local LXC filesystem telemetry | `proxmox.lxc_filesystems` through the no-network helper using fixed bounded `pct` operations | Implemented, unqualified on a representative PVE node. Helper failure omits/degrades this snapshot only | Does not yet justify Proxmox host-agent parity or a default change | `agent-lifecycle`: record live running/stopped LXC, mount, timeout, output-bound, and helper-loss behavior on supported PVE versions | +| Proxmox node-local LXC filesystem telemetry | `proxmox.lxc_filesystems` through the isolated helper using fixed bounded `pct` operations; API monitoring where sufficient | Unavailable on hosts where pmxcfs abstract IPC is hidden by the private namespace. V2 preserves explicitly partial results when collection can proceed; global failure omits/degrades this snapshot only | Implemented, unqualified. No PVE recovery, host-IPC exception, or default change is established | `agent-lifecycle`: repair the IPC-dependent path without weakening isolation, then record live running/stopped LXC, partial/recovery, mount, timeout, output-bound, and helper-loss behavior on supported PVE versions | | Rootful Docker or Podman inventory | No direct collector access to a root-equivalent daemon socket | Implemented as a typed-helper summary-only fallback. Migration preserves container ID/name/image/state/status/creation inventory and marks the report `typed-helper-summary`; stats, secondary inventories, update checks, and actions remain unavailable | The standalone rootless packet exercises live same-family rootful Docker and Podman helper fallback plus summary-inventory semantic parity during socket loss. It does not qualify the complete standalone rootful fresh-install, migration, restart, helper-loss/recovery, and bounds matrix. Full rootful parity remains an explicit default blocker; the legacy/root profile is not safe-profile evidence | `agent-lifecycle`: record the remaining standalone lifecycle matrix on representative rootful Docker and Podman and decide explicitly whether reduced telemetry is sufficient | | Collector-owned rootless Docker or Podman | Direct access only to one usable runtime socket owned by the `pulse-agent` UID whose daemon also attests rootless mode | Qualified locally for the exact source-bound monitoring path. Discovery is deferred until the collector account exists and accepts exactly one readable/writable, non-symlink socket owned by that UID across Docker and Podman. Cross-user, root-owned, remote, unreadable, non-rootless-daemon, or ambiguous endpoints are rejected. An installer-owned exact pin survives daemon loss and update; the running collector can move between direct rootless monitoring and rootful typed-helper summary without regaining action authority | **Qualified on distinct disposable Ubuntu 24.04/systemd 255 arm64 hosts at commit `60041ad9e60c282c892f944e04f777b874991a5d`.** All eleven canonical scenarios passed for Docker and all eleven for Podman. Receipt SHA-256: `7a116d63ab0cd1560482165055f0a8c9158ce0a8333a27bd9e8256582a52dfb5`; local attestation SHA-256: `566279ecd7d7dfa89c92f24243e9fcd5ae3e295d4284bd559d4be42f1e83b3b5`. This is local opt-in artifact-bound self-attestation, not published-RC provenance, action/update qualification, external review, or authorization for a default change | `deployment-installability`: reproduce the exact packet from published release artifacts; separately qualify any desired rootless image-update/action surface; retain the opt-in default until the remaining provider, appliance, and review conditions pass | | Separate runner package update and package-cache cleanup | Root-owned `pulse-agent-runner`, host-bound action credential, typed request, postcondition, and durable receipt | The schema-v6 committed-main systemd receipt records a real verified apt-cache mutation, stale-fingerprint refusal, replay, nonce-bound readiness, effective-unit override rejection, exact credential rotation, bodyless self-revocation, and collector/helper continuity. A separate production Router regression exercises HTTPS issuance, WSS admission, encrypted token persistence, failed-rotation rollback, exact socket invalidation, two server restarts, old-secret rejection, and durable self-revoke | Qualified for the exercised systemd fixture paths and focused production Router lifecycle. Focused installer tests also cover atomic pending cancellation and fail-closed credential retention. The evidence does not cover every runner operation, representative providers, or an exact release candidate | `agent-lifecycle` and `api-contracts`: reproduce the combined systemd and production Router path from trusted RC artifacts with representative package-update success/failure/cancellation evidence | diff --git a/frontend-modern/scripts/sync-public-docs.mjs b/frontend-modern/scripts/sync-public-docs.mjs index 3e06b8815f..3c077a0b69 100644 --- a/frontend-modern/scripts/sync-public-docs.mjs +++ b/frontend-modern/scripts/sync-public-docs.mjs @@ -17,6 +17,7 @@ const shippedDocs = [ { source: path.join(repoRoot, 'docs', 'PRIVACY.md'), target: 'PRIVACY.md' }, { source: path.join(repoRoot, 'docs', 'CONFIGURATION.md'), target: 'CONFIGURATION.md' }, { source: path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), target: 'UNIFIED_AGENT.md' }, + { source: path.join(repoRoot, 'docs', 'AGENT_SECURITY.md'), target: 'AGENT_SECURITY.md' }, { source: path.join(repoRoot, 'docs', 'PROXY_AUTH.md'), target: 'PROXY_AUTH.md' }, { source: path.join(repoRoot, 'docs', 'TRUENAS.md'), target: 'TRUENAS.md' }, { source: path.join(repoRoot, 'SECURITY.md'), target: 'SECURITY.md' }, diff --git a/internal/agenthelper/protocol.go b/internal/agenthelper/protocol.go index 1a5f2dc36b..cda9c716c8 100644 --- a/internal/agenthelper/protocol.go +++ b/internal/agenthelper/protocol.go @@ -43,6 +43,7 @@ const ( OperationAgentUpdateCommit = "agent_update.commit" OperationAgentUpdateRollback = "agent_update.rollback" OperationVersion1 = 1 + OperationVersion2 = 2 ) // Request is the common envelope for one local helper operation. Payload is diff --git a/internal/agenthelper/providers.go b/internal/agenthelper/providers.go index eb523ccaf1..71234d6a4a 100644 --- a/internal/agenthelper/providers.go +++ b/internal/agenthelper/providers.go @@ -20,6 +20,12 @@ type ProxmoxProvider interface { LXCFilesystems(context.Context) (json.RawMessage, error) } +// ProxmoxProviderV2 explicitly distinguishes successful partial collection +// from a complete inventory. V1 remains complete-only for older collectors. +type ProxmoxProviderV2 interface { + LXCFilesystemsV2(context.Context) (json.RawMessage, error) +} + // ContainerProvider owns a complete, bounded inventory of the helper's fixed // local Docker and Podman endpoints. The protocol supplies no socket, URL, // daemon method, or query fields. diff --git a/internal/agenthelper/registry.go b/internal/agenthelper/registry.go index 244a81ec62..ba30681ba0 100644 --- a/internal/agenthelper/registry.go +++ b/internal/agenthelper/registry.go @@ -92,6 +92,17 @@ func NewRegistryWithProviders(smart SMARTProvider, proxmox ProxmoxProvider, prov result, err := proxmox.LXCFilesystems(ctx) return validateProviderResult(result, err) }) + proxmoxV2, supportsV2 := proxmox.(ProxmoxProviderV2) + if supportsV2 { + registry.add(OperationProxmoxLXCFilesystems, OperationVersion2, true, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) { + var request emptyOperationRequest + if err := decodePayload(payload, &request); err != nil { + return nil, invalidPayloadError(err) + } + result, err := proxmoxV2.LXCFilesystemsV2(ctx) + return validateProviderResult(result, err) + }) + } registry.add(OperationContainerInventory, OperationVersion1, providers.Containers != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) { var request emptyOperationRequest if err := decodePayload(payload, &request); err != nil { diff --git a/internal/agenthelper/server_test.go b/internal/agenthelper/server_test.go index b40b29008f..31c3833909 100644 --- a/internal/agenthelper/server_test.go +++ b/internal/agenthelper/server_test.go @@ -602,3 +602,29 @@ func TestAuditHookContainsMetadataOnly(t *testing.T) { t.Fatal("result data reached audit metadata") } } + +type fakeProxmoxV2Provider struct{ calls int } + +func (p *fakeProxmoxV2Provider) LXCFilesystems(context.Context) (json.RawMessage, error) { + return nil, &ProviderError{Code: ErrorProviderUnavailable, Message: "incomplete"} +} +func (p *fakeProxmoxV2Provider) LXCFilesystemsV2(context.Context) (json.RawMessage, error) { + p.calls++ + return json.RawMessage(`{"inventory":{"status":"partial","containers":[{"vmid":100}],"omittedVmids":[102],"collectedAt":"2026-10-05T15:00:00Z"}}`), nil +} + +func TestProxmoxV2DispatchIsTypedAndV1RemainsCompleteOnly(t *testing.T) { + provider := &fakeProxmoxV2Provider{} + server := newTestServer(t, NewRegistry(nil, provider), authorizedResolver(1000), nil) + request := validRequest(OperationProxmoxLXCFilesystems) + requireErrorCode(t, exchange(t, server, request), ErrorProviderUnavailable) + request.OperationVersion = OperationVersion2 + if got := exchange(t, server, request); !got.Success || provider.calls != 1 { + t.Fatalf("v2 response=%+v, calls=%d", got, provider.calls) + } + request.Payload = json.RawMessage(`{"vmid":100}`) + requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest) + if provider.calls != 1 { + t.Fatal("caller-selected VMID reached provider") + } +} diff --git a/internal/hostagent/privilege_helper_client.go b/internal/hostagent/privilege_helper_client.go index 803d35f9c4..374a8f339e 100644 --- a/internal/hostagent/privilege_helper_client.go +++ b/internal/hostagent/privilege_helper_client.go @@ -13,6 +13,7 @@ import ( const privilegeHelperOperationDeadline = 30 * time.Second var errPrivilegeHelperProxmoxInventoryUnavailable = errors.New("helper returned no Proxmox LXC filesystem inventory") +var errPrivilegeHelperProxmoxInventoryPartial = errors.New("helper Proxmox LXC filesystem inventory is incomplete") // PrivilegedTelemetry is the collector-side view of the no-network helper. // It intentionally exposes complete typed snapshots rather than commands, @@ -91,20 +92,37 @@ func (c *privilegeHelperTelemetry) ProxmoxLXCFilesystems(ctx context.Context) (* var response struct { Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"` } + ctx, cancel := context.WithTimeout(ctx, privilegeHelperOperationDeadline) + defer cancel() _, err := c.client.Call( ctx, agenthelper.OperationProxmoxLXCFilesystems, - agenthelper.OperationVersion1, + agenthelper.OperationVersion2, privilegeHelperOperationDeadline, struct{}{}, &response, ) + legacy := false + var remote *agenthelper.RemoteError + if errors.As(err, &remote) && remote.Code == agenthelper.ErrorUnsupportedOperation { + // Narrow compatibility only: an older helper explicitly rejects v2. + // Do not fall back on transport, provider, malformed-result or peer errors. + legacy = true + _, err = c.client.Call(ctx, agenthelper.OperationProxmoxLXCFilesystems, + agenthelper.OperationVersion1, privilegeHelperOperationDeadline, struct{}{}, &response) + } if err != nil { return nil, err } if response.Inventory == nil { return nil, errPrivilegeHelperProxmoxInventoryUnavailable } + if err := response.Inventory.ValidateCollection(); err != nil { + return nil, err + } + if !legacy && response.Inventory.Status == "" { + return nil, errors.New("helper v2 inventory has no completeness status") + } return response.Inventory, nil } @@ -121,7 +139,16 @@ func (a *Agent) collectProxmoxLXCFilesystemsForReport(ctx context.Context) *agen a.recordPrivilegeHelperOperation(privilegeHelperOperationProxmoxFilesystems, nil) return nil } - a.recordPrivilegeHelperOperation(privilegeHelperOperationProxmoxFilesystems, err) + if err == nil && inventory != nil { + if validationErr := inventory.ValidateCollection(); validationErr != nil { + err = validationErr + } + } + statusErr := err + if statusErr == nil && inventory != nil && inventory.Status == agentshost.ProxmoxLXCCollectionPartial { + statusErr = errPrivilegeHelperProxmoxInventoryPartial + } + a.recordPrivilegeHelperOperation(privilegeHelperOperationProxmoxFilesystems, statusErr) if err != nil { a.logger.Debug().Err(err).Msg("Typed helper could not collect Proxmox LXC filesystems") return nil diff --git a/internal/hostagent/privilege_helper_client_test.go b/internal/hostagent/privilege_helper_client_test.go index 1b81a6e806..6ea37bf31c 100644 --- a/internal/hostagent/privilege_helper_client_test.go +++ b/internal/hostagent/privilege_helper_client_test.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "encoding/json" "errors" + "fmt" "io" "net" "os/exec" @@ -412,3 +413,158 @@ func requirePrivilegeHelperModuleStatus(t *testing.T, statuses []agentshost.Modu t.Fatalf("typed privilege helper module missing from %+v", statuses) return agentshost.ModuleStatus{} } + +func TestCollectProxmoxPartialInventoryKeepsRowsAndDegradedHealth(t *testing.T) { + helper := &fakePrivilegedTelemetry{proxmox: &agentshost.ProxmoxLXCInventory{ + Status: agentshost.ProxmoxLXCCollectionPartial, Containers: []agentshost.ProxmoxLXCContainer{{VMID: 100, Name: "web"}}, OmittedVMIDs: []int{102}, + }} + status := NewPrivilegeHelperStatus() + status.Record(privilegeHelperOperationSMART, errors.New("unrelated failure")) + agent := &Agent{privilegedTelemetry: helper, privilegeHelperHealth: status, logger: zerolog.Nop()} + got := agent.collectProxmoxLXCFilesystemsForReport(t.Context()) + if got == nil || len(got.Containers) != 1 || len(got.OmittedVMIDs) != 1 { + t.Fatal("partial inventory was discarded") + } + module := status.ModuleStatus() + if module.State != "degraded" || !strings.Contains(module.LastError, "inventory is incomplete") { + t.Fatalf("module = %+v", module) + } + helper.proxmox = &agentshost.ProxmoxLXCInventory{Status: agentshost.ProxmoxLXCCollectionComplete} + if agent.collectProxmoxLXCFilesystemsForReport(t.Context()) == nil { + t.Fatal("complete recovery omitted") + } + module = status.ModuleStatus() + if module.State != "degraded" || strings.Contains(module.LastError, "proxmox.lxc_filesystems") || !strings.Contains(module.LastError, privilegeHelperOperationSMART) { + t.Fatalf("recovery cleared wrong health: %+v", module) + } +} + +func TestProxmoxHelperVersionCompatibilityIsNarrow(t *testing.T) { + tests := []struct { + name string + code string + status string + omitted []int + wantErr bool + wantCalls int + }{ + {"v2 partial", "", "partial", []int{102}, false, 1}, + {"older helper", agenthelper.ErrorUnsupportedOperation, "", nil, false, 2}, + {"provider failure", agenthelper.ErrorProviderUnavailable, "", nil, true, 1}, + {"peer denial", agenthelper.ErrorUnauthorizedPeer, "", nil, true, 1}, + {"missing v2 completeness", "", "", nil, true, 1}, + {"invalid v2 completeness", "", "complete", []int{102}, true, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := make(chan int, 3) + client, err := agenthelper.NewClient(agenthelper.ClientConfig{ + SocketPath: filepath.Join(t.TempDir(), "helper.sock"), MaxDeadline: privilegeHelperOperationDeadline, + DialContext: func(context.Context, string, string) (net.Conn, error) { + clientConn, serverConn := net.Pipe() + go func() { + defer serverConn.Close() + var header [4]byte + if _, err := io.ReadFull(serverConn, header[:]); err != nil { + return + } + b := make([]byte, binary.BigEndian.Uint32(header[:])) + if _, err := io.ReadFull(serverConn, b); err != nil { + return + } + var req agenthelper.Request + if json.Unmarshal(b, &req) != nil { + return + } + calls <- req.OperationVersion + inventory := &agentshost.ProxmoxLXCInventory{Status: tt.status, Containers: []agentshost.ProxmoxLXCContainer{{VMID: 100, Name: "web"}}, OmittedVMIDs: tt.omitted} + raw, _ := json.Marshal(struct { + Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"` + }{inventory}) + response := agenthelper.Response{ProtocolVersion: 1, RequestID: req.RequestID, Operation: req.Operation, OperationVersion: req.OperationVersion, Success: true, Result: raw} + if tt.code != "" && req.OperationVersion == 2 { + response.Success = false + response.Result = nil + response.Error = &agenthelper.ResponseError{Code: tt.code} + } + b, _ = json.Marshal(response) + frame := make([]byte, 4+len(b)) + binary.BigEndian.PutUint32(frame[:4], uint32(len(b))) + copy(frame[4:], b) + serverConn.Write(frame) + }() + return clientConn, nil + }, + }) + if err != nil { + t.Fatal(err) + } + inventory, err := (&privilegeHelperTelemetry{client: client}).ProxmoxLXCFilesystems(t.Context()) + if (err != nil) != tt.wantErr { + t.Fatalf("inventory=%+v, error=%v", inventory, err) + } + if len(calls) != tt.wantCalls { + t.Fatalf("calls=%d, want %d", len(calls), tt.wantCalls) + } + for n := 0; n < tt.wantCalls; n++ { + version := <-calls + if version != 2-n { + t.Fatalf("call %d version=%d", n, version) + } + } + }) + } +} + +func TestCollectProxmoxInvalidListCannotBecomeCompleteEmpty(t *testing.T) { + header := "VMID Status Lock Name\n" + boundary := header + for n := 0; n < proxmoxLXCMaxContainers; n++ { + boundary += fmt.Sprintf("%d running - web\n", 100+n) + } + if rows, err := parseProxmoxLXCRunningContainers(boundary); err != nil || len(rows) != proxmoxLXCMaxContainers { + t.Fatalf("exact running-container boundary rejected: rows=%d err=%v", len(rows), err) + } + tests := []struct { + name, output string + valid bool + }{ + {"header only", header, true}, + {"all stopped", header + "100 stopped - web\n", true}, + {"empty output", "", false}, + {"unrecognised output", "pmxcfs unavailable\n", false}, + {"headerless rows", "100 running - web\n", false}, + {"malformed header", "VMID Status\n", false}, + {"invalid running identity", header + "99 running - web\n", false}, + {"invalid state", header + "100 unknown - web\n", false}, + {"duplicate identity", header + "100 running - web\n100 stopped - web\n", false}, + {"over running limit", boundary + "228 running - web\n", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + queries := 0 + collector := &mockCollector{ + goos: "linux", + lookPathFn: func(string) (string, error) { return "/usr/sbin/pct", nil }, + commandCombinedOutputLimitedFn: func(_ context.Context, _ int, _ string, args ...string) (string, error) { + queries++ + if strings.Join(args, " ") != "list" { + t.Fatal("invalid or empty list must not start per-container queries") + } + return tt.output, nil + }, + } + result := (&Agent{logger: zerolog.Nop(), collector: collector}).collectProxmoxLXCFilesystemsResult(t.Context()) + if queries != 1 || !result.Applicable { + t.Fatalf("collection did not use the one bounded list operation: %+v queries=%d", result, queries) + } + if tt.valid { + if result.Degraded || result.Inventory == nil || result.Inventory.Status != "complete" || len(result.Inventory.Containers) != 0 { + t.Fatalf("valid empty list=%+v", result) + } + } else if !result.Degraded || result.Inventory != nil { + t.Fatalf("invalid list appeared complete: %+v", result) + } + }) + } +} diff --git a/internal/hostagent/privilege_helper_status.go b/internal/hostagent/privilege_helper_status.go index d0e2733203..0ee3e95dac 100644 --- a/internal/hostagent/privilege_helper_status.go +++ b/internal/hostagent/privilege_helper_status.go @@ -92,6 +92,9 @@ func classifiedPrivilegeHelperStatusError(err error) string { if errors.Is(err, errPrivilegeHelperProxmoxInventoryUnavailable) { return "helper returned no Proxmox LXC filesystem inventory" } + if errors.Is(err, errPrivilegeHelperProxmoxInventoryPartial) { + return "helper Proxmox LXC filesystem inventory is incomplete" + } var remote *agenthelper.RemoteError if errors.As(err, &remote) { switch remote.Code { diff --git a/internal/hostagent/proxmox_lxc_filesystems.go b/internal/hostagent/proxmox_lxc_filesystems.go index a1e3eaa112..e07677c98a 100644 --- a/internal/hostagent/proxmox_lxc_filesystems.go +++ b/internal/hostagent/proxmox_lxc_filesystems.go @@ -57,10 +57,8 @@ type proxmoxLXCRunningContainer struct { } // ProxmoxLXCFilesystemCollectionResult describes applicability separately from -// collection health without changing the helper protocol-v1 response shape. -// A degraded result may retain a partial inventory for local diagnostics, but -// the privileged helper fails the typed operation so the collector cannot -// mistake a partial snapshot for a complete one. +// collection health. Helper operation v1 fails incomplete collections; v2 +// carries successful rows with explicit completeness and omitted identities. type ProxmoxLXCFilesystemCollectionResult struct { Applicable bool Inventory *agentshost.ProxmoxLXCInventory @@ -177,10 +175,12 @@ func (a *Agent) collectProxmoxLXCFilesystemsResult(ctx context.Context) ProxmoxL result.Inventory = &agentshost.ProxmoxLXCInventory{ Containers: []agentshost.ProxmoxLXCContainer{}, CollectedAt: a.collector.Now().UTC(), + Status: agentshost.ProxmoxLXCCollectionComplete, } for _, container := range containers { if collectionCtx.Err() != nil { result.FailedContainers++ + result.Inventory.OmittedVMIDs = append(result.Inventory.OmittedVMIDs, container.VMID) continue } containerCtx, cancelContainer := context.WithTimeout(collectionCtx, proxmoxLXCContainerQueryTimeout) @@ -188,6 +188,7 @@ func (a *Agent) collectProxmoxLXCFilesystemsResult(ctx context.Context) ProxmoxL cancelContainer() if collectionErr != nil { result.FailedContainers++ + result.Inventory.OmittedVMIDs = append(result.Inventory.OmittedVMIDs, container.VMID) continue } result.Inventory.Containers = append(result.Inventory.Containers, agentshost.ProxmoxLXCContainer{ @@ -198,6 +199,7 @@ func (a *Agent) collectProxmoxLXCFilesystemsResult(ctx context.Context) ProxmoxL } if result.FailedContainers > 0 { result.Degraded = true + result.Inventory.Status = agentshost.ProxmoxLXCCollectionPartial a.logger.Warn(). Int("collected", len(result.Inventory.Containers)). Int("failed", result.FailedContainers). @@ -448,23 +450,38 @@ func parseProxmoxLXCRunningContainers(output string) ([]proxmoxLXCRunningContain result := make([]proxmoxLXCRunningContainer, 0) seen := make(map[int]struct{}) + sawHeader := false for _, rawLine := range strings.Split(output, "\n") { line := strings.TrimSuffix(rawLine, "\r") - if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "VMID") { + if strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) - if len(fields) < 2 || !strings.EqualFold(fields[1], "running") { + if fields[0] == "VMID" { + if sawHeader || len(fields) != 4 || fields[1] != "Status" || fields[2] != "Lock" || fields[3] != "Name" { + return nil, errors.New("pct list has an invalid header") + } + sawHeader = true continue } + if !sawHeader || len(fields) < 3 { + return nil, errors.New("pct list has an invalid row") + } vmid, err := strconv.Atoi(fields[0]) if err != nil || vmid < 100 || vmid > 999999999 { - continue + return nil, errors.New("pct list has an invalid container identity") } if _, exists := seen[vmid]; exists { + return nil, errors.New("pct list has a duplicate container identity") + } + seen[vmid] = struct{}{} + if strings.EqualFold(fields[1], "stopped") { continue } + if !strings.EqualFold(fields[1], "running") { + return nil, errors.New("pct list has an unknown container state") + } name := "" if len(line) > 35 { @@ -473,14 +490,16 @@ func parseProxmoxLXCRunningContainers(output string) ([]proxmoxLXCRunningContain name = fields[len(fields)-1] } if !safeProxmoxLXCText(name, proxmoxLXCMaxNameBytes) { - continue + return nil, errors.New("pct list has an invalid container name") } - seen[vmid] = struct{}{} - result = append(result, proxmoxLXCRunningContainer{VMID: vmid, Name: name}) if len(result) == proxmoxLXCMaxContainers { - break + return nil, errors.New("pct list exceeds the running container limit") } + result = append(result, proxmoxLXCRunningContainer{VMID: vmid, Name: name}) + } + if !sawHeader { + return nil, errors.New("pct list has no inventory header") } sort.Slice(result, func(i, j int) bool { return result[i].VMID < result[j].VMID }) return result, nil diff --git a/internal/hostagent/proxmox_lxc_filesystems_test.go b/internal/hostagent/proxmox_lxc_filesystems_test.go index 706ea29df2..d29cd3dc44 100644 --- a/internal/hostagent/proxmox_lxc_filesystems_test.go +++ b/internal/hostagent/proxmox_lxc_filesystems_test.go @@ -74,6 +74,9 @@ mp0 tank:subvol-100-disk-0 1.0T 512.0G 512.0G 50.0 /srv/data if !result.Applicable || !result.Degraded || result.FailedContainers != 1 { t.Fatalf("collection result = %+v, want applicable partial failure", result) } + if got.Status != "partial" || len(got.OmittedVMIDs) != 1 || got.OmittedVMIDs[0] != 102 { + t.Fatalf("partial wire completeness = %+v", got) + } if len(commands) != 3 { t.Fatalf("commands = %v, want list plus two running df queries", commands) } @@ -126,6 +129,9 @@ func TestCollectProxmoxLXCFilesystemsReportsTotalContainerFailure(t *testing.T) if result.Inventory == nil || len(result.Inventory.Containers) != 0 { t.Fatalf("partial inventory = %+v, want empty retained inventory", result.Inventory) } + if result.Inventory.Status != "partial" || len(result.Inventory.OmittedVMIDs) != 2 || result.Inventory.ValidateCollection() != nil { + t.Fatalf("all-failed inventory was not explicitly incomplete: %+v", result.Inventory) + } } // Regression for the #1477 follow-up: pct df costs over a second per guest, @@ -437,11 +443,8 @@ func TestParseProxmoxLXCFilesystemsValidatesAndBoundsInput(t *testing.T) { 100, "running", "", "valid", ) containers, err := parseProxmoxLXCRunningContainers(list) - if err != nil { - t.Fatalf("parse list: %v", err) - } - if len(containers) != 1 || containers[0].VMID != 100 || containers[0].Name != "valid" { - t.Fatalf("running containers = %+v", containers) + if err == nil || containers != nil { + t.Fatalf("invalid identity cannot be silently omitted from a complete list: containers=%+v err=%v", containers, err) } if _, err := parseProxmoxLXCRunningContainers( strings.Repeat("x", proxmoxLXCMaxListOutputBytes+1), diff --git a/internal/monitoring/monitor_agent_lxc_filesystems.go b/internal/monitoring/monitor_agent_lxc_filesystems.go index 86f2027fa9..02d44a73ad 100644 --- a/internal/monitoring/monitor_agent_lxc_filesystems.go +++ b/internal/monitoring/monitor_agent_lxc_filesystems.go @@ -29,9 +29,12 @@ type agentLXCFilesystemCacheEntry struct { } func agentLXCFilesystemCacheKey(instance, node string, vmid int) string { + return agentLXCFilesystemNodePrefix(instance, node) + strconv.Itoa(vmid) +} + +func agentLXCFilesystemNodePrefix(instance, node string) string { return strings.ToLower(strings.TrimSpace(instance)) + "\x00" + - strings.ToLower(strings.TrimSpace(node)) + "\x00" + - strconv.Itoa(vmid) + strings.ToLower(strings.TrimSpace(node)) + "\x00" } func agentLXCFilesystemTTL(intervalSeconds int) time.Duration { @@ -60,6 +63,9 @@ func (m *Monitor) applyAgentLXCFilesystems( strings.TrimSpace(linkedNodeID) == "" || agentID == "" { return } + if inventory.ValidateCollection() != nil { + return + } var linkedNodes []models.Node for _, node := range m.state.GetSnapshot().Nodes { @@ -109,7 +115,15 @@ func (m *Monitor) applyAgentLXCFilesystems( m.proxmoxLXCFilesystemsCache = make(map[string]agentLXCFilesystemCacheEntry) } for key, entry := range m.proxmoxLXCFilesystemsCache { - if !receivedAt.Before(entry.expiresAt) { + if !receivedAt.Before(entry.expiresAt) || + (inventory.Status == agentshost.ProxmoxLXCCollectionComplete && entry.agentID == agentID && + strings.HasPrefix(key, agentLXCFilesystemNodePrefix(node.Instance, node.Name))) { + delete(m.proxmoxLXCFilesystemsCache, key) + } + } + for _, vmid := range inventory.OmittedVMIDs { + key := agentLXCFilesystemCacheKey(node.Instance, node.Name, vmid) + if entry, exists := m.proxmoxLXCFilesystemsCache[key]; exists && entry.agentID == agentID { delete(m.proxmoxLXCFilesystemsCache, key) } } diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index b7d3708922..d227fe1547 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -6783,3 +6783,115 @@ func TestBroadcastProjectionListsRegistryOnceAndKeepsLiveChanges(t *testing.T) { t.Fatal("removed host lifecycle surface disappeared") } } + +func TestAgentLXCPartialInventoryPreservesSuccessAndInvalidatesOmissions(t *testing.T) { + state := models.NewState() + state.UpdateNodes([]models.Node{{ID: "node-id", Name: "node-a", Instance: "pve-a", LinkedAgentID: "agent-a"}}) + m := &Monitor{state: state} + now := time.Date(2026, 10, 5, 16, 0, 0, 0, time.UTC) + row := func(vmid int) agentshost.ProxmoxLXCContainer { + return agentshost.ProxmoxLXCContainer{VMID: vmid, Name: "web", Disks: []agentshost.Disk{{Type: "rootfs", Device: "local:disk", Mountpoint: "/", TotalBytes: 4096, UsedBytes: 1024, FreeBytes: 3072}}} + } + m.applyAgentLXCFilesystems("node-id", "agent-a", &agentshost.ProxmoxLXCInventory{Status: "complete", Containers: []agentshost.ProxmoxLXCContainer{row(100), row(102)}}, now, 30) + foreignKey := agentLXCFilesystemCacheKey("pve-b", "node-a", 102) + m.proxmoxLXCFilesystemsCache[foreignKey] = agentLXCFilesystemCacheEntry{agentID: "agent-b", name: "foreign", expiresAt: now.Add(time.Hour)} + m.applyAgentLXCFilesystems("node-id", "agent-a", &agentshost.ProxmoxLXCInventory{Status: "partial", Containers: []agentshost.ProxmoxLXCContainer{row(100)}, OmittedVMIDs: []int{102}}, now.Add(time.Minute), 30) + if _, ok := m.proxmoxLXCFilesystemsCache[agentLXCFilesystemCacheKey("pve-a", "node-a", 102)]; ok { + t.Fatal("omitted guest kept a usable prior agent reading") + } + if _, ok := m.proxmoxLXCFilesystemsCache[foreignKey]; !ok { + t.Fatal("foreign instance cache was invalidated") + } + guest := models.Container{VMID: 100, Name: "web", Status: "running"} + m.enrichContainerWithAgentLXCFilesystems("pve-a", "node-a", &guest, now.Add(time.Minute)) + if len(guest.Disks) != 1 || guest.Disks[0].Used != 1024 { + t.Fatalf("successful partial guest=%+v", guest) + } + m.applyAgentLXCFilesystems("node-id", "agent-a", &agentshost.ProxmoxLXCInventory{Status: "complete"}, now.Add(2*time.Minute), 30) + if _, ok := m.proxmoxLXCFilesystemsCache[agentLXCFilesystemCacheKey("pve-a", "node-a", 100)]; ok { + t.Fatal("complete empty collection retained a removed/stopped guest reading") + } + if _, ok := m.proxmoxLXCFilesystemsCache[foreignKey]; !ok { + t.Fatal("complete empty collection cleared a foreign instance") + } +} + +func TestAgentLXCMalformedPartialInventoryCannotRenewOrInvalidate(t *testing.T) { + state := models.NewState() + state.UpdateNodes([]models.Node{{ID: "node-id", Name: "node-a", Instance: "pve-a", LinkedAgentID: "agent-a"}}) + m := &Monitor{state: state} + now := time.Now() + key := agentLXCFilesystemCacheKey("pve-a", "node-a", 100) + original := agentLXCFilesystemCacheEntry{agentID: "agent-a", name: "web", expiresAt: now.Add(time.Minute)} + m.proxmoxLXCFilesystemsCache = map[string]agentLXCFilesystemCacheEntry{key: original} + m.applyAgentLXCFilesystems("node-id", "agent-a", &agentshost.ProxmoxLXCInventory{Status: "complete", OmittedVMIDs: []int{100}}, now, 30) + got := m.proxmoxLXCFilesystemsCache[key] + if !got.expiresAt.Equal(original.expiresAt) || got.name != "web" { + t.Fatal("malformed completeness changed cache") + } +} + +func TestAgentLXCPartialWireReportReachesLinkedGuestWithoutStaleOmission(t *testing.T) { + m := newTestMonitor(t) + m.state.UpdateNodes([]models.Node{{ID: "node-id", Name: "node-a", Instance: "pve-a"}}) + row := func(vmid int) agentshost.ProxmoxLXCContainer { + return agentshost.ProxmoxLXCContainer{VMID: vmid, Name: "web", Disks: []agentshost.Disk{{ + Type: "rootfs", Device: "local:disk", Mountpoint: "/", TotalBytes: 4096, UsedBytes: 1024, FreeBytes: 3072, + }}} + } + report := agentshost.Report{ + Agent: agentshost.AgentInfo{ID: "agent-a", IntervalSeconds: 30}, + Host: agentshost.HostInfo{ID: "agent-a", MachineID: "agent-a", Hostname: "node-a", Platform: "linux"}, + ProxmoxLXC: &agentshost.ProxmoxLXCInventory{ + Status: agentshost.ProxmoxLXCCollectionComplete, Containers: []agentshost.ProxmoxLXCContainer{row(100), row(102)}, + }, + } + applyWire := func(sequence uint64) models.Host { + t.Helper() + report.Timestamp = time.Now().UTC() + report.SequenceID = agentshost.FormatReportSequenceID("partial-wire-test", sequence) + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + var decoded agentshost.Report + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + host, err := m.ApplyHostReport(decoded, &config.APITokenRecord{ID: "partial-wire-token"}) + if err != nil || host.LinkedNodeID != "node-id" { + t.Fatalf("wire report did not reach linked node: host=%+v err=%v", host, err) + } + return host + } + applyWire(1) + report.ProxmoxLXC.Status = agentshost.ProxmoxLXCCollectionPartial + report.ProxmoxLXC.Containers = []agentshost.ProxmoxLXCContainer{row(100)} + report.ProxmoxLXC.OmittedVMIDs = []int{102} + report.Agent.Modules = []agentshost.ModuleStatus{{Name: agentshost.ModuleNameTypedPrivilegeHelper, Enabled: true, State: "degraded", LastError: "proxmox.lxc_filesystems: helper Proxmox LXC filesystem inventory is incomplete"}} + host := applyWire(2) + if len(host.AgentModules) != 1 || host.AgentModules[0].State != "degraded" { + t.Fatalf("wire report lost partial helper health: %+v", host.AgentModules) + } + for _, vmid := range []int{100, 102} { + guest := models.Container{VMID: vmid, Name: "web", Status: "running"} + m.enrichContainerWithAgentLXCFilesystems("pve-a", "node-a", &guest, time.Now()) + if vmid == 100 && (len(guest.Disks) != 1 || guest.Disks[0].Used != 1024) { + t.Fatalf("successful wire reading was lost: %+v", guest.Disks) + } + if vmid == 102 && len(guest.Disks) != 0 { + t.Fatalf("omitted wire guest kept a prior reading: %+v", guest.Disks) + } + } + report.ProxmoxLXC = &agentshost.ProxmoxLXCInventory{Status: agentshost.ProxmoxLXCCollectionComplete} + report.Agent.Modules[0].State, report.Agent.Modules[0].LastError = "running", "" + host = applyWire(3) + if len(host.AgentModules) != 1 || host.AgentModules[0].State != "running" || host.AgentModules[0].LastError != "" { + t.Fatalf("wire recovery did not clear partial helper health: %+v", host.AgentModules) + } + guest := models.Container{VMID: 100, Name: "web", Status: "running"} + m.enrichContainerWithAgentLXCFilesystems("pve-a", "node-a", &guest, time.Now()) + if len(guest.Disks) != 0 { + t.Fatal("complete empty wire report retained a stopped guest reading") + } +} diff --git a/pkg/agents/host/proxmox_lxc.go b/pkg/agents/host/proxmox_lxc.go new file mode 100644 index 0000000000..3809428146 --- /dev/null +++ b/pkg/agents/host/proxmox_lxc.go @@ -0,0 +1,64 @@ +package host + +import "errors" + +const ( + ProxmoxLXCCollectionComplete = "complete" + ProxmoxLXCCollectionPartial = "partial" + ProxmoxLXCMaxContainers = 128 +) + +// ValidateCollection checks the shared helper/report completeness boundary. +// Legacy omission is complete-only. Global unavailability has no inventory; +// partial (including all-failed) inventories identify each omitted container. +// Mount/value and node/name admission remain with the monitoring consumer. +func (i *ProxmoxLXCInventory) ValidateCollection() error { + invalid := errors.New("invalid Proxmox LXC filesystem completeness") + if i == nil { + return invalid + } + if i.Status == "" { + // Keep legacy row-by-row admission and truncation unchanged. + if len(i.OmittedVMIDs) != 0 { + return invalid + } + return nil + } + if len(i.Containers)+len(i.OmittedVMIDs) > ProxmoxLXCMaxContainers { + return invalid + } + switch i.Status { + case ProxmoxLXCCollectionComplete: + if len(i.OmittedVMIDs) != 0 { + return invalid + } + case ProxmoxLXCCollectionPartial: + if len(i.OmittedVMIDs) == 0 { + return invalid + } + default: + return invalid + } + seen := make(map[int]struct{}, len(i.Containers)+len(i.OmittedVMIDs)) + admit := func(vmid int) bool { + if vmid < 100 || vmid > 999999999 { + return false + } + if _, duplicate := seen[vmid]; duplicate { + return false + } + seen[vmid] = struct{}{} + return true + } + for _, container := range i.Containers { + if !admit(container.VMID) { + return invalid + } + } + for _, vmid := range i.OmittedVMIDs { + if !admit(vmid) { + return invalid + } + } + return nil +} diff --git a/pkg/agents/host/proxmox_lxc_test.go b/pkg/agents/host/proxmox_lxc_test.go new file mode 100644 index 0000000000..508b7866d6 --- /dev/null +++ b/pkg/agents/host/proxmox_lxc_test.go @@ -0,0 +1,67 @@ +package host + +import ( + "encoding/json" + "reflect" + "testing" + "time" +) + +func TestProxmoxLXCCollectionCompletenessWire(t *testing.T) { + tests := []struct { + name string + inventory ProxmoxLXCInventory + valid bool + }{ + {"legacy", ProxmoxLXCInventory{}, true}, + {"complete empty", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionComplete}, true}, + {"partial", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, Containers: []ProxmoxLXCContainer{{VMID: 100}}, OmittedVMIDs: []int{102}}, true}, + {"all failed", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, OmittedVMIDs: []int{100, 102}}, true}, + {"partial without omissions", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial}, false}, + {"complete with omission", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionComplete, OmittedVMIDs: []int{100}}, false}, + {"legacy with omission", ProxmoxLXCInventory{OmittedVMIDs: []int{100}}, false}, + {"unknown status", ProxmoxLXCInventory{Status: "unavailable"}, false}, + {"contradictory VMID", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, Containers: []ProxmoxLXCContainer{{VMID: 100}}, OmittedVMIDs: []int{100}}, false}, + {"duplicate omission", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, OmittedVMIDs: []int{100, 100}}, false}, + {"invalid omission", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, OmittedVMIDs: []int{99}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.inventory.CollectedAt = time.Unix(1700000000, 0).UTC() + if got := tt.inventory.ValidateCollection() == nil; got != tt.valid { + t.Fatalf("valid=%v, want %v", got, tt.valid) + } + b, err := json.Marshal(tt.inventory) + if err != nil { + t.Fatal(err) + } + var roundTrip ProxmoxLXCInventory + if err := json.Unmarshal(b, &roundTrip); err != nil || !reflect.DeepEqual(tt.inventory, roundTrip) { + t.Fatalf("round-trip=%+v, err=%v", roundTrip, err) + } + if tt.inventory.Status == "" && len(tt.inventory.OmittedVMIDs) == 0 { + var old struct { + Containers []ProxmoxLXCContainer `json:"containers"` + CollectedAt time.Time `json:"collectedAt"` + } + if err := json.Unmarshal(b, &old); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestProxmoxLXCCollectionBounds(t *testing.T) { + i := ProxmoxLXCInventory{Status: ProxmoxLXCCollectionComplete} + for n := 0; n < ProxmoxLXCMaxContainers; n++ { + i.Containers = append(i.Containers, ProxmoxLXCContainer{VMID: 100 + n}) + } + if err := i.ValidateCollection(); err != nil { + t.Fatal(err) + } + i.Containers = append(i.Containers, ProxmoxLXCContainer{VMID: 100 + ProxmoxLXCMaxContainers}) + if i.ValidateCollection() == nil { + t.Fatal("over-bound inventory accepted") + } +} diff --git a/pkg/agents/host/report.go b/pkg/agents/host/report.go index 0cacbeadfe..1dacea3bc7 100644 --- a/pkg/agents/host/report.go +++ b/pkg/agents/host/report.go @@ -120,6 +120,10 @@ type XCPNGVM struct { type ProxmoxLXCInventory struct { Containers []ProxmoxLXCContainer `json:"containers"` CollectedAt time.Time `json:"collectedAt"` + // Empty status is the legacy complete-only helper response. Version 2 + // makes completeness explicit; omitted VMIDs never carry fresh readings. + Status string `json:"status,omitempty"` + OmittedVMIDs []int `json:"omittedVmids,omitempty"` } // ProxmoxLXCContainer contains direct pct df readings for one local running diff --git a/scripts/install.sh b/scripts/install.sh index fe2c62a9cb..5d5cdebeed 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5954,6 +5954,12 @@ provision_typed_privileged_helper() { fail "Failed to enable the typed privileged helper socket" "$EXIT_GENERAL" fi verify_privileged_helper_socket + # Reloading a unit does not replace an already running helper's code. + # Activate the verified binary only after the isolated unit and socket + # identity have passed the installer-owned boundary checks. + if ! systemctl restart "${PRIVILEGED_HELPER_NAME}.service"; then + fail "Failed to restart the typed privileged helper with its installer-owned configuration" "$EXIT_GENERAL" + fi log_info "Typed privileged helper socket active at ${PRIVILEGED_HELPER_SOCKET_PATH} (root:${LEAST_PRIVILEGE_USER} 0660)" } diff --git a/scripts/installtests/safe_profile_migration_test.go b/scripts/installtests/safe_profile_migration_test.go index d0bc57f966..5ec696d6d3 100644 --- a/scripts/installtests/safe_profile_migration_test.go +++ b/scripts/installtests/safe_profile_migration_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" ) @@ -307,6 +308,7 @@ func TestSafeProfileValidatesEveryEffectiveSystemdBoundary(t *testing.T) { {name: "helper service drop-in", unit: "pulse-agent-helper.service", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent-helper.service.d/override.conf"}, {name: "helper executable", unit: "pulse-agent-helper.service", property: "ExecStart", value: "{ path=/tmp/helper ; argv[]=/tmp/helper ; }"}, {name: "helper private network", unit: "pulse-agent-helper.service", property: "PrivateNetwork", value: "no"}, + {name: "helper unknown network isolation", unit: "pulse-agent-helper.service", property: "PrivateNetwork", value: ""}, {name: "helper common hardening", unit: "pulse-agent-helper.service", property: "ProtectKernelModules", value: "no"}, {name: "helper task limit", unit: "pulse-agent-helper.service", property: "TasksMax", value: "infinity"}, {name: "helper descriptor limit", unit: "pulse-agent-helper.service", property: "LimitNOFILE", value: "1048576"}, @@ -369,6 +371,102 @@ func TestTypedHelperProvisionChecksEffectiveUnitsBeforeSocketActivation(t *testi } } +func TestTypedHelperProvisionReplacesRunningHelperAfterValidatedUnit(t *testing.T) { + provision := extractInstallShellFunction(t, "provision_typed_privileged_helper") + // The production activation target remains fixed. Translate just that + // comparison into this disposable filesystem, not the installer itself; + // the wrong-target control must still be refused before any unit operation. + fixedTargetCheck := `if [[ "${INSTALL_DIR}/${BINARY_NAME}" != "/usr/local/bin/pulse-agent" ]]; then` + if strings.Count(provision, fixedTargetCheck) != 1 { + t.Fatal("typed helper no longer enforces exactly one fixed activation target") + } + for _, mode := range []string{"success", "effective-unit-failure", "restart-failure", "wrong-target"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + mustMkdirAll(t, filepath.Join(root, "bin"), filepath.Join(root, "systemd")) + if err := os.WriteFile(filepath.Join(root, "bin", "pulse-agent"), []byte("fixture"), 0o755); err != nil { + t.Fatal(err) + } + script := ` +set -euo pipefail +ROOT=` + strconv.Quote(root) + ` +MODE=` + strconv.Quote(mode) + ` +INSTALL_DIR="$ROOT/bin" +if [[ "$MODE" == wrong-target ]]; then + INSTALL_DIR="$ROOT/other-bin" + mkdir -p "$INSTALL_DIR" + cp "$ROOT/bin/pulse-agent" "$INSTALL_DIR/pulse-agent" +fi +BINARY_NAME=pulse-agent +LEAST_PRIVILEGE_USER=pulse-agent +PRIVILEGED_HELPER_NAME=pulse-agent-helper +PRIVILEGED_HELPER_BINARY_PATH="$ROOT/bin/pulse-agent-helper" +PRIVILEGED_HELPER_SERVICE_UNIT="$ROOT/systemd/pulse-agent-helper.service" +PRIVILEGED_HELPER_SOCKET_UNIT="$ROOT/systemd/pulse-agent-helper.socket" +PRIVILEGED_HELPER_SOCKET_PATH="$ROOT/helper.sock" +PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR="$ROOT/quarantine" +PRIVILEGED_HELPER_STATE_DIR="$ROOT/state" +PRIVILEGED_HELPER_UPDATE_STAGING_DIR="$ROOT/staging" +EXIT_GENERAL=1 +install() { :; } +chown() { :; } +chmod() { :; } +append_service_env() { :; } +log_info() { printf 'complete\n' >> "$ROOT/events"; } +fail() { printf '%s\n' "$1" >&2; exit "$2"; } +safe_profile_verify_helper_effective_target() { + printf 'validate\n' >> "$ROOT/events" + test "$MODE" != effective-unit-failure +} +verify_privileged_helper_socket() { printf 'socket-identity\n' >> "$ROOT/events"; } +systemctl() { + printf '%s\n' "$*" >> "$ROOT/events" + case "$*" in + daemon-reload|'enable --now pulse-agent-helper.socket') return 0 ;; + 'restart pulse-agent-helper.service') + test "$MODE" != restart-failure || return 1 + printf 'new-helper\n' > "$ROOT/running-helper" + return 0 ;; + *) return 2 ;; + esac +} +printf 'old-helper\n' > "$ROOT/running-helper" +: > "$ROOT/events" +` + extractInstallShellFunction(t, "render_privileged_helper_socket_unit") + "\n" + + extractInstallShellFunction(t, "render_privileged_helper_service_unit") + "\n" + + strings.Replace(provision, fixedTargetCheck, `if [[ "${INSTALL_DIR}/${BINARY_NAME}" != "$ROOT/bin/pulse-agent" ]]; then`, 1) + "\nprovision_typed_privileged_helper\n" + out, err := exec.Command("bash", "-c", script).CombinedOutput() + events, readErr := os.ReadFile(filepath.Join(root, "events")) + if readErr != nil { + t.Fatalf("installer fixture events: %v; execution=%v output=%s", readErr, err, out) + } + want := "daemon-reload\nvalidate\n" + if mode == "wrong-target" { + want = "" + } else if mode != "effective-unit-failure" { + want += "enable --now pulse-agent-helper.socket\nsocket-identity\nrestart pulse-agent-helper.service\n" + } + if mode == "success" { + want += "complete\n" + } + if string(events) != want || (err == nil) != (mode == "success") { + t.Fatalf("installer helper activation: events=%q want=%q err=%v output=%s", events, want, err, out) + } + if mode == "success" { + running, _ := os.ReadFile(filepath.Join(root, "running-helper")) + unit, _ := os.ReadFile(filepath.Join(root, "systemd", "pulse-agent-helper.service")) + if string(running) != "new-helper\n" || !strings.Contains(string(unit), "PrivateNetwork=true\n") { + t.Fatal("installer left the simulated predecessor active or did not preserve the isolated unit") + } + } else if mode == "restart-failure" && !strings.Contains(string(out), "Failed to restart the typed privileged helper") { + t.Fatalf("restart failure was not surfaced: %s", out) + } else if mode == "wrong-target" && !strings.Contains(string(out), "require the fixed /usr/local/bin/pulse-agent target") { + t.Fatalf("wrong activation target was not refused: %s", out) + } + }) + } +} + func TestTypedHelperUnitRendersBoundedResources(t *testing.T) { unitPath := filepath.Join(t.TempDir(), "pulse-agent-helper.service") render := extractInstallShellFunction(t, "render_privileged_helper_service_unit") @@ -394,6 +492,55 @@ render_privileged_helper_service_unit "` + unitPath + `" /usr/local/lib/pulse-ag } } +// AF_UNIX-only socket filtering is not enforced on native 32-bit x86. +// Rendering/admission controls must keep the independent private namespace +// on every platform, not infer enforcement from an effective property string. +// These are installer regressions, not execution of a native sandbox. +func TestTypedHelperIsolationDoesNotDependOnArchitectureOrPVE(t *testing.T) { + for _, arch := range []string{"amd64", "arm64", "armv7", "armv6", "386", "unknown"} { + for _, pve := range []string{"false", "true"} { + t.Run(arch+"/pve="+pve, func(t *testing.T) { + unitPath := filepath.Join(t.TempDir(), "helper.service") + script := ` +set -euo pipefail +ARCH=` + strconv.Quote(arch) + ` +ENABLE_PROXMOX=` + strconv.Quote(pve) + ` +PRIVILEGED_HELPER_NAME=pulse-agent-helper +PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR=/var/lib/pulse-agent/update-quarantine +PRIVILEGED_HELPER_STATE_DIR=/var/lib/pulse-agent-helper +` + extractInstallShellFunction(t, "render_privileged_helper_service_unit") + ` +render_privileged_helper_service_unit ` + strconv.Quote(unitPath) + ` /usr/local/lib/pulse-agent/pulse-agent-helper +` + if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("render isolated helper: %v\n%s", err, out) + } + body, err := os.ReadFile(unitPath) + if err != nil { + t.Fatal(err) + } + for _, directive := range []string{"PrivateNetwork=true", "RestrictAddressFamilies=AF_UNIX", "SystemCallArchitectures=native"} { + if strings.Count(string(body), directive+"\n") != 1 { + t.Fatalf("%s/%s did not preserve %s: %s", arch, pve, directive, body) + } + } + for _, value := range []string{"no", ""} { + gate := effectiveSystemdHarness(t, "pulse-agent-helper.service", "PrivateNetwork", value) + "\n" + + "ARCH=" + strconv.Quote(arch) + "\nENABLE_PROXMOX=" + strconv.Quote(pve) + "\n" + + safeProfileEffectiveSystemdFunctions(t) + ` +if safe_profile_verify_effective_target; then + echo 'host or unknown helper network was admitted' >&2 + exit 1 +fi +` + if out, err := exec.Command("bash", "-c", gate).CombinedOutput(); err != nil { + t.Fatalf("fail-closed helper admission: %v\n%s", err, out) + } + } + }) + } + } +} + func TestSafeProfileRegistrationMustAdvanceLastSeen(t *testing.T) { functions := extractInstallShellFunction(t, "verify_agent_server_registration") script := ` diff --git a/scripts/installtests/secure_runtime_platform_matrix_test.go b/scripts/installtests/secure_runtime_platform_matrix_test.go index f921be2e7e..0911d133e0 100644 --- a/scripts/installtests/secure_runtime_platform_matrix_test.go +++ b/scripts/installtests/secure_runtime_platform_matrix_test.go @@ -35,6 +35,11 @@ func TestSecureRuntimePlatformMatrixRemainsExplicitAndShipped(t *testing.T) { "collectionMode: typed-helper-summary", "currently explicit rather than the installer default.", "Residual owner and removal condition", + "systemd does not enforce `RestrictAddressFamilies` on native 32-bit x86", + "Do not\nremove `PrivateNetwork` to work around a telemetry failure.", + "helper's private network namespace", + "operation v2 preserves the collected", + "Unavailable on hosts where pmxcfs abstract IPC is hidden", } for _, marker := range required { if !strings.Contains(content, marker) {