From c62a824eb9d5e44a90439b95c3d77ed3df000560 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:44:03 +0000 Subject: [PATCH 1/2] Restart apt-update timer on upgrade and sandbox its service unit - install.sh: capture whether pimonitor-apt-update.timer was already active before enable --now, same as already done for pimonitor.service, and explicitly restart it afterwards. 'enable --now' is a no-op on an already-active unit, so without this a future change to the timer's schedule would install the new unit file but keep the old schedule loaded until an unrelated restart/reboot (closes #77). - pimonitor-apt-update.service: add cheap sandboxing directives now that the main pimonitor.service has them (closes #74). The capability bounding set and NoNewPrivileges were verified against a live 'apt-get update' run: apt drops to the _apt user for the actual download (CAP_SETUID/CAP_SETGID) and chowns/chmods the fetched index files back (CAP_CHOWN/CAP_FOWNER/CAP_DAC_OVERRIDE); all five are load-bearing. NoNewPrivileges does not interfere with this since it's a plain setuid()/setgid() syscall drop, not an execve of a setuid binary, contrary to the issue's initial assumption. --- packaging/install.sh | 14 ++++++++++--- packaging/pimonitor-apt-update.service | 27 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packaging/install.sh b/packaging/install.sh index f841ba1..cbf1a08 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -82,13 +82,17 @@ install -m 644 "$SCRIPT_DIR/pimonitor-apt-update.service" /etc/systemd/system/pi install -m 644 "$SCRIPT_DIR/pimonitor-apt-update.timer" /etc/systemd/system/pimonitor-apt-update.timer # Capture this before daemon-reload/enable so an upgrade of an already-running -# service is detected: 'enable --now' is a no-op when the unit is already -# active, which would otherwise leave the old binary running until an -# unrelated reboot/restart. +# service/timer is detected: 'enable --now' is a no-op when the unit is +# already active, which would otherwise leave the old binary/schedule loaded +# until an unrelated reboot/restart. SERVICE_WAS_ACTIVE=0 if systemctl is-active --quiet pimonitor.service; then SERVICE_WAS_ACTIVE=1 fi +TIMER_WAS_ACTIVE=0 +if systemctl is-active --quiet pimonitor-apt-update.timer; then + TIMER_WAS_ACTIVE=1 +fi systemctl daemon-reload @@ -100,6 +104,10 @@ if [[ "$SERVICE_WAS_ACTIVE" -eq 1 ]]; then echo "pimonitor.service was already running; restarting to pick up the new binary/unit..." systemctl restart pimonitor.service fi +if [[ "$TIMER_WAS_ACTIVE" -eq 1 ]]; then + echo "pimonitor-apt-update.timer was already active; restarting to pick up any schedule change..." + systemctl restart pimonitor-apt-update.timer +fi echo "Running an initial apt cache refresh so the update count isn't empty on first view..." systemctl start pimonitor-apt-update.service || true diff --git a/packaging/pimonitor-apt-update.service b/packaging/pimonitor-apt-update.service index b030ea8..aa9d0fe 100644 --- a/packaging/pimonitor-apt-update.service +++ b/packaging/pimonitor-apt-update.service @@ -8,3 +8,30 @@ Type=oneshot # This is the only part of PiMonitor that touches apt with write intent; # the main pimonitor.service never runs this itself and never needs root. ExecStart=/usr/bin/apt-get update + +# Hardening. apt-get update needs root (to write the package lists/cache +# and to drop privileges to the _apt sandbox user for the actual network +# fetch) so this can't be locked down as tightly as pimonitor.service, but +# the following cheap directives still apply. +NoNewPrivileges=true +ProtectSystem=full +ReadWritePaths=/var/lib/apt /var/cache/apt /var/log/apt +ProtectHome=true +PrivateTmp=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true + +# Trimmed from the default (full) capability set for root. Verified against +# a live 'apt-get update' run: apt drops to the _apt user for the actual +# download (CAP_SETUID/CAP_SETGID), then chowns/chmods the fetched index +# files (CAP_CHOWN/CAP_FOWNER) and needs CAP_DAC_OVERRIDE to replace +# lock/partial files it doesn't own as _apt. Dropping any one of these five +# makes apt-get update fail outright or silently leave files root-owned +# instead of _apt-owned. +CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETGID CAP_SETUID + +# The 6-hourly refresh shouldn't compete with foreground workloads on slow +# SD cards. +Nice=19 +IOSchedulingClass=idle From 610bc064f3b064ddd37294875fb237bd3615ead7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:08:35 +0000 Subject: [PATCH 2/2] Address review: make ReadWritePaths effective, surface refresh failures - pimonitor-apt-update.service: ProtectSystem=full made ReadWritePaths= a no-op since /var is already writable under 'full' - switch to 'strict' so it actually confines writes. Re-verified with strace against a live 'apt-get update' that only /var/lib/apt/lists and /tmp (already covered by PrivateTmp) are opened for writing; dropped /var/log/apt from ReadWritePaths since update never writes there. - install.sh: the initial cache refresh swallowed failures via '|| true'. With this PR adding new sandboxing to that service, a directive breaking apt-get update on some setup would now install cleanly and only surface as a silently stale update count. Print a warning on failure instead. --- packaging/install.sh | 4 +++- packaging/pimonitor-apt-update.service | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packaging/install.sh b/packaging/install.sh index cbf1a08..a908fef 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -110,7 +110,9 @@ if [[ "$TIMER_WAS_ACTIVE" -eq 1 ]]; then fi echo "Running an initial apt cache refresh so the update count isn't empty on first view..." -systemctl start pimonitor-apt-update.service || true +if ! systemctl start pimonitor-apt-update.service; then + echo "warning: initial apt cache refresh failed; check 'systemctl status pimonitor-apt-update.service'" >&2 +fi echo echo "Done. Check status with:" diff --git a/packaging/pimonitor-apt-update.service b/packaging/pimonitor-apt-update.service index aa9d0fe..d21e5f8 100644 --- a/packaging/pimonitor-apt-update.service +++ b/packaging/pimonitor-apt-update.service @@ -14,8 +14,15 @@ ExecStart=/usr/bin/apt-get update # fetch) so this can't be locked down as tightly as pimonitor.service, but # the following cheap directives still apply. NoNewPrivileges=true -ProtectSystem=full -ReadWritePaths=/var/lib/apt /var/cache/apt /var/log/apt +# strict (not full) so ReadWritePaths= below actually confines writes +# instead of being a no-op - traced (strace) against a live 'apt-get +# update': the only write-mode opens are under /var/lib/apt/lists (index +# files) and /tmp (apt-key's gpg homedir, already covered by PrivateTmp). +# /var/cache/apt is included for portability across apt versions that +# rebuild pkgcache.bin here. Nothing is written under /var/log/apt - that's +# only touched by install/remove, not update. +ProtectSystem=strict +ReadWritePaths=/var/lib/apt /var/cache/apt ProtectHome=true PrivateTmp=true ProtectKernelModules=true