Skip to content

Commit 143a887

Browse files
committed
feat: implement privileged update helper for agent self-update and update sudoers configuration
1 parent c74e4df commit 143a887

7 files changed

Lines changed: 219 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Formatting rules:
1515

1616
## [Unreleased]
1717

18+
### Fixed
19+
- Fleet self-update now uses a dedicated privileged helper so rollout handoff works on hosts that ship `sudo-rs`, where sudoers wildcard argument matching is more restrictive than classic `sudo`.
20+
1821
## [1.0.0] - 2026-04-02
1922

2023
### Added

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ The installer:
4848
- checks and installs required packages
4949
- creates the `noderax` system user
5050
- grants passwordless `sudo` only for `apt-get install/remove/purge`
51-
- grants passwordless `sudo` to the dedicated `noderax-agent update` command used by fleet rollouts
51+
- grants passwordless `sudo` to the dedicated `/usr/local/libexec/noderax-agent-self-update` helper used by fleet rollouts
5252
- downloads the correct prebuilt agent binary
5353
- bootstraps the node with the provided token
5454
- reports bootstrap progress back to the API so the web `Add node` modal can update live
@@ -204,10 +204,10 @@ sudo noderax-agent status
204204
## Fleet Self-Update
205205

206206
Platform-admin fleet rollouts do not use `shell.exec`. They dispatch the dedicated
207-
`agent.update` task type, which hands off to the root-only CLI command below:
207+
`agent.update` task type, which hands off to the root-only helper below:
208208

209209
```bash
210-
sudo noderax-agent update --target-version 1.0.1 --target-id <rollout-target-id>
210+
sudo /usr/local/libexec/noderax-agent-self-update --target-version 1.0.1 --target-id <rollout-target-id>
211211
```
212212

213213
The managed updater:
@@ -345,7 +345,7 @@ The agent executes `shell.exec` tasks in a controlled non-interactive environmen
345345
- when installed through the bootstrap installer, shell and package tasks run under the `noderax` user
346346
- `shell.exec` does not auto-elevate to root; it runs in the `noderax` user context by default
347347
- package operations can elevate through passwordless `sudo -n`, but only for `apt-get install`, `apt-get remove`, and `apt-get purge`
348-
- agent self-update can elevate through passwordless `sudo -n`, but only for the dedicated `noderax-agent update` command
348+
- agent self-update can elevate through passwordless `sudo -n`, but only for the dedicated `/usr/local/libexec/noderax-agent-self-update` helper
349349
- ad-hoc root shell commands are intentionally out of scope for the bundled sudoers profile
350350

351351
For package listing on Debian/Ubuntu, the agent uses optimized `dpkg -l` parsing to return structured package metadata.

internal/agentctl/commands.go

Lines changed: 118 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ const (
2727
serviceManagerSystemd = "systemd"
2828
serviceManagerLaunchd = "launchd"
2929

30-
linuxInstallDir = "/opt/noderax-agent"
31-
linuxBinaryPath = linuxInstallDir + "/noderax-agent"
32-
linuxSymlinkPath = "/usr/local/bin/noderax-agent"
33-
linuxConfigPath = "/etc/noderax-agent/config.json"
34-
linuxStatePath = "/var/lib/noderax-agent/agent_identity.json"
35-
linuxServiceUnit = "/etc/systemd/system/noderax-agent.service"
36-
linuxServiceName = "noderax-agent.service"
37-
linuxServiceUser = "noderax"
38-
linuxServiceHome = "/var/lib/noderax-agent"
30+
linuxInstallDir = "/opt/noderax-agent"
31+
linuxBinaryPath = linuxInstallDir + "/noderax-agent"
32+
linuxSymlinkPath = "/usr/local/bin/noderax-agent"
33+
linuxPrivilegedUpdateHelperPath = "/usr/local/libexec/noderax-agent-self-update"
34+
linuxConfigPath = "/etc/noderax-agent/config.json"
35+
linuxStatePath = "/var/lib/noderax-agent/agent_identity.json"
36+
linuxServiceUnit = "/etc/systemd/system/noderax-agent.service"
37+
linuxServiceName = "noderax-agent.service"
38+
linuxServiceUser = "noderax"
39+
linuxServiceHome = "/var/lib/noderax-agent"
3940

4041
macOSInstallDir = "/usr/local/lib/noderax-agent"
4142
macOSBinaryPath = macOSInstallDir + "/noderax-agent"
@@ -47,22 +48,23 @@ const (
4748
)
4849

4950
type platformSpec struct {
50-
Manager string
51-
InstallDir string
52-
BinaryPath string
53-
SymlinkPath string
54-
ConfigPath string
55-
StatePath string
56-
ServiceUnit string
57-
ServiceName string
58-
WorkingDir string
59-
RequiresRoot bool
60-
ServiceDomain string
61-
LogStdoutPath string
62-
LogStderrPath string
63-
ServiceUser string
64-
ServiceGroup string
65-
ServiceHome string
51+
Manager string
52+
InstallDir string
53+
BinaryPath string
54+
SymlinkPath string
55+
PrivilegedUpdateHelperPath string
56+
ConfigPath string
57+
StatePath string
58+
ServiceUnit string
59+
ServiceName string
60+
WorkingDir string
61+
RequiresRoot bool
62+
ServiceDomain string
63+
LogStdoutPath string
64+
LogStderrPath string
65+
ServiceUser string
66+
ServiceGroup string
67+
ServiceHome string
6668
}
6769

6870
type installOptions struct {
@@ -205,6 +207,9 @@ func (c CLI) Install(ctx context.Context, args []string) error {
205207
return fmt.Errorf("create symlink: %w", err)
206208
}
207209
}
210+
if err := writePrivilegedUpdateHelper(spec); err != nil {
211+
return fmt.Errorf("write privileged update helper: %w", err)
212+
}
208213

209214
switch spec.Manager {
210215
case serviceManagerSystemd:
@@ -339,6 +344,18 @@ func (c CLI) Uninstall(ctx context.Context) error {
339344
}
340345
recordRemovalResult(&removed, &missing, "symlink", spec.SymlinkPath, symlinkRemoved)
341346

347+
helperRemoved, err := removeFileIfExists(spec.PrivilegedUpdateHelperPath)
348+
if err != nil {
349+
return err
350+
}
351+
recordRemovalResult(
352+
&removed,
353+
&missing,
354+
"privileged update helper",
355+
spec.PrivilegedUpdateHelperPath,
356+
helperRemoved,
357+
)
358+
342359
configRemoved, err := removeFileIfExists(configPath)
343360
if err != nil {
344361
return err
@@ -485,36 +502,38 @@ func currentPlatformSpec() (platformSpec, error) {
485502
switch runtime.GOOS {
486503
case "linux":
487504
return platformSpec{
488-
Manager: serviceManagerSystemd,
489-
InstallDir: linuxInstallDir,
490-
BinaryPath: linuxBinaryPath,
491-
SymlinkPath: linuxSymlinkPath,
492-
ConfigPath: linuxConfigPath,
493-
StatePath: linuxStatePath,
494-
ServiceUnit: linuxServiceUnit,
495-
ServiceName: linuxServiceName,
496-
WorkingDir: linuxInstallDir,
497-
RequiresRoot: true,
498-
ServiceDomain: "system",
499-
ServiceUser: linuxServiceUser,
500-
ServiceGroup: linuxServiceUser,
501-
ServiceHome: linuxServiceHome,
505+
Manager: serviceManagerSystemd,
506+
InstallDir: linuxInstallDir,
507+
BinaryPath: linuxBinaryPath,
508+
SymlinkPath: linuxSymlinkPath,
509+
PrivilegedUpdateHelperPath: linuxPrivilegedUpdateHelperPath,
510+
ConfigPath: linuxConfigPath,
511+
StatePath: linuxStatePath,
512+
ServiceUnit: linuxServiceUnit,
513+
ServiceName: linuxServiceName,
514+
WorkingDir: linuxInstallDir,
515+
RequiresRoot: true,
516+
ServiceDomain: "system",
517+
ServiceUser: linuxServiceUser,
518+
ServiceGroup: linuxServiceUser,
519+
ServiceHome: linuxServiceHome,
502520
}, nil
503521
case "darwin":
504522
return platformSpec{
505-
Manager: serviceManagerLaunchd,
506-
InstallDir: macOSInstallDir,
507-
BinaryPath: macOSBinaryPath,
508-
SymlinkPath: macOSSymlinkPath,
509-
ConfigPath: macOSConfigPath,
510-
StatePath: macOSStatePath,
511-
ServiceUnit: macOSServiceUnit,
512-
ServiceName: macOSServiceName,
513-
WorkingDir: macOSInstallDir,
514-
RequiresRoot: true,
515-
ServiceDomain: "system",
516-
LogStdoutPath: "/var/log/noderax-agent.log",
517-
LogStderrPath: "/var/log/noderax-agent.error.log",
523+
Manager: serviceManagerLaunchd,
524+
InstallDir: macOSInstallDir,
525+
BinaryPath: macOSBinaryPath,
526+
SymlinkPath: macOSSymlinkPath,
527+
PrivilegedUpdateHelperPath: "",
528+
ConfigPath: macOSConfigPath,
529+
StatePath: macOSStatePath,
530+
ServiceUnit: macOSServiceUnit,
531+
ServiceName: macOSServiceName,
532+
WorkingDir: macOSInstallDir,
533+
RequiresRoot: true,
534+
ServiceDomain: "system",
535+
LogStdoutPath: "/var/log/noderax-agent.log",
536+
LogStderrPath: "/var/log/noderax-agent.error.log",
518537
}, nil
519538
default:
520539
return platformSpec{}, fmt.Errorf("unsupported platform %s", runtime.GOOS)
@@ -1018,6 +1037,53 @@ func ensureSymlink(target, link string) error {
10181037
return nil
10191038
}
10201039

1040+
func writePrivilegedUpdateHelper(spec platformSpec) error {
1041+
path := strings.TrimSpace(spec.PrivilegedUpdateHelperPath)
1042+
if path == "" {
1043+
return nil
1044+
}
1045+
1046+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1047+
return fmt.Errorf("create helper directory for %s: %w", path, err)
1048+
}
1049+
1050+
content := renderPrivilegedUpdateHelper(spec)
1051+
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
1052+
return fmt.Errorf("write helper %s: %w", path, err)
1053+
}
1054+
1055+
return nil
1056+
}
1057+
1058+
func renderPrivilegedUpdateHelper(spec platformSpec) string {
1059+
return fmt.Sprintf(`#!/bin/sh
1060+
set -eu
1061+
1062+
usage() {
1063+
echo "usage: %s --target-version <version> --target-id <target-id> [--rollback]" >&2
1064+
exit 64
1065+
}
1066+
1067+
if [ "$#" -ne 4 ] && [ "$#" -ne 5 ]; then
1068+
usage
1069+
fi
1070+
1071+
if [ "$1" != "--target-version" ] || [ -z "${2:-}" ] || [ "$3" != "--target-id" ] || [ -z "${4:-}" ]; then
1072+
usage
1073+
fi
1074+
1075+
if [ "$#" -eq 5 ] && [ "$5" != "--rollback" ]; then
1076+
usage
1077+
fi
1078+
1079+
if [ "$#" -eq 5 ]; then
1080+
exec %q update --target-version "$2" --target-id "$4" --rollback
1081+
fi
1082+
1083+
exec %q update --target-version "$2" --target-id "$4"
1084+
`, spec.PrivilegedUpdateHelperPath, spec.BinaryPath, spec.BinaryPath)
1085+
}
1086+
10211087
func writeServiceUnit(path, content string) error {
10221088
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
10231089
return fmt.Errorf("create service directory for %s: %w", path, err)

internal/agentctl/summary_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,26 @@ func TestRenderInstallSummaryIncludesUsefulCommands(t *testing.T) {
4444
}
4545
}
4646
}
47+
48+
func TestRenderPrivilegedUpdateHelperTargetsManagedBinary(t *testing.T) {
49+
t.Parallel()
50+
51+
spec := platformSpec{
52+
BinaryPath: linuxBinaryPath,
53+
PrivilegedUpdateHelperPath: linuxPrivilegedUpdateHelperPath,
54+
}
55+
56+
script := renderPrivilegedUpdateHelper(spec)
57+
58+
expectedSnippets := []string{
59+
"usage: " + linuxPrivilegedUpdateHelperPath,
60+
"exec \"" + linuxBinaryPath + "\" update --target-version \"$2\" --target-id \"$4\" --rollback",
61+
"exec \"" + linuxBinaryPath + "\" update --target-version \"$2\" --target-id \"$4\"",
62+
}
63+
64+
for _, snippet := range expectedSnippets {
65+
if !strings.Contains(script, snippet) {
66+
t.Fatalf("helper script missing snippet %q\n%s", snippet, script)
67+
}
68+
}
69+
}

internal/tasks/executor.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const (
2626
TaskTypePackageInstall = "packageInstall"
2727
TaskTypePackageRemove = "packageRemove"
2828
TaskTypePackagePurge = "packagePurge"
29+
30+
linuxPrivilegedUpdateHelperPath = "/usr/local/libexec/noderax-agent-self-update"
2931
)
3032

3133
var (
@@ -127,6 +129,7 @@ type ShellExecutor struct {
127129
goos string
128130
lookPath func(string) (string, error)
129131
executablePath func() (string, error)
132+
fileExists func(string) bool
130133
newCommand func(context.Context, string, ...string) commandRunner
131134
}
132135

@@ -136,7 +139,11 @@ func NewShellExecutor(defaultTimeout time.Duration) *ShellExecutor {
136139
goos: runtime.GOOS,
137140
lookPath: exec.LookPath,
138141
executablePath: os.Executable,
139-
newCommand: newExecCommandRunner,
142+
fileExists: func(path string) bool {
143+
_, err := os.Stat(path)
144+
return err == nil
145+
},
146+
newCommand: newExecCommandRunner,
140147
}
141148
}
142149

@@ -327,6 +334,40 @@ func (e *ShellExecutor) agentUpdateCommand(payload json.RawMessage) (commandSpec
327334
args = append(args, "--rollback")
328335
}
329336

337+
if e.goos == "linux" && e.fileExists(linuxPrivilegedUpdateHelperPath) {
338+
helperArgs := []string{
339+
"--target-version",
340+
targetVersion,
341+
"--target-id",
342+
targetID,
343+
}
344+
if parsed.Rollback {
345+
helperArgs = append(helperArgs, "--rollback")
346+
}
347+
348+
commandName, commandArgs, err := e.wrapWithSudo(
349+
linuxPrivilegedUpdateHelperPath,
350+
helperArgs,
351+
)
352+
if err != nil {
353+
return commandSpec{}, err
354+
}
355+
356+
return commandSpec{
357+
name: commandName,
358+
args: commandArgs,
359+
startMessage: fmt.Sprintf("handing off agent update to %s", targetVersion),
360+
parseResult: func(string, func(string, string)) any {
361+
return map[string]any{
362+
"status": "handoff",
363+
"targetId": targetID,
364+
"targetVersion": targetVersion,
365+
"rollback": parsed.Rollback,
366+
}
367+
},
368+
}, nil
369+
}
370+
330371
commandName, commandArgs, err := e.wrapWithSudo(agentPath, args)
331372
if err != nil {
332373
return commandSpec{}, err

0 commit comments

Comments
 (0)