Install live scripts by atomic rename

The periodic setup wrote /usr/local/bin scripts with sed > target,
truncating the file in place. bash reads scripts lazily, so the running
hosts-file-monitor could execute a half-new file.

New lib/atomic_install.sh renders into a temp file beside the target,
syntax-checks it, sets mode/owner and renames it over the target (new
inode). A bad template fails closed and leaves the live file untouched.
Applied to the maintenance script, hosts monitor, browser wrapper and
auto-update script; the hosts monitor service is restarted if running.

Test: test_services_atomic_install.sh (inode changes, old inode keeps
old bytes, syntax error and missing template leave target untouched).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYWDKuhbBAoNETfoB9
This commit is contained in:
Krzysztof Rudnicki 2026-11-12 12:59:59 +01:00
parent f75fb60687
commit b57d5f9064
8 changed files with 187 additions and 86 deletions

View File

@ -0,0 +1,11 @@
{
"title": "Atomic install of live periodic-setup scripts",
"objective": "Installing the periodic system scripts never truncates a file a service is running; the target gets a new inode and a bad template leaves the live file untouched.",
"acceptance_criteria": [
"Target inode changes and the old inode keeps its bytes",
"A template failing bash -n is not installed and leaves no temp file",
"hosts-file-monitor.service is restarted only if it was running"
],
"out_of_scope": ["Re-running the installer on the host", "Other installers"],
"verifier": "lib/tests/test_services_atomic_install.sh, lib/tests/run_all.sh, vmbox guest check"
}

View File

@ -0,0 +1,40 @@
{
"intent": "The periodic setup replaces live /usr/local/bin scripts by atomic rename, so a running hosts-file-monitor never executes a half-written file.",
"scope": [
"linux_configuration/periodic_background/lib/atomic_install.sh",
"linux_configuration/periodic_background/setup_periodic_system.sh",
"linux_configuration/periodic_background/lib/periodic_browser.sh",
"linux_configuration/periodic_background/lib/tests/test_services_atomic_install.sh",
"Non-goal: re-running the installer on the host"
],
"changes": [
"install_rendered_script renders to a temp file beside the target, bash -n, chmod/chown, mv -f",
"Maintenance script, hosts monitor, browser wrapper and auto-update script use it",
"restart_if_active restarts hosts-file-monitor.service after install"
],
"verification": [
{
"command": "lib/tests/test_services_atomic_install.sh",
"result": "pass",
"evidence": "red against in-place write (inode kept, old fd saw new bytes), 12 passed after"
},
{
"command": "vm run atomichosts guest_check.sh",
"result": "pass",
"evidence": "real hosts-file-monitor template under systemd: inode 85520 -> 85524, MainPID 745 -> 768, service active"
},
{
"command": "lib/tests/run_all.sh",
"result": "pass",
"evidence": "all check_and_enable_services test files passed"
}
],
"risks": [
"Temp file left in /usr/local/bin if the installer is killed between mktemp and mv",
"Restart briefly interrupts the hosts monitor"
],
"rollback": [
"git revert the commit; the old installer overwrites in place again",
"Check systemctl is-active hosts-file-monitor.service"
]
}

View File

@ -1,30 +0,0 @@
{
"intent": "The i3 gaming-budget block shows the new earned_time 0.3.0 automation earner (anki-guard's Automation-deck quota) as ⚙ instead of its text label.",
"scope": [
"linux_configuration/i3blocks/gaming_budget.sh",
"linux_configuration/tests/test_i3blocks_gaming_budget.sh",
"Non-goal: no change to how the budget is computed or rendered; only the icon map grows"
],
"changes": [
"Icon map gains automation: ⚙ beside workout 💪, leetcode 🧩, reading 📖, anki 🗂",
"The .rules.earners test case lists an automation earner and expects '+⚙30m' in registry order"
],
"verification": [
{
"command": "bash linux_configuration/i3blocks/gaming_budget.sh (live enforcer API, after restarting on earned-time 0.3.0)",
"result": "pass",
"evidence": "'🎮 1h19 left · 5h44/7h03 +📖1h +🗂30m +⚙30m'; /api/budget lists automation 0/1800"
},
{
"command": "bash linux_configuration/tests/test_i3blocks_gaming_budget.sh",
"result": "pass",
"evidence": "All gaming_budget checks passed, incl. '+🧩1h +📖1h +🗂30m +⚙30m +Piano1h30'"
}
],
"risks": [
"⚙ (U+2699) without a variation selector may render as text-style in some fonts; it is still one glyph wide."
],
"rollback": [
"git revert this commit; automation then shows as its label 'Automation30m'"
]
}

View File

@ -1,41 +0,0 @@
{
"intent": "A firewall apply (setup_wireguard_ssh.sh allow-*) no longer bounces Docker and its containers, and the DoT rule (tcp/853 on wg0) survives every re-apply instead of vanishing.",
"scope": [
"linux_configuration/features/lib/wg_firewall.sh: ruleset replaces only 'table inet filter'; restore_docker_rules restarts docker only when its nat DOCKER chain is gone; ALLOW_DOT renders 853 on wg0; allow_dot()",
"linux_configuration/features/setup_wireguard_ssh.sh, lib/wg_sshd.sh: ALLOW_DOT default/save_config, allow-dot subcommand and usage",
"linux_configuration/features/lib/dot_resolver_install.sh: wg-bound branch goes through 'setup_wireguard_ssh.sh allow-dot' instead of a runtime 'nft add rule'",
"Tests: new lib/tests/test_wg_firewall.sh; test_dot_resolver_install.sh wg-branch cases rewritten",
"Non-goal: the publicly bound DoT branch still adds its rate-limited rule at runtime"
],
"changes": [
"'flush ruleset' replaced by 'table inet filter' + 'delete table inet filter' before the definition: atomic, leaves Docker's (iptables-nft) and Tailscale's tables alone, and loads on a first boot",
"restore_docker_rules checks 'iptables -t nat -S DOCKER' and only restarts docker when that chain is missing",
"sshd-death rollback deletes only inet filter instead of flushing the whole ruleset",
"ALLOW_DOT persisted in .wireguard_ssh.conf; open_firewall calls allow-dot so the rule is rendered on every apply"
],
"verification": [
{
"command": "vmbox guest dotfw (iptables-nft, docker 29 + busybox probe, an extra 'inet sentinel' table): old HEAD allow-homeassistant vs new allow-dot",
"result": "pass",
"evidence": "old: all non-inet-filter tables deleted, docker + probe restarted; new re-apply from healthy state: docker ActiveEnterTimestamp and probe StartedAt unchanged, sentinel and docker tables kept; verify clean; systemctl restart nftables and first-boot load (no table) rc=0"
},
{
"command": "sudo setup_wireguard_ssh.sh allow-dot (host, iptables legacy)",
"result": "pass",
"evidence": "'docker's own firewall rules are intact; containers untouched'; docker since 2026-09-28 12:41:15 and gitea-caddy StartedAt unchanged; live input chain gains iifname wg0 tcp dport 853 accept; verify: nftables.conf matches; anki/gitea HTTPS 200"
},
{
"command": "lib/tests/test_wg_firewall.sh (root in vmbox) ; shell_coverage_jail.sh --subject lib/tests/run_all.sh <jail_args> --min 0 --fail-on-case-error",
"result": "pass",
"evidence": "12 passed incl. real nft -c; jailed suite rc=0"
}
],
"risks": [
"Tables left over from an older ruleset outside inet filter are no longer flushed by an apply (none exist on the host).",
"If docker's rules live somewhere other than the nat DOCKER chain, a missing-rules state would not trigger the rebuild."
],
"rollback": [
"git revert this commit, then sudo setup_wireguard_ssh.sh allow-web to re-render (old code restarts docker once)",
"After rollback: setup_wireguard_ssh.sh verify, and confirm containers reach the network"
]
}

View File

@ -0,0 +1,50 @@
#!/bin/bash
# Atomic install of live /usr/local/bin scripts for the periodic system setup.
#
# Sourced by setup_periodic_system.sh and periodic_browser.sh.
#
# Why not `sed ... > /usr/local/bin/x.sh`: that truncates the file in place.
# bash reads a script lazily, so a service already running it (the hosts
# monitor loops forever) can execute a half-new file. Rendering beside the
# target and renaming over it gives the target a NEW inode; a running instance
# keeps reading the old one until it exits or is restarted.
# Fail-closed: a template that is missing or fails `bash -n` leaves the live
# file untouched and returns non-zero.
# install_rendered_script <template> <dest> [sed args...]
# Renders <template> through sed with the given args into a temp file in
# dirname(<dest>) (same filesystem, so mv is a rename), syntax-checks it, sets
# mode 0755 and root ownership when run as root, then renames it over <dest>.
install_rendered_script() {
local template="$1" dest="$2"
shift 2
local tmp
[[ -f $template ]] || {
echo "Error: template not found: $template" >&2
return 1
}
tmp="$(mktemp "${dest}.XXXXXX")" || return 1
# `-e ''` keeps sed valid when the caller passes no expressions.
if sed -e '' "$@" "$template" >"$tmp" &&
bash -n "$tmp" &&
chmod 0755 "$tmp" &&
{ [[ $EUID -ne 0 ]] || chown root:root "$tmp"; } &&
mv -f "$tmp" "$dest"; then
return 0
fi
rm -f "$tmp"
echo "Error: not installing $dest (render or syntax check failed)" >&2
return 1
}
# restart_if_active <unit>
# Restarts a unit onto a freshly installed script, but only if it is running:
# the installer must not start a service the user stopped on purpose.
restart_if_active() {
local unit="$1"
if systemctl is-active "$unit" >/dev/null 2>&1; then
systemctl restart "$unit"
echo "✓ Restarted $unit onto the new script"
fi
return 0
}

View File

@ -2,6 +2,7 @@
# Browser wrapper install, desktop-entry redirect, auto-update units and
# service enablement for the periodic system setup.
#
# Needs install_rendered_script from lib/atomic_install.sh (sourced by the caller).
# Sourced by setup_periodic_system.sh; split out to keep it under the
# 250-line cap. Sourced rather than run, so it inherits the caller's strict
# mode and the variables defined above the source line.
@ -13,9 +14,8 @@ install_browser_preexec_wrapper() {
echo "========================================="
local wrapper="/usr/local/bin/browser-preexec-wrapper"
sed -e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g" \
"$TEMPLATE_BROWSER_WRAPPER" >"$wrapper"
chmod +x "$wrapper"
install_rendered_script "$TEMPLATE_BROWSER_WRAPPER" "$wrapper" \
-e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g"
echo "✓ Installed wrapper: $wrapper"
# Allow passwordless execution of hosts installer for root-only actions
@ -88,9 +88,8 @@ install_auto_update() {
# Install script from template with user substitution
local actual_user="${SUDO_USER:-$USER}"
sed -e "s|__ACTUAL_USER__|$actual_user|g" \
"$TEMPLATE_AUTO_UPDATE" >"$update_script"
chmod +x "$update_script"
install_rendered_script "$TEMPLATE_AUTO_UPDATE" "$update_script" \
-e "s|__ACTUAL_USER__|$actual_user|g"
echo "✓ Installed auto-update script: $update_script (user: $actual_user)"
# Install systemd service and timer from templates

View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Tests for lib/atomic_install.sh — installing a live /usr/local/bin script.
#
# A running bash reads its script lazily, so truncating the file in place
# (`sed ... > target`) lets a running instance execute a half-new file. The
# installer must render beside the target and rename over it: the target gets
# a NEW inode and a process holding the old one keeps reading the old bytes.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=services_harness.sh
. "${SCRIPT_DIR}/services_harness.sh"
# shellcheck source=../atomic_install.sh
. "${SCRIPT_DIR}/../atomic_install.sh"
BIN="${SYSROOT_DIR}/usr/local/bin"
TEMPLATE="${TEST_TMPDIR}/template.sh"
printf '#!/bin/bash\necho __NAME__ new\n' >"$TEMPLATE"
echo "== install_rendered_script: replaces the live file by rename =="
printf '#!/bin/bash\necho old\n' >"${BIN}/live.sh"
old_inode="$(stat -c %i "${BIN}/live.sh")"
exec 9<"${BIN}/live.sh" # a "running instance" holding the old inode open
install_rendered_script "$TEMPLATE" "${BIN}/live.sh" -e 's|__NAME__|hosts|g'
new_inode="$(stat -c %i "${BIN}/live.sh")"
if [[ $new_inode != "$old_inode" ]]; then
_t_pass "the target has a new inode"
else
_t_fail "the target kept inode $old_inode (written in place)"
fi
_t_eq $'#!/bin/bash\necho old' "$(cat <&9)" "the old inode's content is untouched for the running instance"
exec 9<&-
_t_eq $'#!/bin/bash\necho hosts new' "$(cat "${BIN}/live.sh")" "the target holds the rendered template"
if [[ -x "${BIN}/live.sh" ]]; then
_t_pass "the target is executable"
else
_t_fail "the target is not executable"
fi
_t_eq "live.sh" "$(ls "$BIN")" "no temp file is left beside the target"
echo "== install_rendered_script: a script that fails bash -n never replaces the target =="
printf '#!/bin/bash\nif then fi (\n' >"${TEST_TMPDIR}/broken.sh"
before="$(stat -c %i "${BIN}/live.sh")"
if install_rendered_script "${TEST_TMPDIR}/broken.sh" "${BIN}/live.sh" 2>/dev/null; then
_t_fail "a syntax error was installed"
else
_t_pass "a syntax error returns non-zero"
fi
_t_eq "$before" "$(stat -c %i "${BIN}/live.sh")" "the live file is untouched after a syntax error"
_t_eq "live.sh" "$(ls "$BIN")" "no temp file is left after a syntax error"
echo "== install_rendered_script: a missing template fails closed =="
if install_rendered_script "${TEST_TMPDIR}/nope.sh" "${BIN}/live.sh" 2>/dev/null; then
_t_fail "a missing template reported success"
else
_t_pass "a missing template returns non-zero"
fi
_t_eq "live.sh" "$(ls "$BIN")" "no temp file is left after a missing template"
echo "== restart_if_active: restarts only a running unit =="
reset_state
: >"${DEV}/active"
restart_if_active hosts-file-monitor.service >/dev/null
_t_not_called 'systemctl restart hosts-file-monitor.service' "a stopped unit is not started by the installer"
printf 'hosts-file-monitor.service\n' >"${DEV}/active"
restart_if_active hosts-file-monitor.service >/dev/null
_t_called 'systemctl restart hosts-file-monitor.service' "a running unit is restarted onto the new file"
_t_summary

View File

@ -122,13 +122,10 @@ create_execution_script() {
local exec_script="/usr/local/bin/periodic-system-maintenance.sh"
# Install from template with path substitutions
sed \
install_rendered_script "$TEMPLATE_MAINT_SCRIPT" "$exec_script" \
-e "s|__PACMAN_WRAPPER_INSTALL__|$PACMAN_WRAPPER_INSTALL|g" \
-e "s|__MAKEPKG_WRAPPER_INSTALL__|$MAKEPKG_WRAPPER_INSTALL|g" \
-e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g" \
"$TEMPLATE_MAINT_SCRIPT" >"$exec_script"
chmod +x "$exec_script"
-e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g"
echo "✓ Installed execution script from template: $exec_script"
}
@ -174,17 +171,22 @@ create_hosts_monitor_service() {
local monitor_script="/usr/local/bin/hosts-file-monitor.sh"
local monitor_service="/etc/systemd/system/hosts-file-monitor.service"
# Install the monitor script from template with substitution
sed -e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g" \
"$TEMPLATE_HOSTS_MONITOR" >"$monitor_script"
chmod +x "$monitor_script"
# Install the monitor script from template with substitution. The service
# runs it in a loop, so it is replaced by rename (see lib/atomic_install.sh).
install_rendered_script "$TEMPLATE_HOSTS_MONITOR" "$monitor_script" \
-e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g"
echo "✓ Installed hosts monitor script from template: $monitor_script"
# Install the systemd service from template
install -m 0644 "$TEMPLATE_HOSTS_SVC" "$monitor_service"
echo "✓ Installed hosts monitor service from template: $monitor_service"
# The running instance still holds the old inode; move it onto the new one.
restart_if_active hosts-file-monitor.service
}
# shellcheck source=lib/atomic_install.sh
source "$SCRIPT_DIR/lib/atomic_install.sh"
# shellcheck source=lib/periodic_browser.sh
source "$SCRIPT_DIR/lib/periodic_browser.sh"