A go shell can hold later launches in memory after its boot file changes. Treat that live mixed-purpose owner as unknown rather than signalling it or inferring quiescence from the edited file. Require the actual-caller refusal control alongside the existing NAS suite.
Change-source: pulse-maintainer
Retire only exact Unraid and QNAP boot entries before checked watchdog-first process termination and same-instance server removal. Preserve unrelated boot owners and refuse unknown, partial or concurrent state without erasing credentials or restarting. Exercise the complete uninstall caller and generated private watchdogs.
Change-source: pulse-maintainer
A missing SCRIPT path or empty COMMAND target cannot establish that a task is unrelated or absent. Retain recovery before any task or service mutation when those fields are unavailable. Add three actual-caller adverse controls to the mandatory sixteen-method hook and both owning contracts.
Change-source: pulse-maintainer
Disable and independently verify every exact owned middleware activation task before systemd stop and server removal. Close task, unit and process state after the full stop and after task deletion, retaining recovery on refusals or concurrent changes without restarting. Ordinary Linux state directories are not appliance identity. Register all fifteen actual-function/full-caller controls and update both owning contracts in this coherent candidate.
Change-source: pulse-maintainer
Inline and command-double changes did not establish cleanup on the retained race refusal. Register only the non-credential manual temporary with the existing installer trap and execute that exact trap in the confined caller controls. Never register the credential-bearing staged job backup for automatic deletion. All retention, no-clobber, refusal and native obligations remain intact.
Change-source: pulse-maintainer
The retained race refusals still left manual temporary files after the command-double change. Check write, permission and atomic link operations individually rather than relying on the grouped chain to reach cleanup. Keep concurrent files/directories and all credentials intact; the same mandatory race assertions remain. Matching Go1.26.9 is now actually observed and the complete installer suite remains required.
Change-source: pulse-maintainer
A pathname absence check followed by mv could overwrite a concurrent root-created job. Use exact-target non-replacement links for override creation and config restoration; directory races also refuse without creating nested artifacts. Confined concurrent-file/directory controls are mandatory. Partial activation is never restarted and conflicting staged recovery is never discarded.
Change-source: pulse-maintainer
Treat DSM6 Upstart and stale DSM7 jobs through their actual manager, not SysV or late best-effort cleanup. Keep trusted job state and credentials on refused, unknown or partial observations; require effective manual activation and observed stopped/absent processes before remote removal and local erasure. Stage the original config until absence is confirmed, restore without restart on failure, and preserve non-clobbering recovery. Mandatory production-function/caller controls retain every refusal and timeout boundary. This is confined source repair, not native Linux/Windows or whole-issue acceptance.
Change-source: pulse-maintainer
Retain recovery before unknown or unavailable timeout/sysrc observations, require bounded PID records, and align the existing daemon-supervisor assertion with checked signal delivery rather than ignored errors. Every source control remains mandatory.
Change-source: pulse-maintainer
Continue #1099: stop and disable the actual legacy manager before remote deregistration or local erasure, recheck after confirmation, and retain PID/service/credential material on refusal or unknown state. Check collector and supervisor absence, exact runlevel ownership and rendered signal failures with confined production-function controls. Appliance bootstrap and native acceptance remain separate.
Change-source: pulse-maintainer
Require bounded diagnostic-free service and all-runlevel readback before server removal or local mutation, and reconcile again after remote confirmation while recovery state still exists. Refused stops, disable failures, unknown reads and unexpected init paths retain local material; absent and repeated removal need independent manager/process absence. Add executable confined refusal, ordering, custom-runlevel and timeout-child controls to the registered installer hook and owning contracts. Native OpenRC, Windows, other-manager and installed acceptance remain separate.
Change-source: pulse-maintainer
Replace ignored stop and disable failures with bounded complete state observations and independent inactive/non-enabled readback. Reconcile helper socket and service together, and quiesce the full uninstall activation set before remote or local mutation. Preserve failed runner revocation material and existing saved-state, token, trust and platform boundaries. Add executable production-shell refusal, reactivation, timeout and owned-cleanup controls.
Change-source: pulse-maintainer
Non-interactive token-file reading currently erases the supplied credential before permission checks and authenticated removal, including when removal is refused or its result is unknown. Treat token-file ingress as read-only: callers own their bootstrap cleanup, and confirmed removal still deletes only authorised runtime state. Keep token validation, TLS policy, exact record confirmation and credential-free local removal unchanged.
Extend the existing portable removal fixture through the actual parser and token ingress, root refusal and post-confirmation owned cleanup. Parent proof records thirty premature-erasure failures; a separate no-TTY harness control remains distinct. Update both owning contracts and the registered Go inventory hook together. Native Windows, compiled TLS and installed whole-job acceptance remain separate.
Change-source: pulse-maintainer
Credential-bearing removal currently short-circuits its confirmation gate when connection recovery leaves no URL, allowing local teardown without durably removing the server record. Check credential presence first and stop with same-instance recovery guidance before any process, service or state teardown. Keep credential-free local-only removal and existing exact-record/path authority intact.
Execute the real admission and initial teardown with private transport/service doubles, including every saved credential form, refusal, unknown response, identity mismatch and authorised cleanup. Register the portable Python controls in the installer inventory and describe the boundary in both owning contracts. Native Windows/TLS and whole installed lifecycle acceptance remain separate.
Change-source: pulse-maintainer
Account for every byte in the root-boundary compatibility reader, preserving descriptor-first recovery and rejecting oversized valid prefixes, multiple identities and discarded NULs. Keep the original two-second fixture budget, contain descendant processes and report build/probe timing separately. Add real-agent, legacy and cancellation controls without claiming a cause for the retained timeout.
Change-source: pulse-maintainer
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
The FreeBSD rc.d branch printed a log hint pointing at the system log,
but pfSense has no /var/log/messages (its system log is
/var/log/system.log). More importantly, daemon(8) does not capture the
child's stdout and rc.d has no journal, so the agent's zerolog output was
never written to any file: correcting the path alone would still point
users at a log with no agent entries.
Give the agent its own rotating log via --log-file on FreeBSD (both the
TrueNAS CORE and pfSense/OPNsense/vanilla FreeBSD branches) and point the
completion hint at it, matching the Unraid/QNAP/TrueNAS-Linux convention.
The shell installer is a canonical path shared by agent-lifecycle and
deployment-installability, so record the FreeBSD log-capture obligation in
both subsystem contracts.
Refs #2123.
Change-source: pulse-maintainer
FreeBSD base ships sha256(1) but not GNU sha256sum or Perl shasum, so a
fresh pfSense/FreeBSD host without coreutils failed the download checksum
and refused to install. Route every installer checksum through
installer_file_sha256 and fall back through sha256sum, sha256 -q, shasum and
openssl dgst. install-mcp.sh gets the same sha256 branch. Add a focused test
that hides sha256sum/shasum and asserts the FreeBSD fallback returns the
digest.
Change-source: pulse-maintainer
Issue #1890 reports macOS agent updates stopping because the root group does not exist. Use numeric superuser ownership in the two shared lifecycle writes without relaxing failure handling or the least-privilege group boundary. Add a regression fixture that rejects named root ownership and checks that chown failures still prevent replacement.
Change-source: pulse-maintainer
The installer staged the download in /tmp and installed the runtime
binary to /usr/local/bin, both on the small RAM-backed QTS/QuTS hero
root, and the boot wrapper copied 34MiB back onto that root at every
boot. Roots without ~50MiB of headroom could not install at all, and
setting TMPDIR only moved the staging half of the requirement.
QNAP's own QPKG packages execute from the data volume, so do the same:
relocate the install dir to the data volume's state dir before the
preflight and download, default TMPDIR there too, skip the boot-time
self-copy when the stored and runtime binaries are one file, and remove
a pre-relocation runtime copy from /usr/local/bin to give that space
back. Split layouts with an operator-supplied state dir keep the copy
semantics. The rendered wrapper is exercised in both layouts by the
installer tests.
Refs #1617
Contract-Neutral: Refs #1617: QNAP installer layout fix with its deployment-installability contract clause staged in this commit; residual proof policies for unrelated boundaries do not apply to this shell-only change
Live proof on a real systemd host (Debian 12 container, full
install/report/update/uninstall cycle) caught what no unit test did:
NoNewPrivileges=true blocks sudo outright, so the --grant-smart and
--grant-pct helpers failed inside the service and SMART/pct silently
disappeared while the install reported success. A unit with an active
grant now sets NoNewPrivileges=false, keeping the rest of the hardening;
a grantless least-privilege install keeps NNP enabled. Docs and contracts
state the trade-off: each grant is a scoped, auditable widening.
Also proven in the same live cycle: the flag-conflict refusals, user and
sudoers provisioning, wrapper execution as the service user, the healthy
privilege report ({runningAsRoot:false, serviceUser:pulse-agent,
smartctlHelper:true}), --update preserving the profile without repeated
flags, and uninstall removing the sudoers file and helpers.
The unified agent's Linux installer only offered the root profile, and the
docs called non-root unsupported. That default is the most-cited reason
security-conscious evaluators reject Pulse without trying it. install.sh
gains --least-privilege: the service runs as a dedicated nologin
pulse-agent system user with every existing hardening directive, no
LXC-attach ambient capabilities, docker-group membership for socket reads,
and refusal (not silent root fallback) on appliance platforms, non-systemd
init systems, and --enable-commands. Optional --grant-smart and --grant-pct
restore the only two collectors that need elevation through
visudo-validated exact-command sudoers rules and root-owned wrappers the
agent reaches via new absolute-path-only PULSE_SMARTCTL_PATH /
PULSE_PCT_PATH overrides; the pct grant covers pct list and pct df only
and can never widen into pct exec. --update preserves the profile and its
grants by reading the installed unit.
The agent now authors a privilege block in its report (effective root,
service user, active helpers), carried through models into the fleet
doctor as a descriptive field: Agent Doctor shows the profile and its
helpers instead of presenting intentionally absent collectors as a fault,
and a least-privilege agent can never be marked unhealthy on that evidence
alone.
lxc-attach into an unprivileged guest writes /proc/<pid>/uid_map, which
needs CAP_SETUID in the parent user namespace. NoNewPrivileges drops
CAP_SETUID from the effective set and also stops lxc-attach falling back
to the setuid newuidmap/newgidmap helpers, so the socket probe dies with
"write_id_mapping: 61 Operation not permitted".
install.sh already relaxed NoNewPrivileges for this, but only when the
agent was installed with --enable-commands. Command execution is also
togglable from the server afterwards: applyRemoteConfig starts the
command client without rewriting the unit. An agent installed without
the flag and switched on later therefore ends up able to run commands
and unable to attach to unprivileged guests, so Docker inside every
unprivileged LXC disappears from the Proxmox page. The probe failure is
logged at debug level and retried on every poll, so the surface looks
empty rather than broken while the agent re-probes the whole guest list.
Grant CAP_SETUID/CAP_SETGID to any PVE agent rather than gating on the
install-time flag, so the later toggle lands on a unit that can attach.
Ambient capabilities restore exactly the privilege lxc-attach needs and
leave the rest of the sandbox intact; the existing install-time
relaxation is unchanged.
Verified on a live PVE node. With the hardened unit the probe succeeded
only on the three privileged guests and failed on every unprivileged
one. After the ambient grant CapEff regained CAP_SETUID and both
unprivileged Docker guests were discovered, taking that node from one
Docker LXC to three.
The installer compares the agent binary it downloaded against the server that
served it, stripping a leading "v" so "v6.0.4" and "6.0.4" match. It did not
strip semver build metadata, so a server built from a working tree reporting
"6.2.0-rc.8+git.46.g98a638e00.dirty" never matched the "v6.2.0-rc.8" agent it
had just served, and the mismatch warning fired on every correct development
install.
This is the warning's whole job, so a false positive is expensive. It is the
only client-side signal that a stale agent was downloaded, and because it
always fired it read as background noise. That is exactly how a genuinely
stale v6.0.5 agent was installed on a live host earlier today: the warning
was there, above the install output, and looked like the one that always
appears.
Strip build metadata from both sides before comparing, keeping the prerelease
suffix because 6.2.0-rc.8 and 6.2.0 are genuinely different releases. This is
the same release-identity reduction the server applies when deciding whether a
local agent artifact is fresh enough to serve; the contracts now state that
one definition governs both ends rather than leaving each side to invent its
own.
Guarded by a test that pins both normalisation steps and exercises the
comparison across the cases that matter: the dev-server shape that used to
warn wrongly, the stale-download shape that must still warn, and a prerelease
against its release. Verified to fail when either strip is removed.
Completes the wrapper-teardown rule across the remaining branches. The QNAP
install and both uninstall paths still used a bare pkill -f
"start-pulse-agent.sh" and still stopped the agent before its wrapper.
The bare pattern is narrower than it looks and wider than it should be. It
does NOT match a co-installed agent's supervisor, so the sibling case was
already safe; what it does match is anything where the unescaped dot stands in
for another character and the unbounded tail keeps going, including a .bak
copy of the wrapper and an editor session holding it open. Escaping the dot
and bounding the far end removes both without narrowing the intended match.
Ordering is the more consequential half. A wrapper is a watchdog, so stopping
the agent while its wrapper still loops only races the respawn. QNAP and the
uninstall paths now stop the supervisor first, which is what the contracts
already required of every branch that writes and launches a wrapper.
Uninstall keeps a deliberately broader match than install, with no leading
path separator, so it still reaches a wrapper invoked by a relative path or
stranded at a superseded location. Both contracts now carry that distinction
and the teardown ordering rule, which each had stated only for install.
Guarded by two tests that pin every wrapper kill in the file rather than one
branch: one requires the escaped dot and the bounded tail everywhere, the
other walks each stop block and fails if an agent kill precedes its wrapper
kill. Both were confirmed to fail against the pre-fix QNAP block.
The Unraid install path killed the running agent but never the wrapper
supervising it, then appended a second wrapper at the end of the install. The
survivor and the newcomer both loop trying to own the same agent id, and
because the old wrapper is a watchdog it respawns the agent mid-install with
the previous binary and arguments. Observed on a live Unraid host: a
supervisor from a July install was still running beside the one the reinstall
had just started.
Nothing reports this as a failure. It presents later as an agent that
restarts on its own or reverts to superseded arguments.
Stop the wrapper first, then the agent: killing a supervised agent while its
wrapper still loops only races the respawn. The pattern matches the trailing
path segment so a wrapper left at an older storage location is caught too,
with the dot escaped and the far end bounded so a co-installed agent's
supervisor (start-pulse-agent-prod.sh) is not.
The QNAP branch already stopped its wrapper, which is what made the Unraid
omission visible; the contracts now require every wrapper-writing branch to
own the same teardown.
Guarded by two tests: one pins that the Unraid branch stops the wrapper and
does so before the agent, the other pins that the wrapper pattern spares a
sibling supervisor, with a premise check that the loose pattern really does
match so neither assertion can pass vacuously.
pkill -f matches the whole command line and "^" only anchors the start, so
"^/usr/local/bin/pulse-agent" also matches "/usr/local/bin/pulse-agent-prod".
On a host running a second agent whose binary name shares the prefix, every
install, every upgrade, and every restart of the generated Unraid wrapper
silently killed the other agent too. Confirmed on a live dual-agent Unraid
box: the old pattern matched both the dev agent and the production dogfood
agent, the bounded pattern matches only its own.
The wrapper is the worst of the three because restarting through it is the
documented runbook step, so the collateral kill repeats every time an
operator follows it.
Bound the far end of each binary-anchored pattern with ([[:space:]]|$), and
swap the bare pkill -9 -f "pulse-agent" for -x on the exact process name,
which keeps that site's deliberate path-agnostic intent while excluding the
sibling. The pkill -x sites were already safe and are unchanged.
Guarded by two tests: one pins that no binary-anchored pkill in the installer
is left unbounded, the other exercises POSIX ERE semantics against the two
command lines a dual-agent host presents, including a premise check that the
unbounded pattern really does match the sibling so the assertion cannot pass
vacuously.
The installer's discover_rootless_container_runtime only globbed
/run/user/* sockets and never consulted the system Docker daemon, so a
socket-activated rootless Podman API socket (alive only for root's login
session on Debian/OMV) won discovery over a healthy rootful Docker and
pinned PULSE_DOCKER_RUNTIME=podman plus CONTAINER_HOST/PODMAN_HOST/
XDG_RUNTIME_DIR into the agent unit. The env-application block also ran
for explicit --enable-docker installs. Rootless discovery now defers to
system_docker_runtime_is_active (docker info with DOCKER_HOST stripped,
or a live /var/run/docker.sock probe) before touching rootless sockets.
On the agent, detectRuntime short-circuited to podman whenever the
preference said podman, mislabeling connections that actually fell
through to the Docker socket and disabling Swarm collection. The
preference is now an ordering hint: a podman-preferred connection landing
on a docker endpoint reports docker, while unlabeled endpoints with no
runtime signals still honor the pin. When the bound socket disappears
mid-run the agent re-runs runtime discovery after three consecutive
daemon-unavailable collects, swapping the connection behind a
swappableDockerClient so concurrent goroutines keep a stable handle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A host running both PVE and PBS is a deployment the docs call officially
supported, and the agent's RunAll registers each product in turn from the
one install token. The bootstrap grant recorded consumption per TOKEN, so
the PVE leg spent it, the PBS leg came back canRegister=false, the agent
wrote a proxmox-pbs-registration-blocked marker, and install.sh printed an
ERROR banner over a PVE source that had registered perfectly well.
- server: consumption is now recorded per canonical type. One PVE create
and one PBS create per token, each still one-shot — a second create of
the same type takes the same 403. The bounds that are not per type stay
singular: the 24h mint-age clock and the first-use bound_hostname are
shared, so whichever type registers first pins the hostname for both and
the second type cannot be aimed at another machine. The per-type ledger
lives in proxmox_registration_consumed_types; a record carrying only
proxmox_registration_completed=true predates it and still reads as every
type consumed, so upgrading cannot revive a token already spent in the
field. The unsuffixed completion block keeps tracking the most recent
completion, which also leaves an older binary reading the same store
failing closed.
- rollback: the consume-before-persist undo is scoped to the keys one
consumption wrote, so a failed PBS source save restores the PBS grant
without resurrecting the PVE grant that already produced a source.
- agent: RunAll no longer lets one product's failure speak for the host. It
attempts and returns the remaining products, errors only when every
detected product failed, and publishes the detected products in a
proxmox-detected-types state marker.
- installer: report_proxmox_registration_outcome reads that marker, waits
for an outcome from each detected product, and prints a success or denial
line per product instead of one verdict. Agents predating the marker keep
the old first-outcome-wins timing so a single-product host does not wait
out the window. The blocked-marker path now only fires on genuine refusals.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>