Preserve partial PVE filesystem readings without widening helper access

Replace the rejected global host-network proposal with the unchanged private namespace on every platform, including native linux-386. Carry successful rows and omitted VMIDs through operation v2 without clearing degraded health or renewing omitted measurements; retain strict complete-only v1 compatibility. Restart the installer-owned helper only after isolated-unit and socket checks, and document the unresolved pmxcfs IPC limitation rather than claiming PVE recovery.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 18:22:01 +01:00
parent f434e55be0
commit 63fcae0a3a
26 changed files with 834 additions and 24 deletions
+42
View File
@@ -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)
}
}
+29 -1
View File
@@ -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})
+22 -1
View File
@@ -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 |
@@ -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
@@ -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
@@ -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,
@@ -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
+22 -1
View File
@@ -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 |
@@ -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' },
+1
View File
@@ -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
+6
View File
@@ -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.
+11
View File
@@ -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 {
+26
View File
@@ -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")
}
}
+29 -2
View File
@@ -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
@@ -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)
}
})
}
}
@@ -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 {
+30 -11
View File
@@ -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
@@ -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),
@@ -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)
}
}
@@ -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")
}
}
+64
View File
@@ -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
}
+67
View File
@@ -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")
}
}
+4
View File
@@ -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
+6
View File
@@ -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)"
}
@@ -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 := `
@@ -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) {