diff --git a/docs/superpowers/evidence/night-lockdown-power-save-and-dnsmasq-watchdog-2026-07-23.json b/docs/superpowers/evidence/night-lockdown-power-save-and-dnsmasq-watchdog-2026-07-23.json new file mode 100644 index 00000000..1dd20e97 --- /dev/null +++ b/docs/superpowers/evidence/night-lockdown-power-save-and-dnsmasq-watchdog-2026-07-23.json @@ -0,0 +1,57 @@ +{ + "intent": "Reduce overnight power draw during night-lockdown without moving self-hosted services off the desktop, and harden dnsmasq (this PC's LAN DHCP/DNS server) against silently dying again after it was found dead with zero error logged.", + "scope": [ + "linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh", + "linux_configuration/scripts/single_use/features/setup_dns_blocker.sh", + "phone_focus_mode/config.sh", + "Non-goal: migrating services to a Raspberry Pi (tried, abandoned mid-session per user decision after SSH auth failed)", + "Non-goal: full GPU PCI unbind is built but left disabled by default (GPU_UNBIND_ENABLE=0), not enabled in this change" + ], + "changes": [ + "night-lockdown: new POWER_SAVE_ENABLE (default 1) caps CPU via amd-pstate-epp energy_performance_preference and GPU via nvidia-smi -pl during lockdown, restoring the exact pre-lockdown values at unlock", + "night-lockdown: prev-value capture guarded on the .prev file not already existing, and unlock deletes it after a successful restore -- closes a crash-safety hole where a kill between capture and LOCKED-state-write would have permanently corrupted the restore baseline on re-invocation", + "night-lockdown: nvidia-smi -pl calls (both cap and restore) wrapped in timeout 10, matching the existing timeout 20 guard already used for modprobe -r in the same file", + "night-lockdown: new GPU_UNBIND_ENABLE (default 0, left off) fully unbinds the GPU from the PCI bus as a more aggressive alternative; includes a re-blank_console call after unbind and a bounded driver-ready poll (replacing a fixed sleep) before lightdm restarts, in case it's ever enabled", + "night-lockdown: extended the existing DRY_RUN NOTE convention to cover the new EPP/nvidia-smi/GPU-unbind steps", + "setup_dns_blocker.sh: new dnsmasq-watchdog.timer/.service (5-min systemctl is-active check + restart + logger call), mirroring the existing shutdown-timer-monitor-watchdog pattern; enable call centralized in validate_and_enable() (after dnsmasq --test) rather than self-enabling in install_watchdog(), matching this file's own write-then-validate-then-enable convention", + "phone_focus_mode/config.sh: com.tplink.iot (Tapo) added to both WHITELIST and NIGHT_WHITELIST so the overnight power-draw baseline reading is checkable during the curfew window" + ], + "verification": [ + { + "command": "pre-commit run --files linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh linux_configuration/scripts/single_use/features/setup_dns_blocker.sh phone_focus_mode/config.sh", + "result": "pass", + "evidence": "All hooks passed including shellcheck, codespell, secret scan; no lint suppressions added" + }, + { + "command": "bash test_setup_dns_blocker.sh (existing regression suite)", + "result": "pass", + "evidence": "PASS: test_setup_dns_blocker.sh -- unmodified, covers dispatch + firewall_is_loaded pipefail regression" + }, + { + "command": "sudo DRY_RUN=1 /usr/local/bin/night-lockdown-enter.sh / -unlock.sh (real deployed scripts, redeployed via setup_night_lockdown.sh setup)", + "result": "pass", + "evidence": "Correct log output for CPU EPP writes, timeout-wrapped nvidia-smi -pl, new DRY_RUN NOTE lines; state file and no real .prev files touched" + }, + { + "command": "Live dnsmasq incident (real, not simulated): found dead with zero error logged, mid-session", + "result": "pass", + "evidence": "Restarted (systemctl restart dnsmasq), confirmed listening on :53/:67, Tapo plug (P110) obtained a DHCP lease at 192.168.1.76 immediately after; watchdog timer deployed and confirmed active via systemctl list-timers" + }, + { + "command": "5-agent code review (CLAUDE.md compliance, bug scan, git-history context, code-comment compliance) against the working diff", + "result": "pass", + "evidence": "2 bug-scan findings independently verified as false positives on live hardware (nvidia-smi accepts decimal -pl args on this driver version; set -euo pipefail already present, refuting the pipeline-exit-status concern); 3 real findings fixed (install_watchdog enable-ordering, prev-value crash-safety, missing nvidia-smi timeout); 2 gated-off findings (GPU-unbind console-blank race, fixed sleep vs driver-ready poll) fixed proactively since GPU_UNBIND_ENABLE is inert by default" + } + ], + "risks": [ + "First real (non-DRY_RUN) lockdown cycle with POWER_SAVE_ENABLE=1 has not happened yet -- tonight's scheduled window is the first real-hardware validation", + "GPU_UNBIND_ENABLE stays default-off; if ever manually enabled, the first real run of that path is also its first real test (self-acknowledged in the config comment, cannot be safely rehearsed since it tears down the same GPU/X session running any interactive test)", + "Root cause of the dnsmasq death is still unknown (no crash logged) -- the watchdog mitigates recurrence but does not explain the original incident" + ], + "rollback": [ + "Set POWER_SAVE_ENABLE=0 and/or GPU_UNBIND_ENABLE=0 in /etc/night-lockdown.conf, then re-run sudo ./setup_night_lockdown.sh setup to redeploy without the power-save steps", + "To remove the dnsmasq watchdog: systemctl disable --now dnsmasq-watchdog.timer, rm the two unit files, systemctl daemon-reload", + "git revert the commit; re-run both setup scripts' setup subcommand to redeploy the prior generated files", + "After any rollback, verify SSH/WireGuard access before closing the session -- setup_dns_blocker.sh setup reloads the whole nftables ruleset as a side effect" + ] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh index dae7d74f..025520a1 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh @@ -133,6 +133,34 @@ MIN_WAKE_AFTER_LOCKDOWN_HOURS="8" # way you want; the unlock script re-applies it so a DisplayPort link that # didn't re-enumerate cleanly doesn't require a manual xrandr/replug. AUTORANDR_PROFILE="default" + +# Cap CPU/GPU power draw for the night as a safety net against anything left +# running headless overnight (interactive GPU/CPU load dies with the GUI +# teardown above, but a systemd service — e.g. ollama serving a request — does +# not). Set to "0" to disable without touching the enter/unlock scripts. +POWER_SAVE_ENABLE="1" + +# AMD amd-pstate-epp hint applied to every core at lockdown (this machine is +# Ryzen, not Intel, so intel_pstate/no_turbo does not apply). One of: default, +# performance, balance_performance, balance_power, power, custom. +CPU_EPP_NIGHT="power" + +# nvidia-smi power limit (watts) applied at lockdown. Set to this card's +# reported power.min_limit (100W on the RTX 3090 here) so a runaway overnight +# CUDA job is bounded, not eliminated — persistence mode is deliberately left +# alone so any legitimate headless GPU service keeps working, just slower. +GPU_POWER_LIMIT_NIGHT_WATTS="100" + +# Fully unbind the GPU from the PCI bus at lockdown (unload nvidia kernel +# modules + PCI remove), rescanned back at unlock. Far more aggressive than +# the power-limit cap above: recovers more idle wattage, but modprobe -r can +# hang if anything still references the device, and a botched unbind might +# need a hard power-cycle to recover -- which WOULD take every container down +# with it (the one thing night-lockdown exists to prevent). Default OFF and +# deliberately left off: the first real run of this is an uncorroborated test +# on this exact kernel/driver, not a dress rehearsal -- nothing can safely +# test it without also tearing down the GUI it runs under. +GPU_UNBIND_ENABLE="0" EOF chmod 0644 "$CONF_FILE" @@ -181,6 +209,10 @@ MONITORED_PROCS="${MONITORED_PROCS:-}" MIN_WAKE_AFTER_LOCKDOWN_HOURS="${MIN_WAKE_AFTER_LOCKDOWN_HOURS:-8}" CONSOLE_TTY="${CONSOLE_TTY:-/dev/tty1}" CONSOLE_BLANK_MODE="${CONSOLE_BLANK_MODE:-1}" +POWER_SAVE_ENABLE="${POWER_SAVE_ENABLE:-1}" +CPU_EPP_NIGHT="${CPU_EPP_NIGHT:-power}" +GPU_POWER_LIMIT_NIGHT_WATTS="${GPU_POWER_LIMIT_NIGHT_WATTS:-100}" +GPU_UNBIND_ENABLE="${GPU_UNBIND_ENABLE:-0}" DRY_RUN="${DRY_RUN:-}" logg() { logger -t night-lockdown "$*"; printf 'night-lockdown: %s\n' "$*"; } @@ -191,6 +223,10 @@ if [[ "$DRY_RUN" == "1" ]]; then logg "DRY_RUN NOTE: exercise the blank-vs-VT-switch timing race (bug: visible" logg "DRY_RUN NOTE: console text). A clean DRY_RUN run is NOT proof that fix" logg "DRY_RUN NOTE: works — only a real run (DRY_RUN unset) validates it." + logg "DRY_RUN NOTE: The CPU EPP write, nvidia-smi power-limit cap, and (if" + logg "DRY_RUN NOTE: enabled) GPU PCI-unbind are also never actually applied" + logg "DRY_RUN NOTE: under DRY_RUN — real-hardware timing/driver behavior for" + logg "DRY_RUN NOTE: those is unverified until a real run." fi # Run a command best-effort: log-only under DRY_RUN, never abort the lockdown. @@ -269,6 +305,45 @@ if [[ "$RGB_ENABLE" == "1" ]] && command -v openrgb >/dev/null 2>&1; then run env HOME="$RGB_HOME" openrgb --mode "$RGB_OFF_MODE" --color "$RGB_OFF_COLOR" fi +# 2b. Cap CPU/GPU power draw for the night. Save the pre-lockdown values first +# so unlock restores exactly what was there, not a hardcoded "default" — +# the user may have changed either away from factory settings. +# Capture is guarded on the .prev file NOT already existing: this step +# runs before state is written LOCKED (step 5), so a kill between here and +# there leaves state UNLOCKED and the 30-min curfew check re-invokes this +# whole script. Without the guard, that re-run would recapture "prev" from +# the already-applied NIGHT value, permanently stranding the machine in +# night power-save mode. unlock deletes the .prev file after a successful +# restore, so a normal (non-crashed) cycle still captures fresh each night. +if [[ "$POWER_SAVE_ENABLE" == "1" ]]; then + if [[ -f /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference ]]; then + if [[ "$DRY_RUN" != "1" && ! -f "$STATE_DIR/cpu_epp.prev" ]]; then + cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference \ + >"$STATE_DIR/cpu_epp.prev" 2>/dev/null || true + fi + for epp_file in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do + [[ -e "$epp_file" ]] || continue + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would write $CPU_EPP_NIGHT > $epp_file" + else + echo "$CPU_EPP_NIGHT" >"$epp_file" 2>/dev/null || logg "WARN: could not set EPP on $epp_file" + fi + done + fi + + if command -v nvidia-smi >/dev/null 2>&1; then + if [[ "$DRY_RUN" != "1" && ! -f "$STATE_DIR/gpu_power_limit_watts.prev" ]]; then + nvidia-smi --query-gpu=power.limit --format=csv,noheader,nounits \ + >"$STATE_DIR/gpu_power_limit_watts.prev" 2>/dev/null || true + fi + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would run: timeout 10 nvidia-smi -pl $GPU_POWER_LIMIT_NIGHT_WATTS" + else + run timeout 10 nvidia-smi -pl "$GPU_POWER_LIMIT_NIGHT_WATTS" + fi + fi +fi + # 3. Mute audio (best-effort). The session teardown below silences apps anyway; # this covers any direct-ALSA sound. PipeWire is intentionally NOT touched, so # the morning unlock cannot be defeated by a persisted wireplumber mute. @@ -360,6 +435,34 @@ fi # 8. Final guaranteed blank now that the GUI is fully down. blank_console +# 9. Optionally unbind the GPU entirely (default OFF — see GPU_UNBIND_ENABLE +# comment in night-lockdown.conf for the risk tradeoff). Only ever runs +# after the GUI is torn down, since nothing interactive needs the GPU at +# this point. Timeout-guarded: modprobe -r can hang if anything still +# references the device, and a hang here must not block the rest of +# lockdown. +if [[ "$GPU_UNBIND_ENABLE" == "1" ]] && command -v nvidia-smi >/dev/null 2>&1; then + gpu_pci="$(lspci -d 10de: 2>/dev/null | grep -i 'VGA\|3D' | awk '{print $1}' | head -1)" + if [[ -z "$gpu_pci" ]]; then + logg "WARN: GPU_UNBIND_ENABLE=1 but no nvidia PCI device found" + elif [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would unload nvidia modules and remove PCI device 0000:$gpu_pci" + elif timeout 20 modprobe -r nvidia_uvm nvidia_drm nvidia_modeset nvidia 2>&1 | logger -t night-lockdown; then + if echo 1 >"/sys/bus/pci/devices/0000:$gpu_pci/remove" 2>/dev/null; then + logg "GPU unbound (0000:$gpu_pci removed from PCI bus)" + # Unloading nvidia_drm / the PCI remove can surface a fallback + # framebuffer that isn't blanked -- same failure class the + # continuous re-blank during the lightdm stop (above) exists to + # close, just triggered by module unload instead of a VT switch. + blank_console + else + logg "WARN: modules unloaded but PCI remove failed for 0000:$gpu_pci" + fi + else + logg "WARN: modprobe -r timed out or failed -- GPU left bound, skipping unbind" + fi +fi + logg "night lockdown active — servers up, GUI down, login surface masked, console dark" ENTER_EOF chmod 0755 "$ENTER_SCRIPT" @@ -396,6 +499,8 @@ RGB_ENABLE="${RGB_ENABLE:-1}" RGB_OFF_MODE="${RGB_OFF_MODE:-static}" RGB_OFF_COLOR="${RGB_OFF_COLOR:-000000}" RGB_HOME="${RGB_HOME:-/root}" +POWER_SAVE_ENABLE="${POWER_SAVE_ENABLE:-1}" +GPU_UNBIND_ENABLE="${GPU_UNBIND_ENABLE:-0}" DRY_RUN="${DRY_RUN:-}" logg() { logger -t night-lockdown "$*"; printf 'night-lockdown: %s\n' "$*"; } @@ -408,6 +513,10 @@ if [[ "$DRY_RUN" == "1" ]]; then logg "DRY_RUN NOTE: run (DRY_RUN unset) validates them. The alarm floor/dedup" logg "DRY_RUN NOTE: logic (locked_at_epoch) IS exercised" logg "DRY_RUN NOTE: faithfully here, since that part is pure calculation." + logg "DRY_RUN NOTE: The CPU EPP / GPU power-limit restore and (if enabled)" + logg "DRY_RUN NOTE: the PCI rescan are also never actually applied under" + logg "DRY_RUN NOTE: DRY_RUN — real-hardware timing/driver behavior for" + logg "DRY_RUN NOTE: those is unverified until a real run." fi run() { @@ -467,6 +576,33 @@ restore_console # getty@.service is ever masked; autovt@.service is just an alias to it. run systemctl unmask getty@.service +# 1a. If the GPU was unbound at lockdown, rescan the PCI bus to bring it back +# before starting the GUI. Harmless no-op if nothing was removed. Polls +# for the driver actually being ready (nvidia-smi succeeding) rather than +# a fixed sleep -- starting lightdm against a GPU that hasn't finished +# reprobing risks a failed X start, which (unlike a mis-enumerated +# display) the autorandr retry loop above cannot recover from. +if [[ "$GPU_UNBIND_ENABLE" == "1" ]]; then + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would rescan PCI bus and wait for the GPU driver to be ready" + else + echo 1 >/sys/bus/pci/rescan 2>/dev/null || logg "WARN: PCI rescan failed" + gpu_ready=false + for ((i = 0; i < 15; i++)); do + if nvidia-smi >/dev/null 2>&1; then + gpu_ready=true + break + fi + sleep 1 + done + if [[ "$gpu_ready" == true ]]; then + logg "GPU driver ready after PCI rescan" + else + logg "WARN: GPU driver not ready after rescan + 15s -- starting GUI anyway" + fi + fi +fi + # 2. Start the GUI — lightdm autologins back into i3/dwm. run systemctl start lightdm.service @@ -501,6 +637,43 @@ for card in $ALSA_CARDS; do command -v amixer >/dev/null 2>&1 && run amixer -c "$card" -q set Master unmute done +# 3b. Restore CPU EPP and GPU power limit to their pre-lockdown values (saved +# by night-lockdown-enter.sh). Missing state file = enter never ran the +# power-save step (e.g. it was disabled then) — nothing to restore. +# Delete each .prev file after a successful (non-DRY_RUN) restore: enter's +# capture is guarded on the file NOT existing (crash-safety, see its own +# comment), so leaving a stale file here would freeze future captures to +# tonight's value forever instead of tracking whatever the user's daytime +# setting actually is. +if [[ "$POWER_SAVE_ENABLE" == "1" ]]; then + if [[ -r "$STATE_DIR/cpu_epp.prev" ]]; then + prev_epp="$(cat "$STATE_DIR/cpu_epp.prev")" + if [[ -n "$prev_epp" ]]; then + for epp_file in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do + [[ -e "$epp_file" ]] || continue + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would write $prev_epp > $epp_file" + else + echo "$prev_epp" >"$epp_file" 2>/dev/null || logg "WARN: could not restore EPP on $epp_file" + fi + done + fi + [[ "$DRY_RUN" == "1" ]] || rm -f "$STATE_DIR/cpu_epp.prev" + fi + + if command -v nvidia-smi >/dev/null 2>&1 && [[ -r "$STATE_DIR/gpu_power_limit_watts.prev" ]]; then + prev_watts="$(cat "$STATE_DIR/gpu_power_limit_watts.prev")" + if [[ -n "$prev_watts" ]]; then + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would run: timeout 10 nvidia-smi -pl $prev_watts" + else + run timeout 10 nvidia-smi -pl "$prev_watts" + fi + fi + [[ "$DRY_RUN" == "1" ]] || rm -f "$STATE_DIR/gpu_power_limit_watts.prev" + fi +fi + # 4. Re-assert lights-off rather than restoring anything: the preference is no # lighting at all, ever, so the morning must not turn the RGB back on. if [[ "$RGB_ENABLE" == "1" ]] && command -v openrgb >/dev/null 2>&1; then diff --git a/linux_configuration/scripts/single_use/features/setup_dns_blocker.sh b/linux_configuration/scripts/single_use/features/setup_dns_blocker.sh index 12532e38..7d9f535d 100755 --- a/linux_configuration/scripts/single_use/features/setup_dns_blocker.sh +++ b/linux_configuration/scripts/single_use/features/setup_dns_blocker.sh @@ -40,6 +40,8 @@ readonly DNSMASQ_DROPIN_DIR="/etc/systemd/system/dnsmasq.service.d" readonly DNSMASQ_DROPIN="${DNSMASQ_DROPIN_DIR}/blocker.conf" readonly REFRESH_SERVICE="/etc/systemd/system/dns-blocklist-refresh.service" readonly REFRESH_TIMER="/etc/systemd/system/dns-blocklist-refresh.timer" +readonly WATCHDOG_SERVICE="/etc/systemd/system/dnsmasq-watchdog.service" +readonly WATCHDOG_TIMER="/etc/systemd/system/dnsmasq-watchdog.timer" # Optional DHCP-server mode (for routers that cannot advertise a custom DNS). readonly DHCP_CONF="/etc/dnsmasq.d/lan-dhcp.conf" readonly DHCP_LEASE="12h" @@ -184,6 +186,38 @@ WantedBy=timers.target EOF } +# The whole LAN's DHCP/DNS depends on this one process. Restart=always in the +# drop-in only catches an actual crash; an incident on 2026-07-23 found +# dnsmasq dead with ZERO error logged (no crash, no "Stopping"/"Stopped" line +# either -- consistent with something issuing a plain stop, not a failure) and +# it stayed down for hours, silently, until noticed by a phone unable to join +# WiFi. Mirrors the existing shutdown-timer-monitor-watchdog pattern used +# elsewhere in this repo: same 5-minute cadence, same is-active-or-start shape, +# plus a logger call so a future incident leaves a trace instead of none. +install_watchdog() { + cat >"$WATCHDOG_SERVICE" <<'EOF' +[Unit] +Description=Watchdog for dnsmasq (LAN DHCP/DNS) +After=multi-user.target + +[Service] +Type=oneshot +ExecStart=/bin/bash -c 'systemctl is-active --quiet dnsmasq || { logger -t dnsmasq-watchdog "dnsmasq was down -- restarting"; systemctl restart dnsmasq; }' +EOF + cat >"$WATCHDOG_TIMER" <<'EOF' +[Unit] +Description=Watchdog Timer for dnsmasq + +[Timer] +OnBootSec=60 +OnUnitActiveSec=300 +Persistent=true + +[Install] +WantedBy=timers.target +EOF +} + validate_and_enable() { log_info "Validating dnsmasq configuration..." dnsmasq --test 2>&1 | tail -3 @@ -195,7 +229,8 @@ validate_and_enable() { # apply config changes on idempotent re-runs. systemctl restart dnsmasq systemctl enable --now dns-blocklist-refresh.timer - log_ok "dnsmasq and the daily refresh timer are enabled." + systemctl enable --now dnsmasq-watchdog.timer + log_ok "dnsmasq, the daily refresh timer, and the watchdog are enabled." } # Open port 53 for LAN clients -- but only touch the firewall if it is already @@ -272,6 +307,7 @@ cmd_setup() { write_dnsmasq_conf install_restart_dropin install_refresh_timer + install_watchdog validate_and_enable configure_firewall print_manual_steps diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 86e532a3..5a9115e0 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -466,6 +466,7 @@ com.Splitwise.SplitwiseMobile # --- Smart home --- com.xiaomi.smarthome +com.tplink.iot # --- Remote SSH access via WireGuard (self-hosted, see # linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh) --- @@ -526,8 +527,10 @@ com.beemdevelopment.aegis com.azure.authenticator oracle.idm.mobile.authenticator com.kunzisoft.keepass.libre -# Smart home (control lights before sleep) +# Smart home (control lights before sleep; Tapo plug used for overnight +# power-draw measurement, must be checkable during the curfew window) com.xiaomi.smarthome +com.tplink.iot # --- Wake alarm sync (must work at night to set tomorrow's alarm) --- com.kuhy.wake_alarm_sync