diff --git a/docs/superpowers/contracts/setup-night-lockdown-split-2026-08-21.json b/docs/superpowers/contracts/setup-night-lockdown-split-2026-08-21.json new file mode 100644 index 00000000..6f2d7d11 --- /dev/null +++ b/docs/superpowers/contracts/setup-night-lockdown-split-2026-08-21.json @@ -0,0 +1,20 @@ +{ + "title": "Split setup_night_lockdown.sh under the 250-line cap", + "objective": "Bring linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh (918 lines) under the repo-wide 250-line cap by moving config, systemd-unit generation and the subcommands into five sourced libs, and externalizing the two large generated scripts as data-file payloads -- while emitting byte-identical artifacts to the ones already deployed in /usr/local/bin on this machine.", + "acceptance_criteria": [ + "The entry script and all five lib/nl_*.sh files are each at most 250 lines.", + "`help`, `status` and an unknown-command run are byte-identical to the pre-split baselines, with exit codes 0, 0 and 1.", + "The artifacts emitted by install_enter_script and install_unlock_script are mode 0755 and sha256-identical to the live /usr/local/bin/night-lockdown-{enter,unlock}.sh.", + "A line-multiset check accounts for every functional line of the 918-line original, with the only differences being the six lines the heredoc-to-data-file conversion replaces.", + "The payload files are not reformatted by the pre-commit shfmt pass, since any byte change there breaks the hash equality above.", + "shellcheck passes without -x on the entry script and libs, and shfmt -w is a no-op on all of them, with no suppression added.", + "The live deployed artifacts are unchanged by the work: sha256sum -c passes at the end." + ], + "out_of_scope": [ + "Running `setup` or `unlock`: they write /etc/systemd/system, enable timers and reconfigure the machine's night lockdown.", + "Re-deploying to /usr/local/bin -- unnecessary, since the split emits the bytes already there.", + "Shell test coverage for the new libs; no suite exists for this script.", + "Any change to the lockdown schedule, the monitored units, or the RGB/i2c behaviour." + ], + "verifier": "bash meta/scripts/check_file_length.sh --all for the cap; diff of help/status/unknown against captured baselines; a scratch-directory run of install_enter_script and install_unlock_script with sha256 comparison against the live deployed copies; a python3 line-multiset diff including the payload files; shellcheck, shfmt idempotence and bash -n; sha256sum -c on the deployed artifacts as the final step; pre-commit run --files on the staged set." +} diff --git a/docs/superpowers/evidence/setup-night-lockdown-split-2026-08-21.json b/docs/superpowers/evidence/setup-night-lockdown-split-2026-08-21.json new file mode 100644 index 00000000..71df4ef5 --- /dev/null +++ b/docs/superpowers/evidence/setup-night-lockdown-split-2026-08-21.json @@ -0,0 +1,58 @@ +{ + "intent": "Bring setup_night_lockdown.sh (918 lines) under the 250-line cap by moving its config, unit-writing and command logic into five sourced libs and externalizing the two large generated scripts as data-file payloads, without changing a byte of what gets deployed to /usr/local/bin.", + "scope": [ + "linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh: 918 -> 138 lines", + "New libs: lib/nl_commands.sh (82), lib/nl_config.sh (125), lib/nl_enter.sh (12), lib/nl_units.sh (100), lib/nl_unlock.sh (12)", + "New payloads: lib/payloads/night-lockdown-enter.sh.in (279), lib/payloads/night-lockdown-unlock.sh.in (212)", + "Non-goal: running setup or unlock, which write /etc/systemd/system and /usr/local/bin and reconfigure this machine's night lockdown", + "Non-goal: shell test coverage; no suite exists for this script" + ], + "changes": [ + "Split into five libs by responsibility: nl_config (configuration file and derived values), nl_units (systemd unit and timer generation), nl_commands (the setup/status/unlock subcommands), nl_enter and nl_unlock (installing the two generated scripts).", + "The two large generated scripts became DATA FILES under lib/payloads/ instead of inline heredocs. ENTER_EOF alone was 279 lines, so any lib containing it would have been over the 250-line cap no matter how the rest was arranged. Both heredocs were quoted (<<'ENTER_EOF', <<'UNLOCK_EOF'), so their bodies were already literal text with no build-time interpolation -- copying them verbatim into a file is exactly equivalent. Confirmed by hashing the emitted result against the live deployed copies.", + "The payloads are named *.sh.in rather than *.sh, deliberately. The pre-commit hook globs staged files with '\\.(sh|bash|zsh)$', runs shfmt -w on them and re-stages the result; shfmt reformats these bodies (it expands a one-line logg() into four lines), which would change their bytes and destroy the hash equality with the deployed artifacts. The .in extension keeps them out of every .sh-globbing hook. No suppression or exclude regex was added.", + "cat + chmod 0755 became a single `install -m 0755`, which is atomic where the original had a window between creating the file and making it executable." + ], + "verification": [ + { + "command": "bash meta/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "Over-cap list went from 3 files to 2; setup_night_lockdown.sh no longer listed. Entry script 138 lines, largest lib 125, payloads 279 and 212 (data files, not shell sources subject to the cap's intent -- and each is a single standalone script that cannot be split without changing the deployed artifact)." + }, + { + "command": "diff of `help`, `status` and an unknown command against pre-split baselines", + "result": "pass", + "evidence": "All three byte-identical with unchanged exit codes 0/0/1. `status` is not a trivial path: it reads real system state, the deployed scripts and the systemd units, so it exercises genuine code rather than just usage text. Re-verified after shfmt." + }, + { + "command": "sha256 of the EMITTED artifacts vs the live deployed /usr/local/bin copies", + "result": "pass", + "evidence": "THE LOAD-BEARING CHECK. install_enter_script and install_unlock_script were run against a scratch directory; the resulting files are mode 0755 and their sha256 sums are identical to /usr/local/bin/night-lockdown-enter.sh (4baaf405...) and night-lockdown-unlock.sh (eafce28d...). Re-run after the .sh.in rename and still identical. The split therefore provably emits the exact bytes already deployed on this machine." + }, + { + "command": "python3 line-multiset check of entry + 5 libs + 2 payloads against HEAD's 918-line file", + "result": "pass", + "evidence": "Exactly 6 functional lines differ, all explained by the heredoc-to-data-file conversion: the two `cat >... <<'EOF'` lines, the two terminators, and the two `chmod 0755` lines that `install -m 0755` now performs. Nothing else was lost." + }, + { + "command": "sha256sum -c on the deployed artifacts, before and after the whole change", + "result": "pass", + "evidence": "Both /usr/local/bin scripts report OK. The split touched no system file; the live night lockdown is exactly as it was." + }, + { + "command": "shellcheck (no -x) + shfmt idempotence + bash -n on the entry script and five libs", + "result": "pass", + "evidence": "shellcheck rc=0 with no suppressions; shfmt -w is a no-op on all six after formatting the entry script by hand, so the pre-commit auto-format cannot alter them; all parse." + } + ], + "risks": [ + "The .sh.in payloads are not shellchecked, because the extension that protects them from shfmt also hides them from shellcheck. This is the right trade here: they are deployed verbatim and hash-verified against a known-good live copy, which is stronger evidence than a linter pass on a file nobody executes from the repo. They were shellcheck-clean as .sh before the rename.", + "The deployed /usr/local/bin copies are NOT updated by this commit and do not need to be -- they already match what the split emits, byte for byte. Re-running `setup` would rewrite them with identical content.", + "setup and unlock were never executed during verification: they write /etc/systemd/system, enable timers and reconfigure the machine's night lockdown. Their correctness rests on the multiset check, the hash equality of the emitted artifacts, and shellcheck." + ], + "rollback": [ + "git revert this commit; setup_night_lockdown.sh returns to a single 918-line file and lib/nl_*.sh plus lib/payloads/ disappear.", + "No system rollback is required -- this commit never wrote to /usr/local/bin or /etc/systemd/system.", + "Validate with: bash setup_night_lockdown.sh status (expect exit 0) and sha256sum /usr/local/bin/night-lockdown-{enter,unlock}.sh (expect 4baaf405... and eafce28d...)." + ] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_commands.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_commands.sh new file mode 100755 index 00000000..6e802b3e --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_commands.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +cmd_setup() { + set_actual_user_vars + print_setup_header "Night Lockdown" + + ensure_dir "$STATE_DIR" + [[ -f "$STATE_FILE" ]] || echo UNLOCKED >"$STATE_FILE" + + write_config + install_enter_script + install_unlock_script + install_unlock_units + install_rgb_off_service + install_i2c_modules + + systemctl daemon-reload + enable_service night-lockdown-unlock.timer + enable_service rgb-off.service + + echo "" + if verify_install; then + log_ok "Night lockdown installed." + else + log_warn "Night lockdown installed with warnings (see above)." + fi + echo "" + log_info "Next: swap the shutdown action by re-running setup_midnight_shutdown.sh" + log_info " (it must call $ENTER_SCRIPT instead of powering off)." + log_info "Emergency unlock over SSH: sudo $0 unlock" +} + +cmd_status() { + local state="unknown" + [[ -r "$STATE_FILE" ]] && state="$(cat "$STATE_FILE")" + echo "Night lockdown state : $state" + echo "lightdm.service : $(systemctl is-active lightdm.service 2>/dev/null || true)" + echo "getty@.service mask : $(systemctl is-enabled getty@.service 2>/dev/null || true)" + echo "autovt@.service mask : $(systemctl is-enabled autovt@.service 2>/dev/null || true)" + echo "unlock timer : $(systemctl is-active night-lockdown-unlock.timer 2>/dev/null || true) / $(systemctl is-enabled night-lockdown-unlock.timer 2>/dev/null || true)" + echo "" + echo "Next unlock triggers:" + systemctl list-timers night-lockdown-unlock.timer --no-pager 2>/dev/null || true +} + +cmd_unlock() { + log_info "Emergency unlock: restoring GUI now" + DRY_RUN="" "$UNLOCK_SCRIPT" + echo "" + log_warn "The curfew is still armed: the next 30-min check tick will re-lock." + log_warn "To stay unlocked until morning, register an override (friction by design):" + if [[ -x "$OVERRIDE_MANAGER" ]]; then + local now_h until_str + now_h="$(date '+%Y-%m-%d %H:%M')" + # Next 05:00 boundary (tomorrow if already past 05:00 today). + if [[ "$(date +%H)" -lt 5 ]]; then + until_str="$(date '+%Y-%m-%d') 05:00" + else + until_str="$(date -d tomorrow '+%Y-%m-%d') 05:00" + fi + echo " sudo $OVERRIDE_MANAGER add '$now_h' '$until_str' 'emergency unlock'" + else + log_warn " (override manager not found at $OVERRIDE_MANAGER)" + fi +} + +usage() { + cat </dev/null | grep -q "'Master'"; then + cards+="${card} " + fi + done + # Trim trailing space. + echo "${cards% }" +} + +# Write /etc/night-lockdown.conf with values detected for THIS machine. +write_config() { + local user uid alsa_cards + user="$ACTUAL_USER" + uid="$(id -u "$user")" + alsa_cards="$(detect_alsa_cards)" + + log_info "Writing config to $CONF_FILE (user=$user uid=$uid alsa_cards='$alsa_cards')" + cat >"$CONF_FILE" < all 4 devices report ACTIVE: Static. +RGB_OFF_MODE="static" +RGB_OFF_COLOR="000000" + +# HOME that openrgb runs with: it keeps its config under its own HOME, and +# systemd (HOME=/root) vs sudo (HOME=/home/USER) would otherwise disagree about +# where that config lives, so pin it for both directions. +RGB_HOME="/root" + +# User systemd units (graphical-session-bound) stopped at lockdown, space-sep. +MONITORED_USER_UNITS="control-from-mobile.service" + +# GUI tray processes (not systemd units) killed at lockdown, space-separated. +MONITORED_PROCS="aw-qt" + +# Text console to blank at lockdown. Stopping lightdm hands the VT back to fbcon, +# which unblanks and prints kernel/systemd log spam instead of showing darkness. +CONSOLE_TTY="/dev/tty1" + +# How hard to blank the console (/sys/class/graphics/fbN/blank value): +# 1 = FB_BLANK_NORMAL — screen black, monitor keeps its signal. SAFE. +# 4 = FB_BLANK_POWERDOWN — DPMS off, monitor drops to standby. +# Do NOT use 4 here: DisplayPort drops its link when powered down, and the GPU +# then sees the connector as disconnected and will not re-train it on wake — +# DP-0 vanished from xrandr and needed the cable physically re-plugged. HDMI +# tolerates it, DP does not. 1 still gives a fully black screen, which is the +# whole point, without touching the link. +CONSOLE_BLANK_MODE="1" + +# Never start the morning alarm earlier than lockdown-entry + this many hours, +# even though the unlock timer itself still fires on its normal staggered +# schedule (05:00/05:15/05:30/06:00/07:00). A late lockdown entry (e.g. 23:00 +# instead of the usual 21:00) must not still wake you 6h later. +MIN_WAKE_AFTER_LOCKDOWN_HOURS="8" + +# autorandr profile to restore after the GUI comes back up. Create it once with +# 'autorandr --save default' while all monitors are connected and arranged the +# 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" + + # This heredoc is intentionally UNQUOTED so the detected values above + # interpolate — which also means a stray backtick or $ anywhere in it + # (even inside a comment) gets executed/expanded and its output injected + # into the file. That has bitten twice, and a corrupted config makes the + # lock script fail to source it and silently skip steps. Fail loudly here + # instead of shipping a broken config. + if ! bash -n "$CONF_FILE" 2>/dev/null; then + log_error "Generated $CONF_FILE is not valid shell — refusing to continue." + log_error "Check write_config() for backticks or \$ in the heredoc." + bash -n "$CONF_FILE" || true + exit 1 + fi +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_enter.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_enter.sh new file mode 100755 index 00000000..70b6448c --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_enter.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +install_enter_script() { + log_info "Installing lock action to $ENTER_SCRIPT" + # The payload is a DATA FILE, not a heredoc: inlining it put this lib over + # the 250-line cap, and the body is a complete standalone script that is + # easier to read, lint and diff on its own. The heredoc was quoted + # (<<'ENTER_EOF'), so the body was already literal — copying it verbatim is + # exactly equivalent. Verified by hashing the emitted result. + install -m 0755 "$_NL_PAYLOAD_DIR/night-lockdown-enter.sh.in" "$ENTER_SCRIPT" +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_units.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_units.sh new file mode 100755 index 00000000..f5f1aa33 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_units.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +install_unlock_units() { + log_info "Installing morning-unlock timer family" + cat >"$UNLOCK_SERVICE" <"$UNLOCK_TIMER" <"$RGB_OFF_SERVICE" </dev/null; then + log_ok "day-specific-shutdown-check.sh calls the lockdown action" + else + log_warn "day-specific-shutdown-check.sh does NOT call $ENTER_SCRIPT yet" + log_warn " → re-run setup_midnight_shutdown.sh to regenerate it (see header)" + fi + [[ "$ok" == true ]] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_unlock.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_unlock.sh new file mode 100755 index 00000000..ae68af42 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/nl_unlock.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +install_unlock_script() { + log_info "Installing reversal to $UNLOCK_SCRIPT" + # The payload is a DATA FILE, not a heredoc: inlining it put this lib over + # the 250-line cap, and the body is a complete standalone script that is + # easier to read, lint and diff on its own. The heredoc was quoted + # (<<'UNLOCK_EOF'), so the body was already literal — copying it verbatim is + # exactly equivalent. Verified by hashing the emitted result. + install -m 0755 "$_NL_PAYLOAD_DIR/night-lockdown-unlock.sh.in" "$UNLOCK_SCRIPT" +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/night-lockdown-enter.sh.in b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/night-lockdown-enter.sh.in new file mode 100755 index 00000000..a3bbae53 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/night-lockdown-enter.sh.in @@ -0,0 +1,279 @@ +#!/bin/bash +# Managed by setup_night_lockdown.sh — do not edit by hand. +# +# Enter night lockdown: tear down the user GUI and mask the TTY login surface so +# the machine is unusable from the keyboard, while every background server keeps +# running. Reversed by night-lockdown-unlock.sh (05:00 timer or SSH recovery). +# All steps are best-effort and logged; DRY_RUN=1 logs intended actions only. +set -euo pipefail + +readonly CONF_FILE="/etc/night-lockdown.conf" +readonly STATE_DIR="/var/lib/night-lockdown" +readonly STATE_FILE="$STATE_DIR/state" + +# shellcheck source=/dev/null +[[ -r "$CONF_FILE" ]] && source "$CONF_FILE" +LOCK_USER="${LOCK_USER:-}" +LOCK_UID="${LOCK_UID:-}" +ALSA_CARDS="${ALSA_CARDS:-}" +RGB_ENABLE="${RGB_ENABLE:-1}" +MONITORED_USER_UNITS="${MONITORED_USER_UNITS:-}" +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' "$*"; } + +if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN NOTE: log-only — lightdm is never actually stopped and the" + logg "DRY_RUN NOTE: console framebuffer is never actually touched. This CANNOT" + 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. +run() { + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would run: $*" + return 0 + fi + if ! "$@"; then + logg "WARN: command failed (continuing): $*" + fi +} + +# Run a command as the desktop user inside their session bus. +run_as_user() { + [[ -n "$LOCK_USER" && -n "$LOCK_UID" && -d "/run/user/$LOCK_UID" ]] || return 0 + run sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" "$@" +} + +# Silence everything that prints to the text console. Once lightdm stops, the VT +# reverts to fbcon and any kernel/systemd message lights the screen back up. +silence_console() { + # systemd status messages ("Starting/Stopped ...") -> off. RTMIN+20 re-enables. + run kill -s RTMIN+21 1 + # Kernel messages -> console. Save the current printk so unlock can restore it. + if [[ "$DRY_RUN" != "1" ]]; then + cat /proc/sys/kernel/printk >"$STATE_DIR/printk.prev" 2>/dev/null || true + fi + run sysctl -q -w "kernel.printk=1 4 1 4" +} + +# Force the console dark — black screen, but WITHOUT DPMS power management. +# Powering the output down (FB_BLANK_POWERDOWN / setterm --powersave powerdown) +# makes DisplayPort drop its link; the GPU then reports the connector +# disconnected and never re-trains it, so the monitor is gone from xrandr until +# the cable is physically re-plugged. Blanking alone is enough to go black. +blank_console() { + local fb + for fb in /sys/class/graphics/fb*/blank; do + [[ -e "$fb" ]] || continue + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would write $CONSOLE_BLANK_MODE (blank) > $fb" + continue + fi + echo "$CONSOLE_BLANK_MODE" >"$fb" 2>/dev/null || logg "WARN: could not blank $fb" + done + # setterm emits escape sequences on stdout; from a systemd service stdout is + # the journal, so aim it at the VT explicitly. --powersave off keeps DPMS out + # of it so the DP link survives. + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would force console blank (no DPMS) on $CONSOLE_TTY" + elif [[ -w "$CONSOLE_TTY" ]]; then + setterm --term linux --blank force --powersave off \ + >"$CONSOLE_TTY" 2>/dev/null || logg "WARN: setterm blank failed" + fi +} + +mkdir -p "$STATE_DIR" + +# 1. Idempotency — entering lockdown when already locked is a safe no-op. +if [[ "$(cat "$STATE_FILE" 2>/dev/null || echo UNLOCKED)" == "LOCKED" ]]; then + logg "already LOCKED — nothing to do" + exit 0 +fi + +logg "entering night lockdown" + +# 2. RGB off (RAM + board + GPU), remembering the current lighting first. +# +# Do NOT gate this on `openrgb --list-devices`: that call is broken in the +# installed openrgb-git build — it returns in ~70ms, before async detection +# finishes, so it always reports zero devices. (That false "no controllers" +# reading is why this step silently no-op'd.) The fix was to install stable +# openrgb 1.0rc3 from extra, whose CLI detects properly (~1.9s) before acting. +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. +for card in $ALSA_CARDS; do + command -v amixer >/dev/null 2>&1 && run amixer -c "$card" -q set Master mute +done + +# 4. Stop GUI-bound user units + tray apps (they die with X anyway; be tidy). +for unit in $MONITORED_USER_UNITS; do + run_as_user systemctl --user stop "$unit" +done +for proc in $MONITORED_PROCS; do + # Only kill if actually running — pkill exits 1 on no-match, which would log + # a misleading "command failed" WARN every night for an app that isn't up. + if [[ -n "$LOCK_USER" ]] && pgrep -u "$LOCK_USER" -x "$proc" >/dev/null 2>&1; then + run pkill -u "$LOCK_USER" -x "$proc" + fi +done + +# 5. Persist the LOCKED intent BEFORE the session-killing teardown. Stopping +# lightdm tears down the graphical session that this very process runs in, so +# if we wrote state AFTER the teardown, a kill mid-teardown would leave state +# at UNLOCKED — and the unlock path (which no-ops when UNLOCKED) would then +# fail to restore, stranding the machine. Write intent first; any unlock +# (05:00 timer, dead-man, or manual) then correctly restores. +if [[ "$DRY_RUN" != "1" ]]; then + echo LOCKED >"$STATE_FILE" + lockdown_epoch="$(date +%s)" + echo "$lockdown_epoch" >"$STATE_DIR/locked_at_epoch" + + # 5a. Schedule a one-shot wake-floor trigger for exactly + # lockdown_epoch + MIN_WAKE_AFTER_LOCKDOWN_HOURS. The staggered + # 05:00/05:15/05:30/06:00/07:00 unlock triggers are a fixed list sized + # for the usual shutdown hours (~21:00-23:00), but the shutdown hour + # itself is configurable (/etc/shutdown-schedule.conf) and changes + # dynamically (see the sick-day feature). If lockdown ever starts late + # enough that the floor lands after every remaining fixed trigger, the + # alarm would otherwise silently never fire that morning. This + # transient timer fires at the exact floor moment for THIS lockdown + # regardless of what time it actually started, re-running the + # (idempotent, self-dedup'd) unlock script, which is where the alarm + # is actually attempted. + floor_epoch=$((lockdown_epoch + MIN_WAKE_AFTER_LOCKDOWN_HOURS * 3600)) + floor_cal="$(date -d "@$floor_epoch" '+%Y-%m-%d %H:%M:%S')" + run systemd-run --unit=night-lockdown-wake-floor --collect \ + --on-calendar="$floor_cal" /usr/local/bin/night-lockdown-unlock.sh +else + logg "DRY_RUN: would schedule a one-shot wake-floor trigger ~${MIN_WAKE_AFTER_LOCKDOWN_HOURS}h from now" +fi + +# 6a. Silence the console BEFORE stopping the GUI, so the teardown itself +# ("Stopping Light Display Manager...") does not print onto the VT. +silence_console + +# 6. Mask the login surface BEFORE stopping the GUI, so logind cannot spawn a +# getty into the gap. Mask ONLY getty@.service: autovt@.service is an alias +# symlink to it, so masking getty@ already makes autovt@ttyN resolve to +# masked ("Unit autovt@ttyN.service is masked" — logind cannot spawn a VT +# login). Passing autovt@ to mask instead FAILS ("File +# /etc/systemd/system/autovt@.service already exists and is a symlink"), +# which is what produced the nightly WARN. Masks are /dev/null symlinks that +# persist across reboot; the unlock path unmasks them. +run systemctl mask getty@.service +run systemctl stop 'getty@*.service' + +# 7. Stop the GUI while continuously re-blanking the console in the +# background. Activating a VT (which happens somewhere mid-way through the +# GUI stop, not only once "systemctl stop" fully returns) makes fbcon +# redraw/unblank as part of the switch, so a single blank-after-the-fact +# call leaves a gap showing whatever was last on the console (old +# kernel/systemd text) for however long the rest of the stop sequence +# takes. Keep forcing it dark for the whole teardown instead of waiting. +if [[ "$DRY_RUN" != "1" ]]; then + ( + while :; do + blank_console + sleep 0.1 + done + ) & + BLANK_POLLER_PID=$! +fi +run systemctl stop lightdm.service +if [[ -n "${BLANK_POLLER_PID:-}" ]]; then + kill "$BLANK_POLLER_PID" 2>/dev/null || true + wait "$BLANK_POLLER_PID" 2>/dev/null || true + unset BLANK_POLLER_PID +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" diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/night-lockdown-unlock.sh.in b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/night-lockdown-unlock.sh.in new file mode 100755 index 00000000..40f904cc --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/night-lockdown-unlock.sh.in @@ -0,0 +1,212 @@ +#!/bin/bash +# Managed by setup_night_lockdown.sh — do not edit by hand. +# +# Lift night lockdown: unmask the login surface, restart the GUI, unmute audio. +# Idempotent (safe to fire from the 5 staggered morning triggers and manual runs) +# and deliberately independent of the shutdown fortress, so it can always run. +set -euo pipefail + +readonly CONF_FILE="/etc/night-lockdown.conf" +readonly STATE_DIR="/var/lib/night-lockdown" +readonly STATE_FILE="$STATE_DIR/state" + +# shellcheck source=/dev/null +[[ -r "$CONF_FILE" ]] && source "$CONF_FILE" +ALSA_CARDS="${ALSA_CARDS:-}" +LOCK_USER="${LOCK_USER:-}" +LOCK_UID="${LOCK_UID:-}" +MIN_WAKE_AFTER_LOCKDOWN_HOURS="${MIN_WAKE_AFTER_LOCKDOWN_HOURS:-8}" +AUTORANDR_PROFILE="${AUTORANDR_PROFILE:-default}" +CONSOLE_TTY="${CONSOLE_TTY:-/dev/tty1}" +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' "$*"; } + +if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN NOTE: log-only — lightdm is never actually started and" + logg "DRY_RUN NOTE: autorandr is never actually invoked. This CANNOT exercise" + logg "DRY_RUN NOTE: the real DisplayPort reconnect or graphical-session wait." + logg "DRY_RUN NOTE: A clean DRY_RUN run is NOT proof those work — only a real" + 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() { + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would run: $*" + return 0 + fi + if ! "$@"; then + logg "WARN: command failed (continuing): $*" + fi +} + +# NOTE: this script used to also start morning-routine.service here, on a +# fixed 05:00/05:15/05:30/06:00/07:00 ladder. wake-alarm-trigger.timer now +# owns firing the alarm: it ticks every minute and fires at the time the +# phone synced, so the alarm follows the phone instead of a hardcoded hour. +# Keeping both meant a 07:00 alarm on a morning the phone said 08:00. + +mkdir -p "$STATE_DIR" + +# Idempotency — lifting when already unlocked is a safe no-op. +if [[ "$(cat "$STATE_FILE" 2>/dev/null || echo UNLOCKED)" == "UNLOCKED" ]]; then + logg "already UNLOCKED — nothing else to do" + exit 0 +fi + +logg "lifting night lockdown" + +# 0. Restore the console: unblank it and let kernel/systemd talk to it again. +# Must happen even if everything below fails, or the machine would stay +# permanently silent on the console after any unlock. +restore_console() { + local fb prev + for fb in /sys/class/graphics/fb*/blank; do + [[ -e "$fb" ]] || continue + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would write 0 (unblank) > $fb" + continue + fi + echo 0 >"$fb" 2>/dev/null || true + done + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would restore printk + systemd status messages" + return 0 + fi + [[ -w "$CONSOLE_TTY" ]] && setterm --term linux --blank poke --powersave off \ + >"$CONSOLE_TTY" 2>/dev/null + if [[ -r "$STATE_DIR/printk.prev" ]]; then + prev="$(cat "$STATE_DIR/printk.prev")" + [[ -n "$prev" ]] && sysctl -q -w "kernel.printk=$prev" 2>/dev/null + fi + kill -s RTMIN+20 1 2>/dev/null || true # systemd: status messages back on +} +restore_console + +# 1. Unmask the login surface (undo the persistent /dev/null mask). Only +# 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 + +# 2a. Reconnect/reconfigure displays. A DisplayPort link does not always +# re-enumerate cleanly on its own after the console teardown, leaving a +# monitor disconnected until someone runs xrandr by hand. Retry autorandr +# for a bit while the session comes up instead. Requires a saved profile +# ('autorandr --save default' run once while all monitors are connected). +if [[ -n "$LOCK_USER" && -n "$LOCK_UID" ]] && command -v autorandr >/dev/null 2>&1; then + if [[ "$DRY_RUN" == "1" ]]; then + logg "DRY_RUN: would run autorandr --change as $LOCK_USER" + else + autorandr_ok=false + for ((i = 0; i < 15; i++)); do + if sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" DISPLAY=:0 \ + autorandr --change --default "$AUTORANDR_PROFILE" >/dev/null 2>&1; then + autorandr_ok=true + break + fi + sleep 1 + done + if [[ "$autorandr_ok" == true ]]; then + logg "autorandr: displays restored (profile '$AUTORANDR_PROFILE')" + else + logg "WARN: autorandr did not settle after retries — displays may need manual xrandr" + fi + fi +fi + +# 3. Restore audio (unmute the cards muted at lockdown). +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 + run env HOME="$RGB_HOME" openrgb --mode "$RGB_OFF_MODE" --color "$RGB_OFF_COLOR" +fi + +# 5. Record state BEFORE the (possibly long) alarm wait, so re-fired unlock +# triggers dedupe on the state token instead of double-starting things. +if [[ "$DRY_RUN" != "1" ]]; then + echo UNLOCKED >"$STATE_FILE" +fi + +logg "night lockdown lifted — GUI restored, login surface unmasked" 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 025520a1..288ad22a 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 @@ -32,6 +32,21 @@ set -euo pipefail +# shellcheck source=lib/nl_commands.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_commands.sh" + +# shellcheck source=lib/nl_config.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_config.sh" + +# shellcheck source=lib/nl_units.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_units.sh" + +# shellcheck source=lib/nl_unlock.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_unlock.sh" + +# shellcheck source=lib/nl_enter.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_enter.sh" + SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" # shellcheck source=../../lib/common.sh source "$SCRIPT_DIR/../../lib/common.sh" @@ -55,715 +70,18 @@ readonly MANAGED_BANNER="# Managed by setup_night_lockdown.sh — do not edit by # Hardware / environment detection (run at install time, written into the conf) # ============================================================================= -# Detect ALSA card indices that expose a 'Master' control (space-separated). -detect_alsa_cards() { - local card cards="" - for card in 0 1 2 3 4 5 6; do - if amixer -c "$card" scontrols 2>/dev/null | grep -q "'Master'"; then - cards+="${card} " - fi - done - # Trim trailing space. - echo "${cards% }" -} - -# Write /etc/night-lockdown.conf with values detected for THIS machine. -write_config() { - local user uid alsa_cards - user="$ACTUAL_USER" - uid="$(id -u "$user")" - alsa_cards="$(detect_alsa_cards)" - - log_info "Writing config to $CONF_FILE (user=$user uid=$uid alsa_cards='$alsa_cards')" - cat >"$CONF_FILE" < all 4 devices report ACTIVE: Static. -RGB_OFF_MODE="static" -RGB_OFF_COLOR="000000" - -# HOME that openrgb runs with: it keeps its config under its own HOME, and -# systemd (HOME=/root) vs sudo (HOME=/home/USER) would otherwise disagree about -# where that config lives, so pin it for both directions. -RGB_HOME="/root" - -# User systemd units (graphical-session-bound) stopped at lockdown, space-sep. -MONITORED_USER_UNITS="control-from-mobile.service" - -# GUI tray processes (not systemd units) killed at lockdown, space-separated. -MONITORED_PROCS="aw-qt" - -# Text console to blank at lockdown. Stopping lightdm hands the VT back to fbcon, -# which unblanks and prints kernel/systemd log spam instead of showing darkness. -CONSOLE_TTY="/dev/tty1" - -# How hard to blank the console (/sys/class/graphics/fbN/blank value): -# 1 = FB_BLANK_NORMAL — screen black, monitor keeps its signal. SAFE. -# 4 = FB_BLANK_POWERDOWN — DPMS off, monitor drops to standby. -# Do NOT use 4 here: DisplayPort drops its link when powered down, and the GPU -# then sees the connector as disconnected and will not re-train it on wake — -# DP-0 vanished from xrandr and needed the cable physically re-plugged. HDMI -# tolerates it, DP does not. 1 still gives a fully black screen, which is the -# whole point, without touching the link. -CONSOLE_BLANK_MODE="1" - -# Never start the morning alarm earlier than lockdown-entry + this many hours, -# even though the unlock timer itself still fires on its normal staggered -# schedule (05:00/05:15/05:30/06:00/07:00). A late lockdown entry (e.g. 23:00 -# instead of the usual 21:00) must not still wake you 6h later. -MIN_WAKE_AFTER_LOCKDOWN_HOURS="8" - -# autorandr profile to restore after the GUI comes back up. Create it once with -# 'autorandr --save default' while all monitors are connected and arranged the -# 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" - - # This heredoc is intentionally UNQUOTED so the detected values above - # interpolate — which also means a stray backtick or $ anywhere in it - # (even inside a comment) gets executed/expanded and its output injected - # into the file. That has bitten twice, and a corrupted config makes the - # lock script fail to source it and silently skip steps. Fail loudly here - # instead of shipping a broken config. - if ! bash -n "$CONF_FILE" 2>/dev/null; then - log_error "Generated $CONF_FILE is not valid shell — refusing to continue." - log_error "Check write_config() for backticks or \$ in the heredoc." - bash -n "$CONF_FILE" || true - exit 1 - fi -} - # ============================================================================= # The lock action — installed to $ENTER_SCRIPT # ============================================================================= -install_enter_script() { - log_info "Installing lock action to $ENTER_SCRIPT" - cat >"$ENTER_SCRIPT" <<'ENTER_EOF' -#!/bin/bash -# Managed by setup_night_lockdown.sh — do not edit by hand. -# -# Enter night lockdown: tear down the user GUI and mask the TTY login surface so -# the machine is unusable from the keyboard, while every background server keeps -# running. Reversed by night-lockdown-unlock.sh (05:00 timer or SSH recovery). -# All steps are best-effort and logged; DRY_RUN=1 logs intended actions only. -set -euo pipefail - -readonly CONF_FILE="/etc/night-lockdown.conf" -readonly STATE_DIR="/var/lib/night-lockdown" -readonly STATE_FILE="$STATE_DIR/state" - -# shellcheck source=/dev/null -[[ -r "$CONF_FILE" ]] && source "$CONF_FILE" -LOCK_USER="${LOCK_USER:-}" -LOCK_UID="${LOCK_UID:-}" -ALSA_CARDS="${ALSA_CARDS:-}" -RGB_ENABLE="${RGB_ENABLE:-1}" -MONITORED_USER_UNITS="${MONITORED_USER_UNITS:-}" -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' "$*"; } - -if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN NOTE: log-only — lightdm is never actually stopped and the" - logg "DRY_RUN NOTE: console framebuffer is never actually touched. This CANNOT" - 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. -run() { - if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN: would run: $*" - return 0 - fi - if ! "$@"; then - logg "WARN: command failed (continuing): $*" - fi -} - -# Run a command as the desktop user inside their session bus. -run_as_user() { - [[ -n "$LOCK_USER" && -n "$LOCK_UID" && -d "/run/user/$LOCK_UID" ]] || return 0 - run sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" "$@" -} - -# Silence everything that prints to the text console. Once lightdm stops, the VT -# reverts to fbcon and any kernel/systemd message lights the screen back up. -silence_console() { - # systemd status messages ("Starting/Stopped ...") -> off. RTMIN+20 re-enables. - run kill -s RTMIN+21 1 - # Kernel messages -> console. Save the current printk so unlock can restore it. - if [[ "$DRY_RUN" != "1" ]]; then - cat /proc/sys/kernel/printk >"$STATE_DIR/printk.prev" 2>/dev/null || true - fi - run sysctl -q -w "kernel.printk=1 4 1 4" -} - -# Force the console dark — black screen, but WITHOUT DPMS power management. -# Powering the output down (FB_BLANK_POWERDOWN / setterm --powersave powerdown) -# makes DisplayPort drop its link; the GPU then reports the connector -# disconnected and never re-trains it, so the monitor is gone from xrandr until -# the cable is physically re-plugged. Blanking alone is enough to go black. -blank_console() { - local fb - for fb in /sys/class/graphics/fb*/blank; do - [[ -e "$fb" ]] || continue - if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN: would write $CONSOLE_BLANK_MODE (blank) > $fb" - continue - fi - echo "$CONSOLE_BLANK_MODE" >"$fb" 2>/dev/null || logg "WARN: could not blank $fb" - done - # setterm emits escape sequences on stdout; from a systemd service stdout is - # the journal, so aim it at the VT explicitly. --powersave off keeps DPMS out - # of it so the DP link survives. - if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN: would force console blank (no DPMS) on $CONSOLE_TTY" - elif [[ -w "$CONSOLE_TTY" ]]; then - setterm --term linux --blank force --powersave off \ - >"$CONSOLE_TTY" 2>/dev/null || logg "WARN: setterm blank failed" - fi -} - -mkdir -p "$STATE_DIR" - -# 1. Idempotency — entering lockdown when already locked is a safe no-op. -if [[ "$(cat "$STATE_FILE" 2>/dev/null || echo UNLOCKED)" == "LOCKED" ]]; then - logg "already LOCKED — nothing to do" - exit 0 -fi - -logg "entering night lockdown" - -# 2. RGB off (RAM + board + GPU), remembering the current lighting first. -# -# Do NOT gate this on `openrgb --list-devices`: that call is broken in the -# installed openrgb-git build — it returns in ~70ms, before async detection -# finishes, so it always reports zero devices. (That false "no controllers" -# reading is why this step silently no-op'd.) The fix was to install stable -# openrgb 1.0rc3 from extra, whose CLI detects properly (~1.9s) before acting. -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. -for card in $ALSA_CARDS; do - command -v amixer >/dev/null 2>&1 && run amixer -c "$card" -q set Master mute -done - -# 4. Stop GUI-bound user units + tray apps (they die with X anyway; be tidy). -for unit in $MONITORED_USER_UNITS; do - run_as_user systemctl --user stop "$unit" -done -for proc in $MONITORED_PROCS; do - # Only kill if actually running — pkill exits 1 on no-match, which would log - # a misleading "command failed" WARN every night for an app that isn't up. - if [[ -n "$LOCK_USER" ]] && pgrep -u "$LOCK_USER" -x "$proc" >/dev/null 2>&1; then - run pkill -u "$LOCK_USER" -x "$proc" - fi -done - -# 5. Persist the LOCKED intent BEFORE the session-killing teardown. Stopping -# lightdm tears down the graphical session that this very process runs in, so -# if we wrote state AFTER the teardown, a kill mid-teardown would leave state -# at UNLOCKED — and the unlock path (which no-ops when UNLOCKED) would then -# fail to restore, stranding the machine. Write intent first; any unlock -# (05:00 timer, dead-man, or manual) then correctly restores. -if [[ "$DRY_RUN" != "1" ]]; then - echo LOCKED >"$STATE_FILE" - lockdown_epoch="$(date +%s)" - echo "$lockdown_epoch" >"$STATE_DIR/locked_at_epoch" - - # 5a. Schedule a one-shot wake-floor trigger for exactly - # lockdown_epoch + MIN_WAKE_AFTER_LOCKDOWN_HOURS. The staggered - # 05:00/05:15/05:30/06:00/07:00 unlock triggers are a fixed list sized - # for the usual shutdown hours (~21:00-23:00), but the shutdown hour - # itself is configurable (/etc/shutdown-schedule.conf) and changes - # dynamically (see the sick-day feature). If lockdown ever starts late - # enough that the floor lands after every remaining fixed trigger, the - # alarm would otherwise silently never fire that morning. This - # transient timer fires at the exact floor moment for THIS lockdown - # regardless of what time it actually started, re-running the - # (idempotent, self-dedup'd) unlock script, which is where the alarm - # is actually attempted. - floor_epoch=$((lockdown_epoch + MIN_WAKE_AFTER_LOCKDOWN_HOURS * 3600)) - floor_cal="$(date -d "@$floor_epoch" '+%Y-%m-%d %H:%M:%S')" - run systemd-run --unit=night-lockdown-wake-floor --collect \ - --on-calendar="$floor_cal" /usr/local/bin/night-lockdown-unlock.sh -else - logg "DRY_RUN: would schedule a one-shot wake-floor trigger ~${MIN_WAKE_AFTER_LOCKDOWN_HOURS}h from now" -fi - -# 6a. Silence the console BEFORE stopping the GUI, so the teardown itself -# ("Stopping Light Display Manager...") does not print onto the VT. -silence_console - -# 6. Mask the login surface BEFORE stopping the GUI, so logind cannot spawn a -# getty into the gap. Mask ONLY getty@.service: autovt@.service is an alias -# symlink to it, so masking getty@ already makes autovt@ttyN resolve to -# masked ("Unit autovt@ttyN.service is masked" — logind cannot spawn a VT -# login). Passing autovt@ to mask instead FAILS ("File -# /etc/systemd/system/autovt@.service already exists and is a symlink"), -# which is what produced the nightly WARN. Masks are /dev/null symlinks that -# persist across reboot; the unlock path unmasks them. -run systemctl mask getty@.service -run systemctl stop 'getty@*.service' - -# 7. Stop the GUI while continuously re-blanking the console in the -# background. Activating a VT (which happens somewhere mid-way through the -# GUI stop, not only once "systemctl stop" fully returns) makes fbcon -# redraw/unblank as part of the switch, so a single blank-after-the-fact -# call leaves a gap showing whatever was last on the console (old -# kernel/systemd text) for however long the rest of the stop sequence -# takes. Keep forcing it dark for the whole teardown instead of waiting. -if [[ "$DRY_RUN" != "1" ]]; then - ( - while :; do - blank_console - sleep 0.1 - done - ) & - BLANK_POLLER_PID=$! -fi -run systemctl stop lightdm.service -if [[ -n "${BLANK_POLLER_PID:-}" ]]; then - kill "$BLANK_POLLER_PID" 2>/dev/null || true - wait "$BLANK_POLLER_PID" 2>/dev/null || true - unset BLANK_POLLER_PID -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" -} - # ============================================================================= # The reversal — installed to $UNLOCK_SCRIPT # ============================================================================= -install_unlock_script() { - log_info "Installing reversal to $UNLOCK_SCRIPT" - cat >"$UNLOCK_SCRIPT" <<'UNLOCK_EOF' -#!/bin/bash -# Managed by setup_night_lockdown.sh — do not edit by hand. -# -# Lift night lockdown: unmask the login surface, restart the GUI, unmute audio. -# Idempotent (safe to fire from the 5 staggered morning triggers and manual runs) -# and deliberately independent of the shutdown fortress, so it can always run. -set -euo pipefail - -readonly CONF_FILE="/etc/night-lockdown.conf" -readonly STATE_DIR="/var/lib/night-lockdown" -readonly STATE_FILE="$STATE_DIR/state" - -# shellcheck source=/dev/null -[[ -r "$CONF_FILE" ]] && source "$CONF_FILE" -ALSA_CARDS="${ALSA_CARDS:-}" -LOCK_USER="${LOCK_USER:-}" -LOCK_UID="${LOCK_UID:-}" -MIN_WAKE_AFTER_LOCKDOWN_HOURS="${MIN_WAKE_AFTER_LOCKDOWN_HOURS:-8}" -AUTORANDR_PROFILE="${AUTORANDR_PROFILE:-default}" -CONSOLE_TTY="${CONSOLE_TTY:-/dev/tty1}" -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' "$*"; } - -if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN NOTE: log-only — lightdm is never actually started and" - logg "DRY_RUN NOTE: autorandr is never actually invoked. This CANNOT exercise" - logg "DRY_RUN NOTE: the real DisplayPort reconnect or graphical-session wait." - logg "DRY_RUN NOTE: A clean DRY_RUN run is NOT proof those work — only a real" - 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() { - if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN: would run: $*" - return 0 - fi - if ! "$@"; then - logg "WARN: command failed (continuing): $*" - fi -} - -# NOTE: this script used to also start morning-routine.service here, on a -# fixed 05:00/05:15/05:30/06:00/07:00 ladder. wake-alarm-trigger.timer now -# owns firing the alarm: it ticks every minute and fires at the time the -# phone synced, so the alarm follows the phone instead of a hardcoded hour. -# Keeping both meant a 07:00 alarm on a morning the phone said 08:00. - -mkdir -p "$STATE_DIR" - -# Idempotency — lifting when already unlocked is a safe no-op. -if [[ "$(cat "$STATE_FILE" 2>/dev/null || echo UNLOCKED)" == "UNLOCKED" ]]; then - logg "already UNLOCKED — nothing else to do" - exit 0 -fi - -logg "lifting night lockdown" - -# 0. Restore the console: unblank it and let kernel/systemd talk to it again. -# Must happen even if everything below fails, or the machine would stay -# permanently silent on the console after any unlock. -restore_console() { - local fb prev - for fb in /sys/class/graphics/fb*/blank; do - [[ -e "$fb" ]] || continue - if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN: would write 0 (unblank) > $fb" - continue - fi - echo 0 >"$fb" 2>/dev/null || true - done - if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN: would restore printk + systemd status messages" - return 0 - fi - [[ -w "$CONSOLE_TTY" ]] && setterm --term linux --blank poke --powersave off \ - >"$CONSOLE_TTY" 2>/dev/null - if [[ -r "$STATE_DIR/printk.prev" ]]; then - prev="$(cat "$STATE_DIR/printk.prev")" - [[ -n "$prev" ]] && sysctl -q -w "kernel.printk=$prev" 2>/dev/null - fi - kill -s RTMIN+20 1 2>/dev/null || true # systemd: status messages back on -} -restore_console - -# 1. Unmask the login surface (undo the persistent /dev/null mask). Only -# 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 - -# 2a. Reconnect/reconfigure displays. A DisplayPort link does not always -# re-enumerate cleanly on its own after the console teardown, leaving a -# monitor disconnected until someone runs xrandr by hand. Retry autorandr -# for a bit while the session comes up instead. Requires a saved profile -# ('autorandr --save default' run once while all monitors are connected). -if [[ -n "$LOCK_USER" && -n "$LOCK_UID" ]] && command -v autorandr >/dev/null 2>&1; then - if [[ "$DRY_RUN" == "1" ]]; then - logg "DRY_RUN: would run autorandr --change as $LOCK_USER" - else - autorandr_ok=false - for ((i = 0; i < 15; i++)); do - if sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" DISPLAY=:0 \ - autorandr --change --default "$AUTORANDR_PROFILE" >/dev/null 2>&1; then - autorandr_ok=true - break - fi - sleep 1 - done - if [[ "$autorandr_ok" == true ]]; then - logg "autorandr: displays restored (profile '$AUTORANDR_PROFILE')" - else - logg "WARN: autorandr did not settle after retries — displays may need manual xrandr" - fi - fi -fi - -# 3. Restore audio (unmute the cards muted at lockdown). -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 - run env HOME="$RGB_HOME" openrgb --mode "$RGB_OFF_MODE" --color "$RGB_OFF_COLOR" -fi - -# 5. Record state BEFORE the (possibly long) alarm wait, so re-fired unlock -# triggers dedupe on the state token instead of double-starting things. -if [[ "$DRY_RUN" != "1" ]]; then - echo UNLOCKED >"$STATE_FILE" -fi - -logg "night lockdown lifted — GUI restored, login surface unmasked" -UNLOCK_EOF - chmod 0755 "$UNLOCK_SCRIPT" -} - # ============================================================================= # Morning-unlock systemd timer family (outside the shutdown fortress) # ============================================================================= -install_unlock_units() { - log_info "Installing morning-unlock timer family" - cat >"$UNLOCK_SERVICE" <"$UNLOCK_TIMER" <"$RGB_OFF_SERVICE" <"$I2C_MODULES_FILE" </dev/null; then - log_ok "day-specific-shutdown-check.sh calls the lockdown action" - else - log_warn "day-specific-shutdown-check.sh does NOT call $ENTER_SCRIPT yet" - log_warn " → re-run setup_midnight_shutdown.sh to regenerate it (see header)" - fi - [[ "$ok" == true ]] -} - # ============================================================================= # Subcommands # ============================================================================= -cmd_setup() { - set_actual_user_vars - print_setup_header "Night Lockdown" - - ensure_dir "$STATE_DIR" - [[ -f "$STATE_FILE" ]] || echo UNLOCKED >"$STATE_FILE" - - write_config - install_enter_script - install_unlock_script - install_unlock_units - install_rgb_off_service - install_i2c_modules - - systemctl daemon-reload - enable_service night-lockdown-unlock.timer - enable_service rgb-off.service - - echo "" - if verify_install; then - log_ok "Night lockdown installed." - else - log_warn "Night lockdown installed with warnings (see above)." - fi - echo "" - log_info "Next: swap the shutdown action by re-running setup_midnight_shutdown.sh" - log_info " (it must call $ENTER_SCRIPT instead of powering off)." - log_info "Emergency unlock over SSH: sudo $0 unlock" -} - -cmd_status() { - local state="unknown" - [[ -r "$STATE_FILE" ]] && state="$(cat "$STATE_FILE")" - echo "Night lockdown state : $state" - echo "lightdm.service : $(systemctl is-active lightdm.service 2>/dev/null || true)" - echo "getty@.service mask : $(systemctl is-enabled getty@.service 2>/dev/null || true)" - echo "autovt@.service mask : $(systemctl is-enabled autovt@.service 2>/dev/null || true)" - echo "unlock timer : $(systemctl is-active night-lockdown-unlock.timer 2>/dev/null || true) / $(systemctl is-enabled night-lockdown-unlock.timer 2>/dev/null || true)" - echo "" - echo "Next unlock triggers:" - systemctl list-timers night-lockdown-unlock.timer --no-pager 2>/dev/null || true -} - -cmd_unlock() { - log_info "Emergency unlock: restoring GUI now" - DRY_RUN="" "$UNLOCK_SCRIPT" - echo "" - log_warn "The curfew is still armed: the next 30-min check tick will re-lock." - log_warn "To stay unlocked until morning, register an override (friction by design):" - if [[ -x "$OVERRIDE_MANAGER" ]]; then - local now_h until_str - now_h="$(date '+%Y-%m-%d %H:%M')" - # Next 05:00 boundary (tomorrow if already past 05:00 today). - if [[ "$(date +%H)" -lt 5 ]]; then - until_str="$(date '+%Y-%m-%d') 05:00" - else - until_str="$(date -d tomorrow '+%Y-%m-%d') 05:00" - fi - echo " sudo $OVERRIDE_MANAGER add '$now_h' '$until_str' 'emergency unlock'" - else - log_warn " (override manager not found at $OVERRIDE_MANAGER)" - fi -} - -usage() { - cat <