mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
+48
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
+40
@@ -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}${
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
// ToFrontend converts a DockerContainer to DockerContainerFrontend
|
||||
func (c DockerContainer) ToFrontend() DockerContainerFrontend {
|
||||
container := DockerContainerFrontend{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user