Add overnight power-save to night-lockdown; harden dnsmasq with a watchdog
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled

Caps CPU (amd-pstate-epp) and GPU (nvidia-smi power limit) during the
existing night-lockdown window instead of migrating services to a
Raspberry Pi (tried, abandoned after SSH auth failed). A more aggressive
GPU PCI-unbind option is built but left disabled by default.

Also fixes an unrelated live incident found while testing: dnsmasq (this
PC's LAN DHCP/DNS server) had died with zero error logged, breaking WiFi
for every device on the network. Added a watchdog timer mirroring the
existing shutdown-timer-monitor-watchdog pattern, and whitelisted the
Tapo smart plug app so the overnight power reading is checkable during
the phone's curfew window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-23 11:58:37 +02:00
parent a7596d0442
commit 4f2c82fee7
4 changed files with 271 additions and 2 deletions

View File

@ -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"
]
}

View File

@ -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

View File

@ -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

View File

@ -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