From 6ba1b70d1dc12133a88aeaee91037218fe35e747 Mon Sep 17 00:00:00 2001 From: svenvg93 Date: Mon, 5 Oct 2026 08:32:42 +0200 Subject: [PATCH] fix(agent): make package update checks work as the sandboxed agent user --- agent/package_updates.go | 130 +++++++++--------- agent/package_updates_test.go | 112 ++++++++------- .../package_updates/pacman_query_upgrades.txt | 100 ++++++++++++++ supplemental/debian/beszel-agent.service | 1 + supplemental/scripts/install-agent.sh | 8 ++ 5 files changed, 240 insertions(+), 111 deletions(-) create mode 100644 agent/test-data/package_updates/pacman_query_upgrades.txt diff --git a/agent/package_updates.go b/agent/package_updates.go index da00419c5..b3cc8e8f6 100644 --- a/agent/package_updates.go +++ b/agent/package_updates.go @@ -2,8 +2,10 @@ package agent import ( "bufio" + "bytes" "context" "errors" + "fmt" "log/slog" "os" "os/exec" @@ -21,9 +23,6 @@ import ( const ( defaultPackageUpdatesInterval = time.Hour packageUpdatesTimeout = 5 * time.Minute - // pacmanSyncInterval limits how often checkupdates downloads fresh sync - // databases. Checks in between reuse the last synced copy. - pacmanSyncInterval = 12 * time.Hour ) // packageUpdatesResult is the outcome of one package manager check. @@ -51,7 +50,8 @@ type packageUpdatesManager struct { // newPackageUpdatesManager returns nil if disabled or no supported package manager // is found. Agents running in a container are skipped because the container's -// package database is not the host's. dataDir holds pacman's private sync databases. +// package database is not the host's. dataDir holds the package managers' per-user +// caches and state, which they cannot write to their usual locations as the agent user. func newPackageUpdatesManager(dataDir string) *packageUpdatesManager { if runtime.GOOS != "linux" || runningInContainer() { return nil @@ -144,11 +144,11 @@ func detectPackageManager(dataDir string) (string, packageUpdatesCheck) { case commandExists("apt-get"): return "apt", checkApt case commandExists("dnf"): - return "dnf", checkDnf + return "dnf", newDnfCheck(dataDir) case commandExists("zypper"): - return "zypper", checkZypper - case commandExists("checkupdates"): - return "pacman", newPacmanCheck(dataDir) + return "zypper", newZypperCheck(dataDir) + case commandExists("pacman"): + return "pacman", checkPacman case commandExists("apk"): return "apk", checkApk } @@ -171,12 +171,18 @@ func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name cmd := exec.CommandContext(ctx, name, args...) cmd.Env = append(os.Environ(), "LC_ALL=C") cmd.Env = append(cmd.Env, env...) - // checkupdates is a shell script, so a timeout kills only the script and its - // children can keep stdout open. WaitDelay stops Output from waiting on them. + // a timeout kills only the command itself, and children it started can keep + // stdout open. WaitDelay stops Output from waiting on them. cmd.WaitDelay = 10 * time.Second out, err := cmd.Output() - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && slices.Contains(okCodes, exitErr.ExitCode()) { - return string(out), nil + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + if slices.Contains(okCodes, exitErr.ExitCode()) { + return string(out), nil + } + // include stderr so a failure says why, not just "exit status 1" + if stderr := strings.TrimSpace(string(exitErr.Stderr)); stderr != "" { + err = fmt.Errorf("%s: %w: %s", name, err, stderr) + } } return string(out), err } @@ -206,10 +212,26 @@ func checkApt(ctx context.Context) (packageUpdatesResult, error) { }, nil } +// newDnfCheck points dnf5's per-user state (log) and cache dirs into dataDir. +// They default to $HOME/.local/state and $HOME/.cache, and the agent user's home +// is usually /nonexistent. With -C, dnf5 still reads the system metadata cache. +func newDnfCheck(dataDir string) packageUpdatesCheck { + var env []string + if dataDir != "" { + env = []string{ + "XDG_STATE_HOME=" + filepath.Join(dataDir, "state"), + "XDG_CACHE_HOME=" + filepath.Join(dataDir, "cache"), + } + } + return func(ctx context.Context) (packageUpdatesResult, error) { + return checkDnf(ctx, env) + } +} + // checkDnf uses the system metadata cache only (-C), so it never downloads metadata. // check-update lists only available versions, so installed versions come from rpm. -func checkDnf(ctx context.Context) (packageUpdatesResult, error) { - out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update") +func checkDnf(ctx context.Context, env []string) (packageUpdatesResult, error) { + out, err := runPackageCommandEnv(ctx, env, []int{100}, "dnf", "-q", "-C", "check-update") if err != nil { return packageUpdatesResult{}, err } @@ -229,7 +251,7 @@ func checkDnf(ctx context.Context) (packageUpdatesResult, error) { } } - out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security") + out, err = runPackageCommandEnv(ctx, env, []int{100}, "dnf", "-q", "-C", "check-update", "--security") if err == nil { // --security lists the lowest version that fixes an advisory, which may be // older than the version check-update offers, so match on name.arch only @@ -253,63 +275,45 @@ func checkDnf(ctx context.Context) (packageUpdatesResult, error) { return result, nil } -// checkZypper lists package updates. Security updates come from patches, which +// newZypperCheck lists package updates. Security updates come from patches, which // zypper does not map to packages here, so only the security count is known. -func checkZypper(ctx context.Context) (packageUpdatesResult, error) { - out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates") - if err != nil { - return packageUpdatesResult{}, err - } - packages := parseZypperListUpdates(out) - result := packageUpdatesResult{packages: packages, counts: []uint16{uint16(len(packages))}} - out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security") - if err == nil { - result.counts = append(result.counts, parseZypperTable(out)) - } - return result, nil -} - -// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of -// the databases and never touches pacman's own. The copy lives in dataDir because -// the systemd unit's ProtectSystem=strict makes the default /tmp location read-only. -// It syncs every pacmanSyncInterval and uses the existing copy (-n) in between. -// Local upgrades show up right away since checkupdates links the live local DB. -// Exit code 2 means no updates. -func newPacmanCheck(dataDir string) packageUpdatesCheck { - var env []string - var syncDir string +// zypper rebuilds the solv cache from the raw metadata when it is missing or stale, +// which the agent user cannot do in /var/cache/zypp, so it keeps its own in dataDir. +func newZypperCheck(dataDir string) packageUpdatesCheck { + args := []string{"--no-refresh", "-q"} if dataDir != "" { - dbPath := filepath.Join(dataDir, "checkup-db") - env = []string{"CHECKUPDATES_DB=" + dbPath} - syncDir = filepath.Join(dbPath, "sync") + args = append(args, "--solv-cache-dir", filepath.Join(dataDir, "zypp-solv")) } - // checks never overlap (packageUpdatesManager.running), so no lock is needed - var lastSync time.Time return func(ctx context.Context) (packageUpdatesResult, error) { - // -n with a missing database reports no updates rather than failing, - // so always sync first and whenever the private copy is missing - sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval - if !sync && syncDir != "" { - if _, err := os.Stat(syncDir); err != nil { - sync = true - } - } - var args []string - if !sync { - args = append(args, "-n") - } - out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...) + out, err := runPackageCommand(ctx, nil, "zypper", slices.Concat(args, []string{"list-updates"})...) if err != nil { return packageUpdatesResult{}, err } - if sync { - lastSync = time.Now() + packages := parseZypperListUpdates(out) + result := packageUpdatesResult{packages: packages, counts: []uint16{uint16(len(packages))}} + out, err = runPackageCommand(ctx, nil, "zypper", slices.Concat(args, []string{"list-patches", "--category", "security"})...) + if err == nil { + result.counts = append(result.counts, parseZypperTable(out)) } - packages := parsePacmanCheckUpdates(out) - return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil + return result, nil } } +// checkPacman compares installed packages against the system sync databases. +// It never syncs them; pacman -Sy(u) by the user or a timer does that. +func checkPacman(ctx context.Context) (packageUpdatesResult, error) { + out, err := runPackageCommand(ctx, nil, "pacman", "-Qu") + // -Qu exits 1 with no output when nothing is upgradable; errors print to stderr + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(bytes.TrimSpace(exitErr.Stderr)) == 0 { + err = nil + } + if err != nil { + return packageUpdatesResult{}, err + } + packages := parsePacmanQueryUpgrades(out) + return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil +} + func checkApk(ctx context.Context) (packageUpdatesResult, error) { out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list") if err != nil { @@ -462,8 +466,8 @@ func parseZypperListUpdates(out string) (packages []system.PackageUpdate) { return packages } -// parsePacmanCheckUpdates parses "name old -> new" lines. -func parsePacmanCheckUpdates(out string) (packages []system.PackageUpdate) { +// parsePacmanQueryUpgrades parses "name old -> new" lines, which may end in "[ignored]". +func parsePacmanQueryUpgrades(out string) (packages []system.PackageUpdate) { scanner := bufio.NewScanner(strings.NewReader(out)) for scanner.Scan() { fields := strings.Fields(scanner.Text()) diff --git a/agent/package_updates_test.go b/agent/package_updates_test.go index bb49f39d3..8103ee9a5 100644 --- a/agent/package_updates_test.go +++ b/agent/package_updates_test.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "runtime" - "strings" "testing" "time" @@ -218,7 +217,7 @@ exit 100`, "rpm": `cat "` + testDataPath(t, tt.installed) + `" exit 1`, }) - result, err := checkDnf(context.Background()) + result, err := newDnfCheck(t.TempDir())(context.Background()) require.NoError(t, err) assert.Equal(t, []uint16{uint16(tt.total), uint16(tt.securityCount)}, result.counts) assert.True(t, result.securityKnown) @@ -237,12 +236,36 @@ echo "bash.x86_64 5.1.8-9.el9 baseos" exit 100`, "rpm": `echo "bash.x86_64 5.1.8-6.el9_1"`, }) - result, err := checkDnf(context.Background()) + result, err := newDnfCheck(t.TempDir())(context.Background()) require.NoError(t, err) assert.Equal(t, []uint16{1}, result.counts) assert.False(t, result.securityKnown) assert.Equal(t, []system.PackageUpdate{{Name: "bash", Current: "5.1.8-6.el9_1", Available: "5.1.8-9.el9"}}, result.packages) }) + + t.Run("error includes stderr", func(t *testing.T) { + fakeCommands(t, map[string]string{ + "dnf": `echo "Error: Cache-only enabled but no cache for 'baseos'" >&2 +exit 1`, + }) + _, err := newDnfCheck(t.TempDir())(context.Background()) + require.Error(t, err) + assert.Equal(t, "dnf: exit status 1: Error: Cache-only enabled but no cache for 'baseos'", err.Error()) + }) + + t.Run("state and cache in data dir", func(t *testing.T) { + dataDir := t.TempDir() + envFile := filepath.Join(t.TempDir(), "env") + fakeCommands(t, map[string]string{ + "dnf": `echo "$XDG_STATE_HOME $XDG_CACHE_HOME" >> "` + envFile + `"`, + }) + _, err := newDnfCheck(dataDir)(context.Background()) + require.NoError(t, err) + env, err := os.ReadFile(envFile) + require.NoError(t, err) + want := filepath.Join(dataDir, "state") + " " + filepath.Join(dataDir, "cache") + "\n" + assert.Equal(t, want+want, string(env)) + }) } func TestParseZypperTable(t *testing.T) { @@ -273,25 +296,36 @@ func TestParseZypperListUpdates(t *testing.T) { } func TestCheckZypper(t *testing.T) { + dataDir := t.TempDir() + argsFile := filepath.Join(t.TempDir(), "args") fakeCommands(t, map[string]string{ - "zypper": `case "$*" in *list-patches*) cat "` + testDataPath(t, "zypper_leap155_list_patches_security.txt") + `" ;; *) cat "` + testDataPath(t, "zypper_leap155_list_updates.txt") + `" ;; esac`, + "zypper": `echo "$*" >> "` + argsFile + `" +case "$*" in *list-patches*) cat "` + testDataPath(t, "zypper_leap155_list_patches_security.txt") + `" ;; *) cat "` + testDataPath(t, "zypper_leap155_list_updates.txt") + `" ;; esac`, }) - result, err := checkZypper(context.Background()) + result, err := newZypperCheck(dataDir)(context.Background()) require.NoError(t, err) // security patches don't map to packages, so only the count is known assert.Equal(t, []uint16{22, 4}, result.counts) assert.False(t, result.securityKnown) assert.Len(t, result.packages, 22) + + // both commands use the agent's own solv cache + args, err := os.ReadFile(argsFile) + require.NoError(t, err) + solvDir := filepath.Join(dataDir, "zypp-solv") + assert.Equal(t, "--no-refresh -q --solv-cache-dir "+solvDir+" list-updates\n"+ + "--no-refresh -q --solv-cache-dir "+solvDir+" list-patches --category security\n", string(args)) } -func TestParsePacmanCheckUpdates(t *testing.T) { +func TestParsePacmanQueryUpgrades(t *testing.T) { assert.Equal(t, []system.PackageUpdate{ {Name: "libpcap", Current: "1.10.7-1", Available: "1.11.0-1"}, {Name: "libsecret", Current: "0.21.7-1", Available: "0.21.8.2-1"}, {Name: "libtirpc", Current: "1.3.7-1", Available: "1.3.8-1"}, {Name: "tzdata", Current: "2026c-1", Available: "2026d-1"}, - }, parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt"))) - assert.Empty(t, parsePacmanCheckUpdates("")) + }, parsePacmanQueryUpgrades(readPackageUpdatesTestData(t, "pacman_query_upgrades.txt"))) + assert.Empty(t, parsePacmanQueryUpgrades("")) + assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, parsePacmanQueryUpgrades("linux 6.1-1 -> 6.2-1 [ignored]\n")) } func TestParseApkUpgradable(t *testing.T) { @@ -400,46 +434,28 @@ func TestGetPackageUpdatesHandler(t *testing.T) { assert.Equal(t, system.PackageUpdates{Manager: "apk", CheckedAt: 1700000000, Packages: packages}, sent) } -func TestPacmanCheckSync(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("requires a shell script on PATH") +func TestCheckPacman(t *testing.T) { + tests := []struct { + name, script string + counts []uint16 + err string + }{ + {"updates", `echo "linux 6.1-1 -> 6.2-1"`, []uint16{1}, ""}, + {"no updates", "exit 1", []uint16{0}, ""}, + {"error", `echo "error: failed to initialize alpm library" >&2 +exit 1`, nil, "pacman: exit status 1: error: failed to initialize alpm library"}, } - binDir := t.TempDir() - dataDir := t.TempDir() - logFile := filepath.Join(binDir, "calls.log") - // fake checkupdates logs its args and db path, and creates the sync dir when syncing - script := `#!/bin/sh -echo "args=[$*] db=$CHECKUPDATES_DB" >> ` + logFile + ` -[ "$1" = "-n" ] || mkdir -p "$CHECKUPDATES_DB/sync" -echo "linux 6.1-1 -> 6.2-1" -` - require.NoError(t, os.WriteFile(filepath.Join(binDir, "checkupdates"), []byte(script), 0o755)) - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - - check := newPacmanCheck(dataDir) - dbPath := filepath.Join(dataDir, "checkup-db") - readCalls := func() []string { - data, err := os.ReadFile(logFile) - require.NoError(t, err) - return strings.Split(strings.TrimSpace(string(data)), "\n") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeCommands(t, map[string]string{"pacman": `[ "$*" = "-Qu" ] || exit 9 +` + tt.script}) + result, err := checkPacman(context.Background()) + if tt.err != "" { + require.EqualError(t, err, tt.err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.counts, result.counts) + }) } - - // first check syncs - result, err := check(context.Background()) - require.NoError(t, err) - assert.Equal(t, []uint16{1}, result.counts) - assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, result.packages) - // later checks reuse the synced copy - _, err = check(context.Background()) - require.NoError(t, err) - // a missing private copy forces a sync - require.NoError(t, os.RemoveAll(dbPath)) - _, err = check(context.Background()) - require.NoError(t, err) - - assert.Equal(t, []string{ - "args=[] db=" + dbPath, - "args=[-n] db=" + dbPath, - "args=[] db=" + dbPath, - }, readCalls()) } diff --git a/agent/test-data/package_updates/pacman_query_upgrades.txt b/agent/test-data/package_updates/pacman_query_upgrades.txt new file mode 100644 index 000000000..b0491df52 --- /dev/null +++ b/agent/test-data/package_updates/pacman_query_upgrades.txt @@ -0,0 +1,100 @@ +acl 2.3.2-2 -> 2.4.0-1 +archlinux-keyring 20260409-1 -> 20260909-1 +attr 2.5.2-2 -> 2.6.0-1 +audit 4.1.4-2 -> 4.2.1-1 +bash 5.3.9-1 -> 5.3.20-1 +binutils 2.46-1 -> 2.47-4 +ca-certificates-mozilla 3.123-1 -> 3.130-1 +coreutils 9.10-1 -> 9.12-2 +cryptsetup 2.8.6-1 -> 2.8.8-1 +curl 8.19.0-1 -> 8.22.0-1 +device-mapper 2.03.39-1 -> 2.03.43-2 +ex-vi-compat 2-1 -> 3-1 +expat 2.7.5-1 -> 2.8.5-1 +file 5.47-3 -> 5.48-1 +findutils 4.10.0-3 -> 4.11.0-1 +gawk 5.4.0-1 -> 5.4.1-1 +gcc-libs 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +glib2 2.88.0-1 -> 2.88.3-1 +glibc 2.43+r5+g856c426a7534-1 -> 2.44+r50+g1848099f063e-1 +gnupg 2.4.9-1 -> 2.4.9-3 +gnutls 3.8.12-2 -> 3.8.13-3 +gpgme 2.0.1-3 -> 2.2.0-1 +gpm 1.20.7.r38.ge82d1a6-6 -> 1.20.7.r38.ge82d1a6-7 +gzip 1.14-2 -> 1.15-1 +hwdata 0.406-1 -> 0.411-1 +iana-etc 20260409-1 -> 20260617-1 +inetutils 2.7-2 -> 2.8-1 +iproute2 7.0.0-1 -> 7.2.0-1 +jansson 2.15.0-1 -> 2.15.1-1 +json-c 0.18-2 -> 0.19-1 +kbd 2.9.0-1 -> 2.10.0-1 +krb5 1.21.3-2 -> 1.22.2-1 +leancrypto 1.7.2-1 -> 1.9.1-1 +libarchive 3.8.7-1 -> 3.8.9-1 +libasan 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libatomic 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libbpf 1.6.2-1 -> 1.7.0-1 +libcap-ng 0.9.3-1 -> 0.9.6-1 +libedit 20251016_3.1-1 -> 20260512_3.1-1 +libelf 0.194-2 -> 0.196-1 +libevent 2.1.12-5 -> 2.1.13-2 +libffi 3.5.2-1 -> 3.8.0-1 +libgcc 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libgcrypt 1.12.2-1 -> 1.12.4-1 +libgfortran 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libgomp 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libgpg-error 1.59-1 -> 1.61-1 +libksba 1.6.8-1 -> 1.8.1-1 +libldap 2.6.13-1 -> 2.7.1-1 +liblsan 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libmakepkg-dropins 20-1 -> 20-2 +libnetfilter_conntrack 1.0.9-2 -> 1.1.1-1 +libnftnl 1.3.1-1 -> 1.3.2-1 +libnghttp2 1.69.0-1 -> 1.70.0-1 +libnghttp3 1.15.0-1 -> 1.18.0-1 +libngtcp2 1.22.1-1 -> 1.25.0-1 +libnsl 2.0.1-1 -> 2.0.1-2 +libobjc 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libp11-kit 0.26.2-1 -> 0.26.5-1 +libpcap 1.10.6-1 -> 1.11.0-1 +libquadmath 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libsecret 0.21.7-1 -> 0.21.8.2-1 +libssh2 1.11.1-1 -> 1.11.1-7 +libstdc++ 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libsysprof-capture 50.0-1 -> 50.0-6 +libtirpc 1.3.7-1 -> 1.3.8-1 +libtsan 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libubsan 15.2.1+r604+g0b99615a8aef-1 -> 16.2.1+r23+gd564253eb6c8-1 +libunistring 1.4.1-1 -> 1.4.2-1 +libusb 1.0.29-1 -> 1.0.30-1 +libxml2 2.15.3-1 -> 2.15.4-1 +linux-api-headers 6.19-1 -> 7.2-1 +nano 9.0-1 -> 9.2-1 +nettle 3.10.2-1 -> 4.0-1 +nftables 1:1.1.6-3 -> 1:1.1.7-3 +openssh 10.3p1-1 -> 10.5p1-1 +openssl 3.6.2-1 -> 3.6.5-1 +p11-kit 0.26.2-1 -> 0.26.5-1 +pacman 7.1.0.r9.g54d9411-1 -> 7.1.0.r9.g54d9411-2 +pacman-mirrorlist 20260406-1 -> 20260610-1 +pam 1.7.2-2 -> 1.7.3-1 +pambase 20250719-1 -> 20260616-1 +pcre2 10.47-1 -> 10.49-1 +pinentry 1.3.2-2 -> 1.3.3-1 +procps-ng 4.0.6-1 -> 4.0.7-1 +readline 8.3.003-1 -> 8.3.6-1 +sed 4.9-3 -> 4.10-2 +shadow 4.18.0-1 -> 4.20.0.arch1-1 +sqlite 3.53.0-2 -> 3.53.4-1 +systemd 260.1-1 -> 262-1 +systemd-libs 260.1-1 -> 262-1 +systemd-sysvcompat 260.1-1 -> 262-1 +tar 1.35-2 -> 1.35-5 +tpm2-tss 4.1.3-1 -> 4.2.0-2 +tzdata 2026a-1 -> 2026e-1 +util-linux 2.42-1 -> 2.42.4-1 +util-linux-libs 2.42-1 -> 2.42.4-1 +vim 9.2.0357-1 -> 9.2.1046-1 +vim-runtime 9.2.0357-1 -> 9.2.1046-1 +xz 5.8.3-1 -> 5.8.4-1 \ No newline at end of file diff --git a/supplemental/debian/beszel-agent.service b/supplemental/debian/beszel-agent.service index d86388dff..da0f4c55e 100644 --- a/supplemental/debian/beszel-agent.service +++ b/supplemental/debian/beszel-agent.service @@ -20,6 +20,7 @@ ProtectHome=read-only ProtectHostname=yes ProtectKernelLogs=yes ProtectSystem=strict +PrivateTmp=yes RemoveIPC=yes RestrictSUIDSGID=true diff --git a/supplemental/scripts/install-agent.sh b/supplemental/scripts/install-agent.sh index 09e6b59d1..d74402b71 100755 --- a/supplemental/scripts/install-agent.sh +++ b/supplemental/scripts/install-agent.sh @@ -1369,6 +1369,7 @@ ProtectHome=read-only ProtectHostname=yes ProtectKernelLogs=yes ProtectSystem=strict +PrivateTmp=yes RemoveIPC=yes RestrictSUIDSGID=true @@ -1396,6 +1397,13 @@ EOF sed -i '/^User=beszel$/a SupplementaryGroups=systemd-journal' /etc/systemd/system/beszel-agent.service fi + # ProtectSystem=strict makes /var/tmp read-only, but dnf4 and zypper run as a non-root + # user keep temporary files in /var/tmp, so package update checks fail without this. + if grep -q '^ProtectSystem=strict$' /etc/systemd/system/beszel-agent.service && ! grep -q '^PrivateTmp=' /etc/systemd/system/beszel-agent.service; then + echo "Giving the agent service a private writable /tmp and /var/tmp" + sed -i '/^ProtectSystem=strict$/a PrivateTmp=yes' /etc/systemd/system/beszel-agent.service + fi + # Load and start the service printf "\nLoading and starting the agent service...\n" systemctl daemon-reload