Add a supported least-privilege agent install profile

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.
This commit is contained in:
rcourtman
2026-08-20 20:49:44 +01:00
parent 74873e2b55
commit 4686efd8c8
29 changed files with 821 additions and 73 deletions
+48 -9
View File
@@ -2,15 +2,26 @@
Pulse agents incorporate several security mechanisms to ensure that the code running on your infrastructure is authentic and untampered with.
**Start with the least privilege that answers your monitoring question.** For
Proxmox VE, PBS, and PMG, that is usually no agent at all: API-only monitoring
with a read-only token covers inventory, status, and metrics, and the
generated setup script creates a privilege-separated monitoring user for it
(see [Proxmox Deployment Choices](#proxmox-deployment-choices)). Install a
host agent only where you want data the platform API cannot provide, and on
Linux consider the supported
[least-privilege profile](#least-privilege-agent-profile) before the root
default.
## Agent Privilege Model
Pulse's Linux/systemd installer runs the unified agent as `root` by default.
That is intentional for full host telemetry: disk SMART data, mdadm/RAID state,
temperature sensors, Docker or Podman socket reads, Proxmox host-local details
that are not available through the API, and some NAS/platform integrations
commonly require root or equivalent local privileges. Running the service as a
lower-privilege user may work for a narrow subset of metrics, but it is not a
supported full-telemetry profile today.
commonly require root or equivalent local privileges. On Linux/systemd hosts,
the supported alternative is the least-privilege profile documented below; it
trades the root-only collectors it has not been granted for a dedicated
non-root service user.
Treat a host agent like other infrastructure monitoring software with local
root read access:
@@ -123,12 +134,40 @@ mirrors the generated read/monitoring ACLs onto both the service user and the
token. For PBS, the generated script grants the `Audit` ACL to both the service
user and token.
Running `pulse-agent` as a custom non-root systemd user is possible by editing
the service unit, but it is not a supported full-telemetry mode today. Expect
gaps in SMART, temperature, Docker socket, ZFS/Ceph/mdadm, mount, and platform
integration data unless you deliberately grant equivalent capabilities or group
access. If you choose that route, treat it as a local hardening profile and
verify the exact metrics you care about after the change.
## Least-Privilege Agent Profile
On standard Linux systemd hosts, `install.sh --least-privilege` is a supported
alternative to the root profile. It runs the service as a dedicated
`pulse-agent` system user (nologin shell, owning only its state directory and
binary), joins the `docker` group when Docker monitoring is enabled so socket
reads keep working, and keeps every hardening directive of the root unit while
dropping the LXC-attach ambient capability grant entirely.
Two optional flags restore the collectors that genuinely need elevation, each
through an exact-command sudoers grant validated with `visudo` and a
root-owned wrapper the agent is pointed at via an absolute-path-only
environment override:
- `--grant-smart` allows exactly `smartctl`, restoring SMART disk health.
- `--grant-pct` allows exactly `pct list` and `pct df`, restoring Proxmox LXC
filesystem capacity. The grant deliberately excludes `pct exec`, `start`,
`stop`, and `enter`, so guest Docker inventory stays a root-profile feature.
What the profile gives up: command execution (`--enable-commands` is refused
and a later server-side enable requires reinstalling the root profile),
`pct exec` guest Docker inventory, and any platform integration that needs
device or socket access you have not granted. Core metrics, mounts, `/proc`
RAID state, hwmon temperatures, and Docker socket reads work without root.
Ungranted collectors fail soft, and the agent reports its privilege profile so
**Settings → Infrastructure → Agent Doctor** shows the service user and active
helpers instead of presenting missing collectors as a fault. Appliance
platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd init systems keep
the root profile; the installer refuses `--least-privilege` there rather than
silently falling back to root.
`--update` preserves an existing least-privilege profile and its grants
without the flags being repeated. Uninstall removes the sudoers file and
helpers; the inert system user is left behind deliberately.
## Supply-Chain Boundary
+7 -4
View File
@@ -48,10 +48,13 @@ The default posture limits that boundary:
- Proxmox guest Docker inventory through `pct exec` is disabled by default and
requires an explicit server setting.
A custom non-root systemd user is possible, but it is not currently a supported
full-telemetry profile. Expect gaps unless you deliberately grant equivalent
device, filesystem, or socket access. If API data is sufficient, API-only
monitoring is the cleaner least-privilege choice.
On standard Linux systemd hosts the installer also offers a supported
least-privilege profile: `--least-privilege` runs the service as a dedicated
`pulse-agent` system user, with optional `--grant-smart` and `--grant-pct`
flags that restore SMART and Proxmox LXC filesystem collection through
exact-command sudoers grants. Command execution and `pct exec` guest inventory
stay root-profile features. If API data is sufficient, API-only monitoring
remains the cleanest least-privilege choice of all — it needs no agent.
See [Agent Security](AGENT_SECURITY.md) for the precise command, guest-access,
update, and service-hardening boundaries.
@@ -6403,3 +6403,25 @@ enabled gate, and they do not enroll or select an agent, open or alter a
command session, change fleet policy or update state, or add any
agent-routed capability. Agent lifecycle obligations over `internal/api/`
are unchanged by this surface.
### Least-privilege agent profile
`scripts/install.sh --least-privilege` is a supported Linux/systemd install
profile that runs the unified agent as the dedicated nologin `pulse-agent`
system user instead of root. The profile is explicit at every boundary: it is
refused on appliance platforms and non-systemd init systems rather than
silently falling back to root, it is mutually exclusive with
`--enable-commands` (governed command execution stays a root-profile
capability), it never receives the LXC-attach ambient capability grant, and
`--update` preserves an existing profile and its grants by reading the
installed unit rather than requiring the flags to be repeated. Optional
`--grant-smart` and `--grant-pct` restore exactly the two collectors that
need elevation through visudo-validated, exact-command sudoers rules and
root-owned wrapper helpers the agent reaches only via the 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`. The agent
authors a `privilege` block in its report (`pkg/agents/host/report.go`
`PrivilegeStatus`: effective root, service user, active helpers) so the
server can present the profile descriptively. Uninstall removes the sudoers
file and helpers. `scripts/installtests/install_sh_test.go`
(`TestInstallSHLeastPrivilegeProfile`) pins the profile's invariants.
@@ -9644,3 +9644,13 @@ calls consume budget without becoming evidence, and exhausting the budget with
no success fails the run closed. Both paths are reductions after
execution-profile projection; neither external clients nor model output can
select or widen the surface.
The agent fleet diagnostics payload (`GET /api/agents/diagnostics`) now
carries an optional per-agent `privilege` object — `runningAsRoot`,
`serviceUser`, `smartctlHelper`, `pctHelper` — mirrored by the frontend
transport in `frontend-modern/src/api/agentDiagnostics.ts`
(`AgentFleetDiagnosticPrivilege`). The field is descriptive fleet evidence:
it appears only when the agent reported a profile, it never carries
credentials or paths, and consumers must not derive health status from it.
The unified agent report contract (`pkg/agents/host/report.go`) gains the
matching agent-authored `privilege` block with the same fields.
@@ -3461,3 +3461,19 @@ install unlicensed with an explicit warning, and `PULSE_PROVIDER_MSP_SKIP_EVAL_L
skips the request outright for air-gapped hosts. An existing evaluation licence
on disk is reused rather than re-requested. The install must never abort
because an evaluation licence could not be obtained.
### Least-privilege agent install profile
The unified agent installer (`scripts/install.sh`) offers
`--least-privilege` on standard Linux systemd hosts: a dedicated nologin
`pulse-agent` system user owns the service, state directory, and binary;
docker-group membership covers socket reads; and the optional
`--grant-smart` / `--grant-pct` flags install visudo-validated,
exact-command sudoers rules with root-owned wrapper helpers wired through
`PULSE_SMARTCTL_PATH` / `PULSE_PCT_PATH`. Installability boundaries: the
flag is refused (never silently downgraded to root) on appliance platforms
and non-systemd init systems, is mutually exclusive with
`--enable-commands`, and `--update` preserves an installed profile and its
grants by reading the existing unit. Uninstall removes the sudoers file and
helper directory. `scripts/installtests/install_sh_test.go`
(`TestInstallSHLeastPrivilegeProfile`) pins these invariants.
@@ -3063,3 +3063,20 @@ errored identity lookup. Monitoring does not reinterpret provider ownership or
invent lifecycle state; Alerts owns signal suppression and unified resources
owns persistence. `internal/monitoring/monitor_alert_intent_test.go` and the
alerts intent-policy proof pin this adapter boundary.
### Agent privilege profile is descriptive model state
Host reports may carry an agent-authored privilege profile (effective root,
service user, active smartctl/pct helpers). Ingest copies it verbatim into
`models.Host.AgentPrivilege` (trimming the user), state deep-copy isolates it
(`cloneHost`), the frontend host projection clones it, and the agent fleet
doctor surfaces it as the dedicated descriptive `privilege` field rather than
a health reason. A report without the block yields nil — the server never
invents a profile — and a non-root profile must never degrade agent health on
that evidence alone. Proofs:
`pkg/agents/host/report_test.go` (`TestAgentInfoPrivilegeStatusRoundTrip`),
`internal/monitoring/monitor_host_agents_test.go`
(`TestApplyHostReportCarriesAgentPrivilegeProfile`),
`internal/models/deepcopy_test.go` (`TestCloneHostIsolatesAgentPrivilege`),
`internal/monitoring/agent_fleet_doctor_test.go`
(`TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth`).
@@ -2278,3 +2278,15 @@ endpoint remains non-secret release metadata; this binding does not expose
inventory, credentials, update selections, or command authority.
`TestRunBindsPackagedVersionIdentity` in `pkg/server/server_test.go` pins the
wrapper-runtime boundary.
### Least-privilege agent install profile boundaries
The least-privilege agent install profile is a security boundary, not a
convenience flag: `install.sh --least-privilege` must keep the service user
non-root with a nologin shell, keep every sudoers grant exact-command and
visudo-validated with the pct grant excluding `pct exec`/`start`/`stop`/
`enter`, refuse `--enable-commands` under the profile, refuse unsupported
platforms instead of silently reverting to root, and drop the LXC-attach
ambient capability grant. The agent-reported privilege profile is
informational: the fleet doctor presents it descriptively and must not treat
a non-root agent as unhealthy on that evidence alone.
+19 -29
View File
@@ -1,28 +1,20 @@
{
"version": 1,
"base_sha": "a5f1567172314003ee1b96b93090ffcbbe18e8f6",
"verified_at": "2026-08-20T16:20:02Z",
"base_sha": "74873e2b558e4f0984c8edd86e51cd7b9dbac012",
"verified_at": "2026-08-20T19:44:49Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/notifications.ts",
"frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts",
"frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts",
"frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts",
"frontend-modern/src/utils/alertDestinationsPresentation.ts"
"frontend-modern/src/api/agentDiagnostics.ts",
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts"
],
"content_sha256": {
"frontend-modern/src/api/notifications.ts": "5bd796b6ec9222d6973a4a26b892b4fcfde11b2d9dd2e2ef705ad34531af0071",
"frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "7e7e4c5da2a4ea8c267c546da0064806949573bc57a51cf7c5b5f5f8576826ef",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "0411429355fd78e75948df8ede39da75f9f9e3600840d9b98f7342d2adbfad7b",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "c88336576ec08b69271244947f7892cac0b77e9ec68fdf33a12fe0f130e4e29d",
"frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts": "b9c959388a06ce146f3706cbc2b191449d4dd5380198d711ed15fdf06fdc48b9",
"frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts": "48ca901ec9435d94a06de5e55bf05dbf254cfd23114cd2b31383909d1585237b",
"frontend-modern/src/utils/alertDestinationsPresentation.ts": "8a2e9446dfbdd77d46ef6a678ff2aa403741e3d296ac8ef67ba708c8940a9b2a"
"frontend-modern/src/api/agentDiagnostics.ts": "f6ff4e1d6c29a0d141b973618d68263aad8e05a454c048469d0b5e3eb3cbb7ab",
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "f4334238aea3d20bd1b602b6313555fe9b8855d8e9aa344cd7be3fa8f9b86334",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "5300b2ad75a3223d520a392df463ce9e9096c8f29cf1c543a60c7327904c58ad"
},
"routes": [
"/alerts/notifications"
"/settings/infrastructure/agent-doctor"
],
"viewports": [
{
@@ -35,19 +27,17 @@
}
],
"states": [
"Fresh-install destinations tab (activation pending): AlertDeliveryPausedCard at top, Recent delivery activity card at bottom with honest empty state naming the 7-day window and the test-sends-skip-the-queue caveat",
"Delivery log with seeded audit rows served by the sidecar-built backend at GET /api/notifications/delivery-log: newest-first entries with Failed-retries-exhausted (red), Retrying (amber), Delivered (green) outcome badges",
"Webhook destination id (webhook:<id>) resolved to the configured webhook name 'Ops Sink'; email entry labeled 'Email' with its opaque destination hash",
"Failure detail line renders 'Authentication failure: HTTP 401 Unauthorized from https://hooks.example.test/notify?token=REDACTED' proving API-layer webhook secret redaction (seeded value was token=supersecret)",
"Mobile viewport: card and badges wrap with no horizontal overflow (documentElement.scrollWidth == 375)",
"No console errors on the destinations tab after load, test send, and delivery log refresh",
"Receipt re-pinned during rebase onto a5f156717 (copy-fix replayed onto fd35c5477 canonical search work): every staged blob hash above is byte-identical to the exercised content, and neither upstream commit touches any of these files"
"Agent Doctor with a live non-root agent report: the expanded agent row's metadata line reads 'Privilege: least privilege (rcourtman) · SMART helper active', derived end-to-end from a real pulse-agent process (built from this tree) running as an unprivileged user with PULSE_SMARTCTL_PATH set, reporting into the sidecar-built backend",
"The agent's 'Needs attention' status comes from genuine unrelated reasons (dev version not comparable to update target, docker module retrying because this Mac has no Docker socket) — the privilege profile itself raises no reason, matching the non-degrading contract",
"Mobile viewport 375x812: the privilege line wraps cleanly inside the expanded row with no horizontal overflow (documentElement.scrollWidth == 375); desktop 1280 same check passes",
"Backend /api/agents/diagnostics returns privilege {runningAsRoot:false, serviceUser:'rcourtman', smartctlHelper:true} for the reporting agent"
],
"interactions": [
"Logged into the isolated sidecar-built stack (backend :7811, vite :5487), opened Alerts > Notifications",
"Added webhook 'Ops Sink' -> http://127.0.0.1:18712/hook (allowlisted 127.0.0.1/32) and clicked Test: local sink logged the POST 200 and the UI raised the warning toast 'Test sent, but notification delivery is paused: real alerts are not being sent' instead of plain success, because the install's activation gate is pending",
"Saved the webhook via Add Webhook and confirmed GET /api/notifications/webhooks returns it",
"Seeded notification_audit rows (sent email, retry webhook, dead-letter webhook with token-bearing error text), clicked Refresh delivery status, and verified the three entries rendered newest-first with redacted error text",
"Resized to 375x812 and re-exercised the delivery log card"
"Rebuilt the scratch backend and pulse-agent from this tree, started the stack (backend :7811, vite :5487)",
"Minted a host-agent install token via POST /api/agent-install-command from the logged-in page and started pulse-agent as the unprivileged rcourtman user with --agent-id least-priv-verify and PULSE_SMARTCTL_PATH set",
"Opened Settings > Infrastructure > Agent Doctor, expanded the agent row, and located the rendered privilege line via DOM text walk at 1280x720 and 375x812",
"Read /api/agents/diagnostics from the page to confirm the wire payload carries the privilege block",
"Receipt hash for infrastructureAgentUpdateCommandsModel.ts re-pinned after the pre-commit prettier pass reformatted the staged blob; the change is formatting-only relative to the exercised content",
"Captured the mobile screenshot of the expanded row; stopped the scratch agent and both preview servers afterwards"
]
}
@@ -46,6 +46,15 @@ export interface AgentFleetDiagnosticModule {
updatedAt?: string;
}
// Agent-reported privilege profile. Descriptive only: a least-privilege
// install is an intentional hardening choice, never a health defect.
export interface AgentFleetDiagnosticPrivilege {
runningAsRoot: boolean;
serviceUser?: string;
smartctlHelper?: boolean;
pctHelper?: boolean;
}
export interface AgentFleetAgentDiagnostic {
/** Canonical `/api/connections` identifier. */
connectionId?: string;
@@ -74,6 +83,7 @@ export interface AgentFleetAgentDiagnostic {
deployedProfileVersion?: number;
agentUpdate?: AgentFleetDiagnosticUpdate;
agentModules?: AgentFleetDiagnosticModule[];
privilege?: AgentFleetDiagnosticPrivilege;
reasons: AgentFleetDiagnosticReason[];
repairActions?: AgentFleetDiagnosticRepair[];
}
@@ -500,6 +500,10 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
{' '}
· Updater: {target.updaterLabel}
</Show>
<Show when={target.privilegeLabel}>
{' '}
· Privilege: {target.privilegeLabel}
</Show>
<Show when={target.profileLabel}>
{' '}
· Profile: {target.profileLabel}
@@ -121,6 +121,46 @@ describe('Agent Doctor model', () => {
expect(targets[0].commandBlockedReason).toBeUndefined();
});
it('describes the reported privilege profile without treating least privilege as a fault', () => {
const connection = connectionFixture();
const [rootTarget] = collectInfrastructureAgentDoctorTargets({
rows: [rowFixture(connection)],
connections: [connection],
diagnostics: [diagnosticFixture({ privilege: { runningAsRoot: true } })],
diagnosticsAvailable: true,
targetVersion: '6.2.0',
});
expect(rootTarget.privilegeLabel).toBe('root');
const [leastPrivTarget] = collectInfrastructureAgentDoctorTargets({
rows: [rowFixture(connection)],
connections: [connection],
diagnostics: [
diagnosticFixture({
privilege: {
runningAsRoot: false,
serviceUser: 'pulse-agent',
smartctlHelper: true,
pctHelper: false,
},
}),
],
diagnosticsAvailable: true,
targetVersion: '6.2.0',
});
// Descriptive facts only: the service user and its active scoped helpers.
expect(leastPrivTarget.privilegeLabel).toBe(
'least privilege (pulse-agent) · SMART helper active',
);
// The profile itself must never be surfaced as an issue.
expect(
leastPrivTarget.reasons.some((reason) => reason.message.toLowerCase().includes('privilege')),
).toBe(false);
const report = formatInfrastructureAgentDoctorReport([leastPrivTarget]);
expect(report).toContain('Privilege least privilege (pulse-agent) · SMART helper active');
});
it('turns a missing credential into a token-gated authentication repair', () => {
const connection = connectionFixture({
agentUpdateAvailable: false,
@@ -38,6 +38,7 @@ export type InfrastructureAgentDoctorTarget = Omit<
updaterLabel?: string;
profileLabel?: string;
profileVersionLabel?: string;
privilegeLabel?: string;
lastSeen?: number | string | null;
source: 'diagnostics' | 'ledger-fallback' | 'removed';
};
@@ -623,6 +624,7 @@ const doctorTargetFromBinding = (
updaterLabel: updater.label,
profileLabel,
profileVersionLabel,
privilegeLabel: diagnosticPrivilegeLabel(diagnostic),
lastSeen: connection.lastSeen ?? diagnostic?.lastSeen,
source: diagnosticsAvailable && diagnostic ? 'diagnostics' : 'ledger-fallback',
};
@@ -665,6 +667,21 @@ const diagnosticProfileVersionLabel = (
return `Expected v${diagnostic.profileVersion} · deployed v${diagnostic.deployedProfileVersion}`;
};
// Descriptive privilege line for the doctor detail row. A least-privilege
// install is an intentional hardening profile, so this label states facts
// (service user, active scoped helpers) and never implies a defect.
const diagnosticPrivilegeLabel = (diagnostic?: AgentFleetAgentDiagnostic): string | undefined => {
const privilege = diagnostic?.privilege;
if (!privilege) return undefined;
if (privilege.runningAsRoot) return 'root';
const user = privilege.serviceUser?.trim();
const base = user ? `least privilege (${user})` : 'least privilege';
const helpers: string[] = [];
if (privilege.smartctlHelper) helpers.push('SMART helper active');
if (privilege.pctHelper) helpers.push('pct helper active');
return helpers.length > 0 ? `${base} · ${helpers.join(' · ')}` : base;
};
const diagnosticOnlyDoctorTarget = (
diagnostic: AgentFleetAgentDiagnostic,
): InfrastructureAgentDoctorTarget => {
@@ -685,6 +702,7 @@ const diagnosticOnlyDoctorTarget = (
commandPlatform: resolveKnownAgentCommandPlatform(diagnostic.platform),
profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined,
profileVersionLabel: diagnosticProfileVersionLabel(diagnostic),
privilegeLabel: diagnosticPrivilegeLabel(diagnostic),
lastSeen: diagnostic.lastSeen,
source: removed ? 'removed' : 'diagnostics',
};
@@ -845,6 +863,7 @@ export const formatInfrastructureAgentDoctorReport = (
const lastSeen = doctorReportLastSeen(target.lastSeen);
if (lastSeen) lines.push(` Last seen ${lastSeen}`);
if (target.updaterLabel) lines.push(` Updater ${target.updaterLabel}`);
if (target.privilegeLabel) lines.push(` Privilege ${target.privilegeLabel}`);
if (target.profileLabel) {
lines.push(
` Profile ${target.profileLabel}${
+1
View File
@@ -1214,6 +1214,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
AppliedConfig: runtimeConfig.appliedConfig,
Update: a.currentUpdateStatus(),
Modules: moduleStatus,
Privilege: collectPrivilegeStatus(),
},
Host: agentshost.HostInfo{
ID: a.machineID,
+27
View File
@@ -0,0 +1,27 @@
package hostagent
import (
"os"
"os/user"
"strings"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
)
// collectPrivilegeStatus reports the privilege the agent actually runs with.
// The values are facts about this process, not configuration: effective uid,
// the service account name, and whether the scoped privilege-helper overrides
// a least-privilege install configures are in effect. On Windows Geteuid is
// always -1, so RunningAsRoot stays false and ServiceUser carries the account
// name; the server renders the profile descriptively rather than judging it.
func collectPrivilegeStatus() *agentshost.PrivilegeStatus {
status := &agentshost.PrivilegeStatus{
RunningAsRoot: os.Geteuid() == 0,
SmartctlHelper: strings.TrimSpace(os.Getenv("PULSE_SMARTCTL_PATH")) != "",
PctHelper: strings.TrimSpace(os.Getenv("PULSE_PCT_PATH")) != "",
}
if current, err := user.Current(); err == nil {
status.ServiceUser = strings.TrimSpace(current.Username)
}
return status
}
+72
View File
@@ -0,0 +1,72 @@
package hostagent
import (
"os"
"testing"
)
func TestCollectPrivilegeStatusReportsProcessFacts(t *testing.T) {
t.Setenv("PULSE_SMARTCTL_PATH", "")
t.Setenv("PULSE_PCT_PATH", "")
status := collectPrivilegeStatus()
if status == nil {
t.Fatal("collectPrivilegeStatus returned nil")
}
if status.RunningAsRoot != (os.Geteuid() == 0) {
t.Fatalf("RunningAsRoot = %v, euid = %d", status.RunningAsRoot, os.Geteuid())
}
if status.ServiceUser == "" {
t.Fatal("ServiceUser is empty")
}
if status.SmartctlHelper || status.PctHelper {
t.Fatalf("helper flags set without overrides: %+v", status)
}
}
func TestCollectPrivilegeStatusReportsHelperOverrides(t *testing.T) {
t.Setenv("PULSE_SMARTCTL_PATH", "/usr/local/lib/pulse-agent/smartctl-helper")
t.Setenv("PULSE_PCT_PATH", "/usr/local/lib/pulse-agent/pct-helper")
status := collectPrivilegeStatus()
if !status.SmartctlHelper || !status.PctHelper {
t.Fatalf("helper overrides not reported: %+v", status)
}
}
func TestResolvePctPathHonorsAbsoluteOverride(t *testing.T) {
t.Setenv("PULSE_PCT_PATH", "/usr/local/lib/pulse-agent/pct-helper")
resolved, err := resolvePctPath(func(string) (string, error) {
t.Fatal("lookPath must not be consulted when the override is set")
return "", nil
})
if err != nil {
t.Fatalf("resolvePctPath: %v", err)
}
if resolved != "/usr/local/lib/pulse-agent/pct-helper" {
t.Fatalf("resolved = %q", resolved)
}
}
func TestResolvePctPathRejectsRelativeOverride(t *testing.T) {
t.Setenv("PULSE_PCT_PATH", "bin/pct")
if _, err := resolvePctPath(func(string) (string, error) { return "/usr/sbin/pct", nil }); err == nil {
t.Fatal("relative PULSE_PCT_PATH accepted; a PATH-relative helper could be hijacked")
}
}
func TestResolvePctPathFallsBackToLookPath(t *testing.T) {
t.Setenv("PULSE_PCT_PATH", "")
resolved, err := resolvePctPath(func(name string) (string, error) {
if name != "pct" {
t.Fatalf("lookPath(%q)", name)
}
return "/usr/sbin/pct", nil
})
if err != nil || resolved != "/usr/sbin/pct" {
t.Fatalf("resolved = %q, err = %v", resolved, err)
}
}
+16 -1
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -17,6 +18,20 @@ import (
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
)
// resolvePctPath mirrors resolveSmartctlPath: PULSE_PCT_PATH lets a
// least-privilege install point the read-only pct list/df queries at a scoped
// privilege helper instead of requiring the whole agent to run as root. The
// override must be absolute so a PATH-relative name cannot be hijacked.
func resolvePctPath(lookPath func(string) (string, error)) (string, error) {
if configured := strings.TrimSpace(os.Getenv("PULSE_PCT_PATH")); configured != "" {
if !filepath.IsAbs(configured) {
return "", fmt.Errorf("PULSE_PCT_PATH must be an absolute path")
}
return configured, nil
}
return lookPath("pct")
}
const (
proxmoxLXCQueryTimeout = 10 * time.Second
proxmoxLXCMaxContainers = 128
@@ -40,7 +55,7 @@ func (a *Agent) collectProxmoxLXCFilesystems(ctx context.Context) *agentshost.Pr
if a.collector.GOOS() != "linux" {
return nil
}
pctPath, err := a.collector.LookPath("pct")
pctPath, err := resolvePctPath(a.collector.LookPath)
if err != nil {
if !errors.Is(err, exec.ErrNotFound) && !os.IsNotExist(err) {
a.logger.Debug().Err(err).Msg("Failed to locate pct for Proxmox LXC filesystems")
+9
View File
@@ -513,6 +513,7 @@ func (h Host) ToFrontend() HostFrontend {
AppliedConfig: cloneAgentConfigFingerprint(h.AppliedConfig),
AgentUpdate: cloneAgentUpdateStatus(h.AgentUpdate),
AgentModules: cloneAgentModuleStatuses(h.AgentModules),
AgentPrivilege: cloneAgentPrivilegeStatus(h.AgentPrivilege),
PackageUpdates: cloneHostPackageUpdateStatus(h.PackageUpdates),
StorageCleanup: cloneHostStorageCleanupStatus(h.StorageCleanup),
IsLegacy: h.IsLegacy,
@@ -590,6 +591,14 @@ func cloneAgentModuleStatuses(values []AgentModuleStatus) []AgentModuleStatus {
return append([]AgentModuleStatus(nil), values...)
}
func cloneAgentPrivilegeStatus(value *AgentPrivilegeStatus) *AgentPrivilegeStatus {
if value == nil {
return nil
}
copy := *value
return &copy
}
// ToFrontend converts a DockerContainer to DockerContainerFrontend
func (c DockerContainer) ToFrontend() DockerContainerFrontend {
container := DockerContainerFrontend{
+1
View File
@@ -410,6 +410,7 @@ func cloneHost(src Host) Host {
dest.Tags = append([]string(nil), src.Tags...)
dest.DiskExclude = append([]string(nil), src.DiskExclude...)
dest.IdentityConflict = cloneHostIdentityConflict(src.IdentityConflict)
dest.AgentPrivilege = cloneAgentPrivilegeStatus(src.AgentPrivilege)
return dest.NormalizeCollections()
}
+15
View File
@@ -66,6 +66,21 @@ func TestCloneHostAndZFSPoolIsolateZFSDatasets(t *testing.T) {
}
}
func TestCloneHostIsolatesAgentPrivilege(t *testing.T) {
host := Host{AgentPrivilege: &AgentPrivilegeStatus{
RunningAsRoot: false,
ServiceUser: "pulse-agent",
}}
hostClone := cloneHost(host)
if hostClone.AgentPrivilege == nil {
t.Fatal("host clone dropped agent privilege")
}
hostClone.AgentPrivilege.ServiceUser = "mutated"
if host.AgentPrivilege.ServiceUser != "pulse-agent" {
t.Fatal("host clone aliased agent privilege")
}
}
func TestCloneDockerContainer_PreservesIndependentOOMEvidence(t *testing.T) {
oomKilled := false
src := DockerContainer{
+13
View File
@@ -332,6 +332,7 @@ type Host struct {
AppliedConfig *AgentConfigFingerprint `json:"appliedConfig,omitempty"`
AgentUpdate *AgentUpdateStatus `json:"agentUpdate,omitempty"`
AgentModules []AgentModuleStatus `json:"agentModules,omitempty"`
AgentPrivilege *AgentPrivilegeStatus `json:"agentPrivilege,omitempty"`
// IntegrationSource names the platform integration ("vmware", "truenas", ...)
// that supplies this host's telemetry when no Pulse Agent reports for it.
// Empty means Pulse-Agent-backed. Only unified-fabric snapshots populate it;
@@ -411,6 +412,18 @@ type AgentModuleStatus struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// AgentPrivilegeStatus is the agent-reported privilege profile: whether the
// service runs as root, the service account, and whether the scoped
// smartctl/pct privilege helpers of a least-privilege install are in effect.
// Informational only — a non-root agent is an intentional profile, not a
// health defect.
type AgentPrivilegeStatus struct {
RunningAsRoot bool `json:"runningAsRoot"`
ServiceUser string `json:"serviceUser,omitempty"`
SmartctlHelper bool `json:"smartctlHelper,omitempty"`
PctHelper bool `json:"pctHelper,omitempty"`
}
func (h Host) NormalizeCollections() Host {
if h.LoadAverage == nil {
h.LoadAverage = []float64{}
+1
View File
@@ -730,6 +730,7 @@ type HostFrontend struct {
AppliedConfig *AgentConfigFingerprint `json:"appliedConfig,omitempty"`
AgentUpdate *AgentUpdateStatus `json:"agentUpdate,omitempty"`
AgentModules []AgentModuleStatus `json:"agentModules,omitempty"`
AgentPrivilege *AgentPrivilegeStatus `json:"agentPrivilege,omitempty"`
PackageUpdates *HostPackageUpdateStatus `json:"packageUpdates,omitempty"`
StorageCleanup *HostStorageCleanupStatus `json:"storageCleanup,omitempty"`
IsLegacy bool `json:"isLegacy,omitempty"` // True if using legacy agent protocol
+54 -28
View File
@@ -65,34 +65,35 @@ type AgentFleetDiagnosticSummary struct {
}
type AgentFleetAgentDiagnostic struct {
RowKey string `json:"rowKey"`
ID string `json:"id"`
AgentID string `json:"agentId,omitempty"`
ConnectionID string `json:"connectionId,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
Platform string `json:"platform,omitempty"`
OSName string `json:"osName,omitempty"`
OSVersion string `json:"osVersion,omitempty"`
KernelVersion string `json:"kernelVersion,omitempty"`
Architecture string `json:"architecture,omitempty"`
MachineIDFingerprint string `json:"machineIdFingerprint,omitempty"`
ReportIP string `json:"reportIp,omitempty"`
InterfaceAddresses []string `json:"interfaceAddresses,omitempty"`
Types []string `json:"types"`
Status string `json:"status"`
RawStatus string `json:"rawStatus,omitempty"`
LastSeen int64 `json:"lastSeen,omitempty"`
IntervalSeconds int `json:"intervalSeconds,omitempty"`
Version string `json:"version,omitempty"`
ProfileID string `json:"profileId,omitempty"`
ProfileName string `json:"profileName,omitempty"`
ProfileVersion int `json:"profileVersion,omitempty"`
DeployedProfileVersion int `json:"deployedProfileVersion,omitempty"`
AgentUpdate *AgentFleetDiagnosticUpdate `json:"agentUpdate,omitempty"`
AgentModules []AgentFleetDiagnosticModule `json:"agentModules,omitempty"`
Reasons []AgentFleetDiagnosticReason `json:"reasons"`
RepairActions []AgentFleetDiagnosticRepair `json:"repairActions,omitempty"`
RowKey string `json:"rowKey"`
ID string `json:"id"`
AgentID string `json:"agentId,omitempty"`
ConnectionID string `json:"connectionId,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
Platform string `json:"platform,omitempty"`
OSName string `json:"osName,omitempty"`
OSVersion string `json:"osVersion,omitempty"`
KernelVersion string `json:"kernelVersion,omitempty"`
Architecture string `json:"architecture,omitempty"`
MachineIDFingerprint string `json:"machineIdFingerprint,omitempty"`
ReportIP string `json:"reportIp,omitempty"`
InterfaceAddresses []string `json:"interfaceAddresses,omitempty"`
Types []string `json:"types"`
Status string `json:"status"`
RawStatus string `json:"rawStatus,omitempty"`
LastSeen int64 `json:"lastSeen,omitempty"`
IntervalSeconds int `json:"intervalSeconds,omitempty"`
Version string `json:"version,omitempty"`
ProfileID string `json:"profileId,omitempty"`
ProfileName string `json:"profileName,omitempty"`
ProfileVersion int `json:"profileVersion,omitempty"`
DeployedProfileVersion int `json:"deployedProfileVersion,omitempty"`
AgentUpdate *AgentFleetDiagnosticUpdate `json:"agentUpdate,omitempty"`
AgentModules []AgentFleetDiagnosticModule `json:"agentModules,omitempty"`
Privilege *AgentFleetDiagnosticPrivilege `json:"privilege,omitempty"`
Reasons []AgentFleetDiagnosticReason `json:"reasons"`
RepairActions []AgentFleetDiagnosticRepair `json:"repairActions,omitempty"`
}
type AgentFleetDiagnosticUpdate struct {
@@ -114,6 +115,17 @@ type AgentFleetDiagnosticModule struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// AgentFleetDiagnosticPrivilege is the agent-reported privilege profile,
// surfaced descriptively beside modules and update state. It is deliberately
// not a Reason: a least-privilege install is an intentional hardening choice,
// so it must never degrade the agent's health status by itself.
type AgentFleetDiagnosticPrivilege struct {
RunningAsRoot bool `json:"runningAsRoot"`
ServiceUser string `json:"serviceUser,omitempty"`
SmartctlHelper bool `json:"smartctlHelper,omitempty"`
PctHelper bool `json:"pctHelper,omitempty"`
}
type AgentFleetDiagnosticReason struct {
Code string `json:"code"`
Severity string `json:"severity"`
@@ -420,6 +432,7 @@ func diagnoseAgentFleetSubject(
Version: subject.version,
AgentUpdate: agentFleetUpdateForSubject(subject),
AgentModules: agentFleetModulesForSubject(subject),
Privilege: agentFleetPrivilegeForSubject(subject),
}
if !subject.lastSeen.IsZero() {
result.LastSeen = subject.lastSeen.UnixMilli()
@@ -917,6 +930,19 @@ func agentFleetUpdateForSubject(subject agentFleetSubject) *AgentFleetDiagnostic
}
}
func agentFleetPrivilegeForSubject(subject agentFleetSubject) *AgentFleetDiagnosticPrivilege {
if subject.host == nil || subject.host.AgentPrivilege == nil {
return nil
}
privilege := subject.host.AgentPrivilege
return &AgentFleetDiagnosticPrivilege{
RunningAsRoot: privilege.RunningAsRoot,
ServiceUser: strings.TrimSpace(privilege.ServiceUser),
SmartctlHelper: privilege.SmartctlHelper,
PctHelper: privilege.PctHelper,
}
}
func agentFleetModulesForSubject(subject agentFleetSubject) []AgentFleetDiagnosticModule {
if subject.host == nil || len(subject.host.AgentModules) == 0 {
return nil
@@ -363,6 +363,47 @@ func TestAgentFleetDiagnosticsSurfacesReportedUpdateModuleAndIdentityEvidence(t
}
}
// A least-privilege install is an intentional hardening profile: the doctor
// must surface the reported privilege descriptively and must not degrade the
// agent's health status on that evidence alone.
func TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth(t *testing.T) {
now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
monitor := newAgentFleetDoctorTestMonitor(t)
monitor.state.UpsertHost(models.Host{
ID: "agent-least-priv",
Hostname: "pve-node",
Status: "online",
LastSeen: now.Add(-30 * time.Second),
IntervalSeconds: 30,
AgentVersion: "6.2.0",
AgentPrivilege: &models.AgentPrivilegeStatus{
RunningAsRoot: false,
ServiceUser: "pulse-agent",
SmartctlHelper: true,
PctHelper: false,
},
})
diagnostics := monitor.GetAgentFleetDiagnosticsForTarget("6.2.0", "6.2.0", now)
agent := requireAgentDiagnostic(t, diagnostics, "agent-agent-least-priv")
if agent.Privilege == nil {
t.Fatal("privilege profile missing from diagnostics")
}
if agent.Privilege.RunningAsRoot || agent.Privilege.ServiceUser != "pulse-agent" ||
!agent.Privilege.SmartctlHelper || agent.Privilege.PctHelper {
t.Fatalf("privilege profile = %+v", agent.Privilege)
}
if agent.Status != AgentFleetStatusHealthy {
t.Fatalf("least-privilege agent status = %q, want healthy; reasons = %+v", agent.Status, agent.Reasons)
}
for _, reason := range agent.Reasons {
if strings.Contains(strings.ToLower(reason.Message), "privilege") {
t.Fatalf("privilege surfaced as a health reason: %+v", reason)
}
}
}
func TestAgentFleetDiagnosticsUpdaterReasonCodes(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
tests := []struct {
+16
View File
@@ -2993,6 +2993,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
AppliedConfig: convertAgentConfigFingerprint(report.Agent.AppliedConfig),
AgentUpdate: agentUpdate,
AgentModules: convertAgentModuleStatuses(report.Agent.Modules),
AgentPrivilege: convertAgentPrivilegeStatus(report.Agent.Privilege),
PackageUpdates: convertHostPackageUpdateStatus(report.Host.PackageUpdates, observedAt),
StorageCleanup: convertHostStorageCleanupStatus(report.Host.StorageCleanup, observedAt),
IsLegacy: isLegacyAgent(report.Agent.Type),
@@ -3382,6 +3383,21 @@ func convertAgentModuleStatuses(values []agentshost.ModuleStatus) []models.Agent
return result
}
// convertAgentPrivilegeStatus copies the agent-authored privilege profile
// into model state. It is informational only: nothing downstream may treat a
// non-root agent as unhealthy on this evidence alone.
func convertAgentPrivilegeStatus(value *agentshost.PrivilegeStatus) *models.AgentPrivilegeStatus {
if value == nil {
return nil
}
return &models.AgentPrivilegeStatus{
RunningAsRoot: value.RunningAsRoot,
ServiceUser: strings.TrimSpace(value.ServiceUser),
SmartctlHelper: value.SmartctlHelper,
PctHelper: value.PctHelper,
}
}
func previousHostAgentUpdate(hosts []models.Host, identifier string) *models.AgentUpdateStatus {
for i := range hosts {
if hosts[i].ID == identifier {
@@ -5066,3 +5066,48 @@ func TestMockModeDiscardsRealHostReports(t *testing.T) {
}
})
}
// The agent-authored privilege profile must survive ingest into model state
// exactly, and a report without one must not invent a profile.
func TestApplyHostReportCarriesAgentPrivilegeProfile(t *testing.T) {
report := agentshost.Report{
Agent: agentshost.AgentInfo{
ID: "privilege-agent",
Version: "6.3.0",
Type: "unified",
IntervalSeconds: 30,
Privilege: &agentshost.PrivilegeStatus{
RunningAsRoot: false,
ServiceUser: " pulse-agent ",
SmartctlHelper: true,
},
},
Host: agentshost.HostInfo{ID: "privilege-machine", Hostname: "privilege-host", Platform: "linux"},
Timestamp: time.Now().UTC(),
}
monitor := newTestMonitor(t)
host, err := monitor.ApplyHostReport(report, &config.APITokenRecord{ID: "privilege-token"})
if err != nil {
t.Fatalf("ApplyHostReport: %v", err)
}
if host.AgentPrivilege == nil ||
host.AgentPrivilege.RunningAsRoot ||
host.AgentPrivilege.ServiceUser != "pulse-agent" ||
!host.AgentPrivilege.SmartctlHelper ||
host.AgentPrivilege.PctHelper {
t.Fatalf("ingested privilege = %+v", host.AgentPrivilege)
}
report.Agent.Privilege = nil
report.Agent.ID = "privilege-agent-legacy"
report.Host.ID = "privilege-machine-legacy"
report.Host.Hostname = "privilege-host-legacy"
legacyHost, err := monitor.ApplyHostReport(report, &config.APITokenRecord{ID: "privilege-token"})
if err != nil {
t.Fatalf("ApplyHostReport legacy: %v", err)
}
if legacyHost.AgentPrivilege != nil {
t.Fatalf("legacy report invented a privilege profile: %+v", legacyHost.AgentPrivilege)
}
}
+19
View File
@@ -167,6 +167,25 @@ type AgentInfo struct {
AppliedConfig *ConfigFingerprint `json:"appliedConfig,omitempty"`
Update *UpdateStatus `json:"update,omitempty"`
Modules []ModuleStatus `json:"modules,omitempty"`
Privilege *PrivilegeStatus `json:"privilege,omitempty"`
}
// PrivilegeStatus is the agent-authored view of the privilege it actually runs
// with, so the server can present a least-privilege install as an intentional
// profile instead of a broken agent. It is informational only: it never grants
// anything, and the server must not treat a non-root agent as unhealthy on
// this evidence alone.
type PrivilegeStatus struct {
RunningAsRoot bool `json:"runningAsRoot"`
ServiceUser string `json:"serviceUser,omitempty"`
// SmartctlHelper reports that a PULSE_SMARTCTL_PATH override is in effect,
// which a least-privilege install uses to route SMART reads through a
// scoped privilege helper.
SmartctlHelper bool `json:"smartctlHelper,omitempty"`
// PctHelper reports that a PULSE_PCT_PATH override is in effect, which a
// least-privilege install on a Proxmox node uses to route the read-only
// pct list/df queries through a scoped privilege helper.
PctHelper bool `json:"pctHelper,omitempty"`
}
// ModuleStatus describes whether an enabled Unified Agent module initialized
+38
View File
@@ -126,6 +126,44 @@ func TestCustomSensorMetricJSONRoundTrip(t *testing.T) {
}
}
// The privilege block is agent-authored fact, not configuration: it must
// survive a JSON round trip exactly, and a legacy report without it must
// decode to nil rather than a zero-value profile pretending to be evidence.
func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) {
agent := AgentInfo{
ID: "agent-privilege",
Privilege: &PrivilegeStatus{
RunningAsRoot: false,
ServiceUser: "pulse-agent",
SmartctlHelper: true,
PctHelper: false,
},
}
encoded, err := json.Marshal(agent)
if err != nil {
t.Fatal(err)
}
var decoded AgentInfo
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Privilege == nil ||
decoded.Privilege.RunningAsRoot ||
decoded.Privilege.ServiceUser != "pulse-agent" ||
!decoded.Privilege.SmartctlHelper ||
decoded.Privilege.PctHelper {
t.Fatalf("privilege round trip = %+v", decoded.Privilege)
}
var legacy AgentInfo
if err := json.Unmarshal([]byte(`{"id":"legacy"}`), &legacy); err != nil {
t.Fatal(err)
}
if legacy.Privilege != nil {
t.Fatalf("legacy report invented a privilege profile: %+v", legacy.Privilege)
}
}
func TestAgentInfo_Fields(t *testing.T) {
agent := AgentInfo{
ID: "agent-123",
+184 -1
View File
@@ -26,6 +26,9 @@
# --server-fingerprint <sha256> Pin the Pulse server leaf certificate
# --observers-file <path> Report to additional observer Pulse instances
# --enable-commands Enable Pulse command execution on agent (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory)
# --least-privilege Run the agent as the dedicated 'pulse-agent' system user instead of root (Linux systemd only; SMART, Proxmox LXC filesystems, and command execution need root or an explicit grant)
# --grant-smart With --least-privilege: allow SMART collection through an exact-command sudoers grant for smartctl
# --grant-pct With --least-privilege: allow Proxmox LXC filesystem capacity through a sudoers grant restricted to 'pct list' and 'pct df'
# --health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191, use "" to disable)
# --update Update an existing agent using saved connection state
# --uninstall Remove the agent
@@ -117,6 +120,17 @@ ROOTLESS_RUNTIME_SOCKET_PATH=""
ROOTLESS_RUNTIME_SOCKET_URI=""
ROOTLESS_RUNTIME_XDG_DIR=""
# Least-privilege profile: run the service as a dedicated system user instead
# of root, with optional exact-command sudo helpers for the two collectors
# that genuinely need elevation (smartctl, pct list/df). Linux systemd only.
LEAST_PRIVILEGE="false"
GRANT_SMART="false"
GRANT_PCT="false"
SERVICE_USER="root"
LEAST_PRIVILEGE_USER="pulse-agent"
PRIVILEGE_HELPER_DIR="/usr/local/lib/pulse-agent"
PRIVILEGE_SUDOERS_FILE="/etc/sudoers.d/pulse-agent"
SYSTEMD_ENV_LINES=""
SHELL_EXPORT_LINES=""
PLIST_ENV_ENTRIES=""
@@ -481,6 +495,9 @@ Options:
--server-fingerprint <sha256> Pin the Pulse server leaf certificate for agent connections
--observers-file <path> Absolute path to private JSON config for report-only observer Pulse destinations
--enable-commands Enable Pulse command execution (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory)
--least-privilege Run the agent as the 'pulse-agent' system user instead of root (Linux systemd only)
--grant-smart With --least-privilege: exact-command sudoers grant so SMART collection keeps working
--grant-pct With --least-privilege: sudoers grant restricted to 'pct list'/'pct df' so Proxmox LXC filesystem capacity keeps working
--health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191; use "" to disable)
--enroll Exchange bootstrap token for runtime token (deploy wizard)
--update Update an existing agent using saved connection state
@@ -1081,6 +1098,12 @@ systemd_agent_requires_lxc_attach() {
# from the server after the fact, so the unit has to be provisioned for it up
# front or the later toggle produces a half-working agent.
systemd_agent_may_attach_lxc() {
# The least-privilege profile never attaches into guests: pct exec and
# command execution stay root-profile features, so it does not get the
# CAP_SETUID/CAP_SETGID ambient grant either.
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
return 1
fi
if [[ "$ENABLE_PROXMOX" != "true" ]]; then
return 1
fi
@@ -2955,6 +2978,9 @@ while [[ $# -gt 0 ]]; do
--server-fingerprint) SERVER_FINGERPRINT="$2"; shift 2 ;;
--observers-file) OBSERVERS_FILE="$2"; shift 2 ;;
--enable-commands) ENABLE_COMMANDS="true"; shift ;;
--least-privilege) LEAST_PRIVILEGE="true"; shift ;;
--grant-smart) GRANT_SMART="true"; shift ;;
--grant-pct) GRANT_PCT="true"; shift ;;
--health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;;
--enroll) ENROLL="true"; shift ;;
--update) UPDATE_ONLY="true"; shift ;;
@@ -3011,6 +3037,13 @@ if [[ -n "$PROXMOX_TYPE" && "$PROXMOX_TYPE" != "pve" && "$PROXMOX_TYPE" != "pbs"
fail "Invalid --proxmox-type value: ${PROXMOX_TYPE} (expected 'pve' or 'pbs')"
fi
if [[ "$GRANT_SMART" == "true" || "$GRANT_PCT" == "true" ]] && [[ "$LEAST_PRIVILEGE" != "true" ]]; then
fail "--grant-smart and --grant-pct only apply with --least-privilege (a root agent needs no grants)" "$EXIT_MISSING_ARGS"
fi
if [[ "$LEAST_PRIVILEGE" == "true" && "$ENABLE_COMMANDS" == "true" ]]; then
fail "--least-privilege and --enable-commands are mutually exclusive: governed command execution requires the root profile" "$EXIT_MISSING_ARGS"
fi
# --- Check Root ---
if [[ $EUID -ne 0 && "$PREFLIGHT_ONLY" != "true" ]]; then
echo "This script must be run as root. Please use sudo."
@@ -3319,6 +3352,12 @@ if [[ "$UNINSTALL" == "true" ]]; then
# Remove agent state directory (contains agent ID, proxmox registration state, etc.)
remove_agent_state_dir "$STATE_DIR"
# Remove least-privilege helper artifacts. The pulse-agent system user is
# deliberately left behind: deleting accounts can orphan files elsewhere,
# and an inert nologin system user is harmless.
rm -f "$PRIVILEGE_SUDOERS_FILE"
rm -rf "$PRIVILEGE_HELPER_DIR"
# Remove log files
rm -f /var/log/pulse-agent.log
@@ -3492,6 +3531,121 @@ is_install_dir_writable() {
return 1
}
# The least-privilege profile is supported only on standard Linux systemd
# hosts: appliance platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd
# init systems keep the root profile because their service managers, mounts,
# or vendor tooling assume it. Failing here is deliberate — a flag that
# silently falls back to root would defeat its purpose.
if [[ "$LEAST_PRIVILEGE" == "true" && "$UNINSTALL" != "true" ]]; then
if [[ "$(uname -s)" != "Linux" ]] || ! command -v systemctl >/dev/null 2>&1 ||
is_truenas || [[ -d /usr/syno ]] || [[ -f /etc/unraid-version ]] ||
[[ -d /boot/config/plugins ]] || [[ -x /sbin/getcfg ]]; then
fail "--least-privilege is supported only on standard Linux systemd hosts. This platform keeps the root profile; see docs/AGENT_SECURITY.md for the per-platform privilege model." "$EXIT_MISSING_ARGS"
fi
fi
# Create the dedicated service account for the least-privilege profile and
# hand it the state directory plus the agent binary (self-update swaps the
# binary in place, so the service user must own it).
provision_least_privilege_user() {
if ! id -u "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1; then
if ! command -v useradd >/dev/null 2>&1; then
fail "--least-privilege needs useradd to create the ${LEAST_PRIVILEGE_USER} system user" "$EXIT_MISSING_ARGS"
fi
local nologin_shell="/usr/sbin/nologin"
if [[ ! -x "$nologin_shell" ]]; then
nologin_shell="/sbin/nologin"
fi
if [[ ! -x "$nologin_shell" ]]; then
nologin_shell="/bin/false"
fi
useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home \
--shell "$nologin_shell" "$LEAST_PRIVILEGE_USER" ||
fail "Failed to create the ${LEAST_PRIVILEGE_USER} system user" "$EXIT_MISSING_ARGS"
log_info "Created system user ${LEAST_PRIVILEGE_USER}"
fi
# Docker/Podman socket reads need group membership, not root. Auto-detect
# mirrors the module default: only an explicit --disable-docker skips it.
if [[ "$ENABLE_DOCKER" != "false" ]] && getent group docker >/dev/null 2>&1; then
usermod -aG docker "$LEAST_PRIVILEGE_USER" 2>/dev/null || true
fi
chown -R "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "$STATE_DIR" 2>/dev/null || true
chown "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
}
# Write one privilege helper: a root-owned wrapper that execs the real binary
# through sudo -n, plus the sudoers rule that makes exactly that invocation
# possible. The agent is pointed at the wrapper via an env override that only
# accepts absolute paths.
write_privilege_helper() {
local helper_name="$1"
local real_path="$2"
local sudoers_spec="$3"
local helper_path="${PRIVILEGE_HELPER_DIR}/${helper_name}"
mkdir -p "$PRIVILEGE_HELPER_DIR"
chmod 755 "$PRIVILEGE_HELPER_DIR"
cat > "$helper_path" <<HELPER
#!/bin/sh
# Pulse least-privilege helper: runs ${real_path} through the exact-command
# sudoers grant in ${PRIVILEGE_SUDOERS_FILE}. Managed by install.sh.
exec sudo -n ${real_path} "\$@"
HELPER
chown root:root "$helper_path"
chmod 755 "$helper_path"
PRIVILEGE_SUDOERS_CONTENT+="${LEAST_PRIVILEGE_USER} ALL=(root) NOPASSWD: ${sudoers_spec}"$'\n'
}
# Install the requested sudo grants and point the agent at the wrappers.
provision_privilege_helpers() {
if [[ "$GRANT_SMART" != "true" && "$GRANT_PCT" != "true" ]]; then
return 0
fi
if ! command -v sudo >/dev/null 2>&1; then
fail "--grant-smart/--grant-pct need sudo installed on this host" "$EXIT_MISSING_ARGS"
fi
PRIVILEGE_SUDOERS_CONTENT="# Pulse least-privilege agent grants. Managed by install.sh."$'\n'
if [[ "$GRANT_SMART" == "true" ]]; then
local smartctl_path
smartctl_path="$(command -v smartctl 2>/dev/null || true)"
if [[ -z "$smartctl_path" ]]; then
fail "--grant-smart requires smartctl (smartmontools) on this host" "$EXIT_MISSING_ARGS"
fi
write_privilege_helper "smartctl" "$smartctl_path" "$smartctl_path"
append_service_env "PULSE_SMARTCTL_PATH" "${PRIVILEGE_HELPER_DIR}/smartctl"
fi
if [[ "$GRANT_PCT" == "true" ]]; then
local pct_path
pct_path="$(command -v pct 2>/dev/null || true)"
if [[ -z "$pct_path" ]]; then
fail "--grant-pct requires the Proxmox pct tool on this host" "$EXIT_MISSING_ARGS"
fi
# Restricted to the two read-only queries the collector issues. This
# deliberately does NOT cover pct exec, start, stop, or enter.
write_privilege_helper "pct" "$pct_path" "${pct_path} list, ${pct_path} df *"
append_service_env "PULSE_PCT_PATH" "${PRIVILEGE_HELPER_DIR}/pct"
fi
local sudoers_tmp
sudoers_tmp="$(mktemp)"
printf '%s' "$PRIVILEGE_SUDOERS_CONTENT" > "$sudoers_tmp"
if command -v visudo >/dev/null 2>&1; then
if ! visudo -cf "$sudoers_tmp" >/dev/null 2>&1; then
rm -f "$sudoers_tmp"
fail "Generated sudoers rules failed visudo validation; not installing them" "$EXIT_MISSING_ARGS"
fi
fi
install -o root -g root -m 0440 "$sudoers_tmp" "$PRIVILEGE_SUDOERS_FILE"
rm -f "$sudoers_tmp"
log_info "Installed scoped sudoers grants at ${PRIVILEGE_SUDOERS_FILE}"
}
if [[ "$(uname -s)" == "Linux" ]] && is_truenas; then
TRUENAS=true
INSTALL_DIR="$TRUENAS_STATE_DIR"
@@ -4383,13 +4537,42 @@ if command -v systemctl >/dev/null 2>&1; then
TOKEN_FILE="${TOKEN_DIR}/token"
log_info "Configuring Systemd service at $UNIT..."
# A least-privilege install must survive updates that do not repeat the
# flags: recover the profile and its grants from the existing unit before
# rendering a replacement, so an --update never silently reverts the
# service to root.
if [[ -f "$UNIT" ]]; then
if [[ "$LEAST_PRIVILEGE" != "true" ]] && grep -q "^User=${LEAST_PRIVILEGE_USER}\$" "$UNIT"; then
if [[ "$ENABLE_COMMANDS" == "true" ]]; then
fail "This agent runs the least-privilege profile; --enable-commands requires reinstalling the root profile first" "$EXIT_MISSING_ARGS"
fi
LEAST_PRIVILEGE="true"
log_info "Preserving existing least-privilege profile (User=${LEAST_PRIVILEGE_USER})"
fi
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
if [[ "$GRANT_SMART" != "true" ]] && grep -q "PULSE_SMARTCTL_PATH=${PRIVILEGE_HELPER_DIR}/" "$UNIT"; then
GRANT_SMART="true"
fi
if [[ "$GRANT_PCT" != "true" ]] && grep -q "PULSE_PCT_PATH=${PRIVILEGE_HELPER_DIR}/" "$UNIT"; then
GRANT_PCT="true"
fi
fi
fi
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
SERVICE_USER="$LEAST_PRIVILEGE_USER"
provision_least_privilege_user
provision_privilege_helpers
log_info "Least-privilege profile: service runs as ${SERVICE_USER}. SMART $( [[ "$GRANT_SMART" == "true" ]] && echo "via scoped sudo helper" || echo "unavailable without --grant-smart" ); Proxmox LXC filesystems $( [[ "$GRANT_PCT" == "true" ]] && echo "via scoped sudo helper" || echo "unavailable without --grant-pct" )."
fi
# Build command line args with --token-file instead of the raw token.
build_exec_args
render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" ""
render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "$SERVICE_USER" ""
# Restrict service file permissions (contains no secrets now, but good practice)
chmod 644 "$UNIT"
+45 -1
View File
@@ -1956,7 +1956,9 @@ func TestInstallSHUsesSharedServiceRenderers(t *testing.T) {
`render_freebsd_rc_agent_script() {`,
`render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network.target" "" "" ""`,
`render_systemd_agent_unit "$TRUENAS_SERVICE_STORAGE" "${TRUENAS_RUNTIME_BINARY}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" "${TRUENAS_LOG_TARGET}"`,
`render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" ""`,
// The Linux systemd unit takes the resolved service user so the
// least-privilege profile can swap root for pulse-agent.
`render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "$SERVICE_USER" ""`,
`render_freebsd_rc_agent_script "$TRUENAS_SERVICE_STORAGE" "${TRUENAS_RUNTIME_BINARY}" "${EXEC_ARGS}"`,
`render_freebsd_rc_agent_script "$RCSCRIPT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}"`,
}
@@ -5531,3 +5533,45 @@ func TestInstallSHVersionMismatchWarningIgnoresBuildMetadata(t *testing.T) {
}
}
}
// The least-privilege profile must stay a real profile, not a cosmetic flag:
// a dedicated nologin system user, validated exact-command sudoers grants, a
// pct grant that can never widen into pct exec, env-pinned absolute helper
// paths, update-time preservation of the profile, and no ambient capability
// grant. Silently falling back to root on unsupported platforms is forbidden.
func TestInstallSHLeastPrivilegeProfile(t *testing.T) {
content, err := os.ReadFile(repoFile("scripts", "install.sh"))
if err != nil {
t.Fatalf("read install.sh: %v", err)
}
script := string(content)
required := []string{
`--least-privilege) LEAST_PRIVILEGE="true"; shift ;;`,
`--grant-smart) GRANT_SMART="true"; shift ;;`,
`--grant-pct) GRANT_PCT="true"; shift ;;`,
`LEAST_PRIVILEGE_USER="pulse-agent"`,
`PRIVILEGE_SUDOERS_FILE="/etc/sudoers.d/pulse-agent"`,
"--least-privilege and --enable-commands are mutually exclusive",
"--least-privilege is supported only on standard Linux systemd hosts",
`useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home`,
`visudo -cf`,
`install -o root -g root -m 0440 "$sudoers_tmp" "$PRIVILEGE_SUDOERS_FILE"`,
"${pct_path} list, ${pct_path} df *",
`append_service_env "PULSE_SMARTCTL_PATH" "${PRIVILEGE_HELPER_DIR}/smartctl"`,
`append_service_env "PULSE_PCT_PATH" "${PRIVILEGE_HELPER_DIR}/pct"`,
`grep -q "^User=${LEAST_PRIVILEGE_USER}\$" "$UNIT"`,
`"network-online.target" "$SERVICE_USER" ""`,
"# The least-privilege profile never attaches into guests",
`rm -f "$PRIVILEGE_SUDOERS_FILE"`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
t.Fatalf("install.sh missing least-privilege profile invariant: %s", needle)
}
}
if strings.Contains(script, `NOPASSWD: ${pct_path}`) && !strings.Contains(script, "does NOT cover pct exec") {
t.Fatal("install.sh must document that the pct grant excludes pct exec")
}
}