mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:07:04 +01:00
feat(shutdown): night lockdown instead of power-off, so servers stay up
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
The digital-wellbeing curfew powered the PC off at 21:00, which also took down every service this machine hosts (Gitea, the Caddy TLS edge, SyncYomi, the personal site, Open WebUI, Joplin, dufs, ollama, dnsmasq, wg-quick, sshd). Keep the curfew's lockout, drop the power-off: the machine now stays on with the GUI torn down, the TTY login surface masked, the console blanked and the RGB killed, and restores itself at 05:00. The fortress still owns the schedule (immutable config, ratchet, monitor, watchdog); only its terminal action is swapped for night-lockdown-enter.sh. The reversal deliberately lives outside the fortress so a bug in the lock path can never take the recovery path down with it. Verified in production overnight: lightdm down 21:00:04 -> 05:00:00 while all 7 containers stayed "Up 14 hours" and kept serving; ticks idempotent all night; 05:00 unlock fired. Notable fixes found along the way: - Wake alarm regression: it only ever fired on resume-from-hibernate, so an always-on machine silently killed it. The unlock now re-fires morning-routine.service on alarm days (the Mon/Fri/Sat/Sun gate lived in the removed rtcwake branch, not in the alarm). - Morning boundary -le -> -lt, so 05:00 no longer races the unlock. - Mask getty@ only; autovt@ is an alias of it and masking it errors. - Persist LOCKED before the session-killing teardown, else a kill mid-lock leaves state=UNLOCKED and the unlock no-ops, stranding the machine. - Blank the console without DPMS: FB_BLANK_POWERDOWN drops the DisplayPort link and the GPU never re-trains it (DP-0 needed a physical re-plug). - RGB: openrgb-git 0.9.2148's CLI returns before detection finishes, so every call silently no-op'd while exiting 0. Stable openrgb 1.0rc3 fixes it. Use static+black, not "off" (the ZOTAC has no Off mode; the ASRock ignores it). - Validate the generated config with bash -n: its heredoc must stay unquoted, so a stray backtick executes and corrupts the file. Thu-Sun 22->21 aligns the source with the canonical schedule screen_locker's sick-day feature ratcheted in on 2026-07-03; the ratchet only permits same/stricter, so the script was otherwise un-re-runnable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dpu8SqqBVm97tCuTGE3nCs
This commit is contained in:
parent
9c2abe2520
commit
3ebb56ccbb
71
docs/superpowers/evidence/night-lockdown-2026-07-17.json
Normal file
71
docs/superpowers/evidence/night-lockdown-2026-07-17.json
Normal file
@ -0,0 +1,71 @@
|
||||
{
|
||||
"intent": "Replace the digital-wellbeing midnight power-off with a 'night lockdown' that keeps the machine ON so the 24/7 self-hosted servers keep serving, while tearing down the user GUI, masking the TTY login surface, and blanking the console so the machine is unusable and the room is dark. A morning timer restores the desktop at 05:00 and, on alarm days, re-fires the wake alarm that the removed hibernate/resume cycle used to trigger.",
|
||||
"scope": [
|
||||
"NEW linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh (installer)",
|
||||
"EDIT linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh (terminal action swap, morning off-by-one, Thu-Sun constant 22->21)",
|
||||
"Installed: /usr/local/bin/night-lockdown-{enter,unlock}.sh; /etc/night-lockdown.conf; /var/lib/night-lockdown/; night-lockdown-unlock.{service,timer}; /etc/modules-load.d/night-lockdown-i2c.conf",
|
||||
"Non-goals: night fan quieting deferred (BIOS SmartFan retained); nftables untouched (investigated, not broken -- it is a Type=oneshot unit whose 'inactive (dead)' state is normal, and its ruleset is enforcing)"
|
||||
],
|
||||
"changes": [
|
||||
"setup_midnight_shutdown.sh: terminal action swapped from rtcwake/hibernate/poweroff to a call to night-lockdown-enter.sh (DRY_RUN passthrough), so the curfew no longer takes the servers down with it.",
|
||||
"setup_midnight_shutdown.sh: morning boundary -le -> -lt on both day branches, so 05:00 evaluates as OUTSIDE the window and cannot race the unlock timer.",
|
||||
"setup_midnight_shutdown.sh: SCHEDULE_THU_SUN_HOUR 22->21 to match the canonical schedule screen_locker's sick-day feature ratcheted in on 2026-07-03; the ratchet only permits same/stricter, so the script was otherwise un-re-runnable and the check script could not be regenerated. User authorized this alignment.",
|
||||
"Lock action: RGB-off (best-effort) -> ALSA mute -> stop GUI-bound user units/tray procs -> silence console -> mask getty@ -> stop lightdm -> blank console. Persists state=LOCKED BEFORE the session-killing teardown so a kill mid-teardown cannot leave state=UNLOCKED and defeat the unlock (which no-ops on UNLOCKED).",
|
||||
"Mask getty@.service ONLY: autovt@.service is an alias symlink to it, so masking getty@ makes autovt@ttyN resolve to masked; passing autovt@ to `systemctl mask` fails ('File /etc/systemd/system/autovt@.service already exists and is a symlink'), which produced a nightly WARN. Guarded the tray-proc pkill with pgrep so a not-running app stops logging a false WARN.",
|
||||
"Console darkening: stopping lightdm hands the VT back to fbcon, which unblanks and prints kernel/systemd log spam instead of darkness (consoleblank=0 means it never blanks on its own). Lock now sends RTMIN+21 to PID 1 (systemd status off), lowers kernel.printk to '1 4 1 4' (saved for restore), and blanks via fbN/blank + setterm. Unlock restores all of it first, so the console can never be left permanently muted.",
|
||||
"Console blank uses FB_BLANK_NORMAL(1), NOT FB_BLANK_POWERDOWN(4): DisplayPort drops its link on powerdown and the GPU never re-trains it (DP-0 vanished from xrandr and needed a physical cable re-plug); HDMI tolerates it. Exposed as CONSOLE_BLANK_MODE with the hazard documented.",
|
||||
"Morning unlock re-fires the wake alarm the always-on model displaced: on alarm days (date %u in ALARM_DAYS_DOW='1 5 6 7' = Mon/Fri/Sat/Sun) it waits <=60s for graphical-session.target then starts morning-routine.service. The day-gate is re-implemented here because wake_alarm's own gate fires every day; the Mon/Fri/Sat/Sun restriction previously lived only in the removed rtcwake branch.",
|
||||
"Recovery: setup_night_lockdown.sh unlock restores the GUI immediately over SSH and prints the shutdown-override-manager.sh command to hold unlocked till morning, so staying up keeps the deliberate confirm-phrase friction rather than becoming a friction-free nightly bypass.",
|
||||
"RGB now actually works (it previously self-reported 'no controllers' and silently no-op'd). Root cause: the installed openrgb-git 0.9.2148 build had a broken CLI -- every invocation returned in ~70ms, before async detection completed (~1700ms), so --list-devices printed nothing, `--device N` errored 'Empty device ID', and `--mode X` silently applied to an empty device list while still exiting 0. Fixed by replacing openrgb-git with stable openrgb 1.0rc3-1 from extra, whose CLI detects (~1.9s) before acting. All 4 controllers are detected on 1.0rc3, including the ZOTAC (the 'Unrecognized controller version' line comes from a second Zotac detector; another one registers it), so no capability was lost in the swap.",
|
||||
"Lights-off uses static+black, NOT the 'off' mode: the ZOTAC RTX 3090 exposes no Off mode at all, and the ASRock B650M Pro RS lists Off but silently ignores it (its active mode stayed Rainbow), while static+black moves it to Static. All 4 devices support Static, so 'openrgb --mode static --color 000000' is the one form that works everywhere -- verified by readback showing ACTIVE: Static on all four.",
|
||||
"RGB policy is simply OFF AT ALL TIMES (user preference), so there is no pre-lock lighting to remember: RGB_RESTORE_MODE=off keeps the morning unlock dark, and a new rgb-off.service (Type=oneshot, RemainAfterExit, WantedBy=multi-user.target) re-applies `openrgb --mode off` at every boot -- without it the board firmware re-lights RAM/board/GPU on power-on and they would only go dark again at the next 21:00 lock. This also sidesteps openrgb --save-profile being broken in this build (reports success while writing a profile with only {profile_name, profile_version} and no device state).",
|
||||
"RGB_HOME pins openrgb's HOME to /root: openrgb keeps config/profiles under $HOME, and systemd (HOME=/root) vs sudo (HOME=/home/kuhy) would otherwise disagree about where that state lives.",
|
||||
"write_config() now runs `bash -n` on the generated /etc/night-lockdown.conf and aborts if invalid. The config heredoc must stay UNQUOTED (it interpolates the detected user/uid/ALSA cards), which means any stray backtick or $ ANYWHERE in it -- including inside a comment -- is executed/expanded and its output injected into the file. That happened twice during development; the second time it ran openrgb mid-write and pasted its stderr into the config, producing invalid shell that made the lock script fail to source it and silently skip every configured step. The syntax check turns that class of bug into a loud install-time failure."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "PRODUCTION: overnight run 2026-07-16 21:00 -> 2026-07-17 05:00",
|
||||
"result": "pass",
|
||||
"evidence": "journalctl: lightdm 'Stopping' 21:00:04 -> 'Started' 05:00:00 (GUI down the full 8h). All 7 docker containers 'Up 14 hours' and docker/ollama/dnsmasq/wg-quick@wg0/sshd active since 17:15 -- servers ran straight THROUGH the lockdown, no restart; gitea/syncyomi/personal-website/HTTPS edge all 200. Ticks 21:30-04:30 all 'already LOCKED - nothing to do'. 05:00 check: 'Time 05:00 is outside shutdown window' (off-by-one fix). 05:00 unlock: 'alarm day (dow=5) - starting morning-routine.service' (alarm regression fix fired for real). 05:15/05:30/06:00/07:00 'already UNLOCKED'."
|
||||
},
|
||||
{
|
||||
"command": "sudo systemd-run --unit=nl-test --collect <live lock/unlock test>",
|
||||
"result": "pass",
|
||||
"evidence": "WARN-free run (autovt@ mask + pkill warnings gone). Lock 07:57:54 -> unlock 07:58:06 (~10s dark). 'not an alarm day (dow=5) - skipping morning alarm' with the gate suppressed, so the workout locker was not triggered; ALARM_DAYS_DOW restored to '1 5 6 7' by the trap. User confirmed screen was completely black and the GUI returned. REGRESSION FOUND: FB_BLANK_POWERDOWN killed DP-0 (needed cable re-plug); HDMI unaffected -- fixed by switching to FB_BLANK_NORMAL."
|
||||
},
|
||||
{
|
||||
"command": "sudo <scratchpad>/nl_console_test.sh (VT-switch test, does not stop lightdm)",
|
||||
"result": "pass",
|
||||
"evidence": "DP-0 'connected primary 3840x2160' BEFORE and AFTER the blank -- no link loss, no re-plug. HDMI-0 unaffected. All state restored: VT 7, fb0 blank=0, printk '4 4 1 4', ShowStatus=yes, getty@ enabled. User confirmed VT6 was actually black. GUI/Claude sessions survived (test never stops lightdm)."
|
||||
},
|
||||
{
|
||||
"command": "sudo env HOME=/root openrgb --mode off ; systemctl status rgb-off.service",
|
||||
"result": "pass",
|
||||
"evidence": "openrgb --mode off rc=0 in ~1073ms (vs the broken --list-devices at ~73ms); observed live: RAM/board lights go off, and come back with `--mode 'spectrum cycle'`, so control is real in both directions. OpenRGB server-mode log confirms 4 controllers registered: 2x 'ENE DRAM', 'ASRock B650M Pro RS', 'ZOTAC GAMING GeForce RTX 3090 Trinity'; client mode lists modes incl. 'Off'. rgb-off.service: enabled, 'active (exited)', ExecStart status=0/SUCCESS -- lights off immediately on install and on every boot thereafter."
|
||||
},
|
||||
{
|
||||
"command": "shellcheck + bash -n on installer and generated enter/unlock scripts",
|
||||
"result": "pass",
|
||||
"evidence": "All clean. shellcheck (SC1073) caught a real bug: backticks and $HOME inside the UNQUOTED config heredoc were being command-substituted/expanded at config-write time (the written comment had lost its backticked text and $HOME had become /home/kuhy). Reworded to avoid backticks and $ in that heredoc. Installer idempotent; verify_install reports all artifacts present, unlock timer enabled, and 'day-specific-shutdown-check.sh calls the lockdown action'."
|
||||
},
|
||||
{
|
||||
"command": "Component checks (reversible, live)",
|
||||
"result": "pass",
|
||||
"evidence": "amixer -c 3 Master mute->[off]/unmute->[on]; getty@ mask->masked/unmask->enabled and 'systemctl start autovt@tty4' -> 'Unit is masked' (proves VT login blocked by masking getty@ alone); RTMIN+21 -> ShowStatus=no, RTMIN+20 -> yes; kernel.printk 4->1->restored; check_active_override still runs before the lock call after regeneration."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Hard lockout's only recovery is SSH (WireGuard/LAN sshd); a bug in the lock path can lock the physical machine out until 05:00. Mitigated by state-before-teardown ordering and 5 staggered Persistent unlock triggers on units outside the shutdown fortress.",
|
||||
"getty masks are /dev/null symlinks persisting across reboot; recovery must unmask, never rely on a reboot. Booting inside the window stays locked by design.",
|
||||
"Monitors now stay awake showing black rather than dropping to standby (DP link safety); slightly more backlight glow than true-off.",
|
||||
"The alarm's only outputs are the G27Q HDMI speaker (ALARM_AUDIO_SINK) and the analog RGB speakers. Powering BOTH off by hand leaves the alarm literally no sink -- which is how the first night's alarm went silent. The darkness fix removes the reason to do that; a Tapo P110 plug is supported by wake_alarm but not configured.",
|
||||
"SCHEDULE_THU_SUN_HOUR 22->21 is now the permanent source default; if screen_locker's sick-day 21 was meant to be temporary this bakes it in (user authorized).",
|
||||
"The installed openrgb-git 0.9.2148 build has a broken CLI (--list-devices races; --save-profile writes empty profiles). Stable openrgb 1.0rc3-1 exists in extra and would likely fix both, but the swap was deliberately NOT made: its only benefit was true profile save/restore, which an always-off policy does not need, and it is unverified whether 1.0rc3 still supports the ZOTAC 30/40-series GPU controller.",
|
||||
"The speakers' RGB is NOT controllable: they expose no data device (lsusb shows only webcam/mouse/keyboard/BT/phone/ASRock LED controller), so their light is wired to their power. If that glow is what prompts switching them off by hand, only cutting their power would fix it -- and switching them off removes the analog sink, leaving the alarm dependent on the G27Q HDMI sink (so the monitors must stay powered)."
|
||||
],
|
||||
"rollback": [
|
||||
"sudo chattr -i setup_midnight_shutdown.sh; git checkout the file; sudo ./setup_midnight_shutdown.sh enable (answer y) to regenerate the poweroff/hibernate check script.",
|
||||
"sudo systemctl disable --now night-lockdown-unlock.timer; rm /usr/local/bin/night-lockdown-*.sh /etc/night-lockdown.conf /etc/modules-load.d/night-lockdown-i2c.conf; rm -rf /var/lib/night-lockdown; rm /etc/systemd/system/night-lockdown-unlock.{service,timer}; systemctl daemon-reload.",
|
||||
"Validate after rollback: at a window time, DRY_RUN /usr/local/bin/day-specific-shutdown-check.sh and confirm it logs the poweroff/hibernate path; confirm getty@ is not masked and lightdm is active."
|
||||
]
|
||||
}
|
||||
@ -14,7 +14,9 @@ source "$SCRIPT_DIR/../../lib/common.sh"
|
||||
# Schedule constants (single source of truth for this script)
|
||||
# These values are written to /etc/shutdown-schedule.conf during setup
|
||||
SCHEDULE_MON_WED_HOUR=21
|
||||
SCHEDULE_THU_SUN_HOUR=22
|
||||
# Thu-Sun aligned to 21:00 to match the canonical schedule that screen_locker's
|
||||
# sick-day feature ratcheted in (was 22); the ratchet only permits same/stricter.
|
||||
SCHEDULE_THU_SUN_HOUR=21
|
||||
SCHEDULE_MORNING_END_HOUR=5
|
||||
|
||||
# ============================================================================
|
||||
@ -915,7 +917,7 @@ if [[ $day_of_week -ge 1 ]] && [[ $day_of_week -le 3 ]]; then
|
||||
shutdown_start=$mon_wed_minutes
|
||||
logger -t day-specific-shutdown "Today is $day_name - checking ${MON_WED_HOUR}:00-0${MORNING_END_HOUR}:00 window"
|
||||
|
||||
if [[ $current_time_minutes -ge $shutdown_start ]] || [[ $current_time_minutes -le $morning_end_minutes ]]; then
|
||||
if [[ $current_time_minutes -ge $shutdown_start ]] || [[ $current_time_minutes -lt $morning_end_minutes ]]; then
|
||||
should_shutdown=true
|
||||
if [[ $current_time_minutes -ge $shutdown_start ]]; then
|
||||
logger -t day-specific-shutdown "Time $current_hour:$current_minute is within evening shutdown window (${MON_WED_HOUR}:00-23:59)"
|
||||
@ -930,7 +932,7 @@ else
|
||||
shutdown_start=$thu_sun_minutes
|
||||
logger -t day-specific-shutdown "Today is $day_name - checking ${THU_SUN_HOUR}:00-0${MORNING_END_HOUR}:00 window"
|
||||
|
||||
if [[ $current_time_minutes -ge $shutdown_start ]] || [[ $current_time_minutes -le $morning_end_minutes ]]; then
|
||||
if [[ $current_time_minutes -ge $shutdown_start ]] || [[ $current_time_minutes -lt $morning_end_minutes ]]; then
|
||||
should_shutdown=true
|
||||
if [[ $current_time_minutes -ge $shutdown_start ]]; then
|
||||
logger -t day-specific-shutdown "Time $current_hour:$current_minute is within evening shutdown window (${THU_SUN_HOUR}:00-23:59)"
|
||||
@ -946,37 +948,18 @@ if [[ $should_shutdown == true ]]; then
|
||||
printf '%(%Y-%m-%d %H:%M:%S)T: Executing shutdown - current time %s:%s is within shutdown window for %s\n' -1 "$current_hour" "$current_minute" "$day_name"
|
||||
logger -t day-specific-shutdown "Executing scheduled shutdown at $(printf '%(%Y-%m-%d %H:%M:%S)T' -1)"
|
||||
|
||||
# If tomorrow is a wake-alarm day (Mon=1, Fri=5, Sat=6, Sun=7), hibernate
|
||||
# with an RTC timer so the alarm fires 8 hours later. Hibernate is completely
|
||||
# silent and dark — ideal when the PC is in a bedroom. rtcwake -m disk saves
|
||||
# state to swap and powers off, then the RTC restores power at wake_epoch.
|
||||
#
|
||||
# NOTE the -i (--ignore-inhibitors): this is a digital-wellbeing *enforcement*
|
||||
# shutdown and must be unbypassable. Without -i, any process holding a block
|
||||
# inhibitor — a game, Steam, a video player, or our own controller idle-off
|
||||
# watcher — silently denies the hibernate ("Operation denied due to active
|
||||
# block inhibitor") and the PC stays up all night. -i overrides all locks.
|
||||
tomorrow_dow=$(date -d "tomorrow" +%u)
|
||||
case "$tomorrow_dow" in
|
||||
1|5|6|7)
|
||||
wake_epoch=$(( $(printf '%(%s)T' -1) + 8 * 3600 ))
|
||||
logger -t day-specific-shutdown "Tomorrow is alarm day (dow=$tomorrow_dow) — hibernating, RTC wake at epoch $wake_epoch"
|
||||
if [[ "${DRY_RUN:-}" == "1" ]]; then
|
||||
logger -t day-specific-shutdown "DRY_RUN: would run rtcwake -m no -t $wake_epoch then systemctl hibernate -i"
|
||||
else
|
||||
/usr/bin/sudo /usr/sbin/rtcwake -m no -t "$wake_epoch"
|
||||
/usr/bin/systemctl hibernate -i
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
logger -t day-specific-shutdown "Tomorrow is not an alarm day — powering off normally"
|
||||
if [[ "${DRY_RUN:-}" == "1" ]]; then
|
||||
logger -t day-specific-shutdown "DRY_RUN: would run systemctl poweroff -i"
|
||||
else
|
||||
/usr/bin/systemctl poweroff -i
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
# Night lockdown instead of power-off. This machine is a 24/7 home server
|
||||
# (Gitea, the Caddy TLS edge, SyncYomi, the personal website, Open WebUI,
|
||||
# Joplin, dufs, ollama, dnsmasq, wg-quick@wg0, nftables, sshd). Powering off
|
||||
# took every server down with it. The lockdown action tears down the user GUI
|
||||
# and masks the TTY login surface so the machine is unusable from the keyboard,
|
||||
# while all servers keep running. See setup_night_lockdown.sh. The morning
|
||||
# unlock (night-lockdown-unlock.timer) restores the desktop at 05:00, so the
|
||||
# old rtcwake/hibernate wake-for-alarm path is no longer needed — the machine
|
||||
# simply stays on all night. DRY_RUN passes through so
|
||||
# `DRY_RUN=1 day-specific-shutdown-check.sh` exercises the path without locking.
|
||||
logger -t day-specific-shutdown "Entering night lockdown (servers stay up) via /usr/local/bin/night-lockdown-enter.sh"
|
||||
DRY_RUN="${DRY_RUN:-}" /usr/local/bin/night-lockdown-enter.sh
|
||||
else
|
||||
printf '%(%Y-%m-%d %H:%M:%S)T: Skipping shutdown - not within shutdown window for %s (current: %s:%s)\n' -1 "$day_name" "$current_hour" "$current_minute"
|
||||
logger -t day-specific-shutdown "Skipped shutdown - not within shutdown window for $day_name (current: $current_hour:$current_minute)"
|
||||
|
||||
@ -0,0 +1,686 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# setup_night_lockdown.sh
|
||||
#
|
||||
# Installs the "night lockdown" action that REPLACES the midnight power-off.
|
||||
#
|
||||
# This machine is a 24/7 home server (Gitea, the Caddy TLS edge, SyncYomi, the
|
||||
# personal website, Open WebUI, Joplin, dufs, ollama, dnsmasq, wg-quick@wg0,
|
||||
# nftables, sshd). The old digital-wellbeing curfew powered the PC off at night,
|
||||
# which also took every server down. Night lockdown keeps the curfew's lockout
|
||||
# intent but leaves the machine ON: it tears down the user GUI and masks the
|
||||
# TTY login surface so the machine is unusable from the keyboard, while every
|
||||
# background server keeps running. At 05:00 a morning timer restores the desktop.
|
||||
#
|
||||
# The evening/morning SCHEDULE and its anti-tamper guards still live in
|
||||
# setup_midnight_shutdown.sh; that script's terminal action is swapped to call
|
||||
# night-lockdown-enter.sh instead of powering off. This installer owns only the
|
||||
# lock/unlock action and the morning-unlock timer, and is deliberately NOT made
|
||||
# immutable so the reversal path can always be iterated and can never be taken
|
||||
# down by a bug in the (guarded) lock path.
|
||||
#
|
||||
# HARD LOCKOUT: the only recovery path once locked is SSH (WireGuard / LAN).
|
||||
# `setup_night_lockdown.sh unlock` restores the GUI immediately over SSH.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./setup_night_lockdown.sh setup # install / re-install (idempotent)
|
||||
# ./setup_night_lockdown.sh status # show current state
|
||||
# sudo ./setup_night_lockdown.sh unlock # emergency: lift lockdown now (SSH)
|
||||
# ./setup_night_lockdown.sh help
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$SCRIPT_DIR/../../lib/common.sh"
|
||||
|
||||
# --- Installed artifact locations -------------------------------------------
|
||||
readonly ENTER_SCRIPT="/usr/local/bin/night-lockdown-enter.sh"
|
||||
readonly UNLOCK_SCRIPT="/usr/local/bin/night-lockdown-unlock.sh"
|
||||
readonly CONF_FILE="/etc/night-lockdown.conf"
|
||||
readonly STATE_DIR="/var/lib/night-lockdown"
|
||||
readonly STATE_FILE="$STATE_DIR/state"
|
||||
readonly I2C_MODULES_FILE="/etc/modules-load.d/night-lockdown-i2c.conf"
|
||||
readonly UNLOCK_SERVICE="/etc/systemd/system/night-lockdown-unlock.service"
|
||||
readonly UNLOCK_TIMER="/etc/systemd/system/night-lockdown-unlock.timer"
|
||||
readonly RGB_OFF_SERVICE="/etc/systemd/system/rgb-off.service"
|
||||
readonly OVERRIDE_MANAGER="/usr/local/bin/shutdown-override-manager.sh"
|
||||
# HOME openrgb runs with; must match RGB_HOME in the generated config.
|
||||
readonly RGB_HOME_DEFAULT="/root"
|
||||
readonly MANAGED_BANNER="# Managed by setup_night_lockdown.sh — do not edit by hand."
|
||||
|
||||
# =============================================================================
|
||||
# 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" <<EOF
|
||||
$MANAGED_BANNER
|
||||
# Desktop user whose GUI session is torn down at lockdown.
|
||||
LOCK_USER="$user"
|
||||
LOCK_UID="$uid"
|
||||
|
||||
# ALSA card indices with a 'Master' control, muted at lockdown (best-effort).
|
||||
ALSA_CARDS="$alsa_cards"
|
||||
|
||||
# Kill all RGB via OpenRGB. Detected here: 2x ENE DRAM (RGB RAM, i2c 0x71/0x73),
|
||||
# ZOTAC RTX 3090 (GPU), ASRock B650M Pro RS (USB Polychrome 26ce:01a2).
|
||||
RGB_ENABLE="1"
|
||||
|
||||
# How to turn the lighting off. Use static + black, NOT the "off" mode:
|
||||
# - the ZOTAC GPU has no "Off" mode at all (its modes are Static/Breath/...);
|
||||
# - the ASRock board lists "Off" but silently ignores it (mode stayed Rainbow),
|
||||
# while static+black moves it to Static and goes dark.
|
||||
# All four devices support Static, so this one form works everywhere. Verified:
|
||||
# 'openrgb --mode static --color 000000' -> 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"
|
||||
|
||||
# Morning wake alarm. The always-on lockdown removes the hibernate/resume event
|
||||
# that used to start the alarm, so the unlock re-fires it on alarm days. Days are
|
||||
# date(1) %u values: 1=Mon .. 7=Sun. Mon/Fri/Sat/Sun matches screen_locker's
|
||||
# ALARM_DAYS. The alarm's own gate fires every day, so the day-gate lives here.
|
||||
ALARM_DAYS_DOW="1 5 6 7"
|
||||
# User systemd unit that runs the morning routine (wake alarm + workout lock).
|
||||
MORNING_ROUTINE_UNIT="morning-routine.service"
|
||||
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:-}"
|
||||
CONSOLE_TTY="${CONSOLE_TTY:-/dev/tty1}"
|
||||
CONSOLE_BLANK_MODE="${CONSOLE_BLANK_MODE:-1}"
|
||||
DRY_RUN="${DRY_RUN:-}"
|
||||
|
||||
logg() { logger -t night-lockdown "$*"; printf 'night-lockdown: %s\n' "$*"; }
|
||||
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
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. This hands the VT back to fbcon, which unblanks it.
|
||||
run systemctl stop lightdm.service
|
||||
|
||||
# 8. Now that fbcon owns the VT again, force it dark (DPMS off -> standby).
|
||||
blank_console
|
||||
|
||||
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:-}"
|
||||
ALARM_DAYS_DOW="${ALARM_DAYS_DOW:-}"
|
||||
MORNING_ROUTINE_UNIT="${MORNING_ROUTINE_UNIT:-}"
|
||||
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}"
|
||||
DRY_RUN="${DRY_RUN:-}"
|
||||
|
||||
logg() { logger -t night-lockdown "$*"; printf 'night-lockdown: %s\n' "$*"; }
|
||||
|
||||
run() {
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
logg "DRY_RUN: would run: $*"
|
||||
return 0
|
||||
fi
|
||||
if ! "$@"; then
|
||||
logg "WARN: command failed (continuing): $*"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fire the morning wake alarm on alarm days. The always-on lockdown removed the
|
||||
# hibernate/resume event that used to start morning-routine.service, so we start
|
||||
# it here once the desktop session is back. Only runs when we actually unlocked
|
||||
# from a real lockdown (this function is reached only past the idempotency gate).
|
||||
maybe_start_morning_alarm() {
|
||||
[[ -n "$LOCK_USER" && -n "$LOCK_UID" && -n "$MORNING_ROUTINE_UNIT" ]] || return 0
|
||||
local dow today_is_alarm_day=false d
|
||||
dow="$(date +%u)" # 1=Mon .. 7=Sun
|
||||
for d in $ALARM_DAYS_DOW; do
|
||||
[[ "$d" == "$dow" ]] && today_is_alarm_day=true
|
||||
done
|
||||
if [[ "$today_is_alarm_day" != true ]]; then
|
||||
logg "not an alarm day (dow=$dow) — skipping morning alarm"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
logg "DRY_RUN: would wait for graphical session then start $MORNING_ROUTINE_UNIT"
|
||||
return 0
|
||||
fi
|
||||
# Bounded wait (≤60s) for the user graphical session to come up after lightdm.
|
||||
local i
|
||||
for ((i = 0; i < 60; i++)); do
|
||||
if sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" \
|
||||
systemctl --user is-active graphical-session.target >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
logg "alarm day (dow=$dow) — starting $MORNING_ROUTINE_UNIT"
|
||||
run sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" \
|
||||
systemctl --user start --no-block "$MORNING_ROUTINE_UNIT"
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
# 2. Start the GUI — lightdm autologins back into i3/dwm.
|
||||
run systemctl start lightdm.service
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 6. On alarm days, re-fire the morning wake alarm the always-on lockdown
|
||||
# displaced (it used to start on the hibernate/resume event we removed).
|
||||
maybe_start_morning_alarm
|
||||
|
||||
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" <<EOF
|
||||
$MANAGED_BANNER
|
||||
[Unit]
|
||||
Description=Lift night lockdown (restore GUI, unmute, unmask login surface)
|
||||
DefaultDependencies=false
|
||||
After=multi-user.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$UNLOCK_SCRIPT
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
EOF
|
||||
|
||||
# Multiple staggered triggers + Persistent=true = dead-man robustness: a
|
||||
# single missed/failed 05:00 run cannot strand the machine, and each run is
|
||||
# idempotent so repeated firing is harmless.
|
||||
cat >"$UNLOCK_TIMER" <<EOF
|
||||
$MANAGED_BANNER
|
||||
[Unit]
|
||||
Description=Morning triggers to lift night lockdown
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 05:00:00
|
||||
OnCalendar=*-*-* 05:15:00
|
||||
OnCalendar=*-*-* 05:30:00
|
||||
OnCalendar=*-*-* 06:00:00
|
||||
OnCalendar=*-*-* 07:00:00
|
||||
Persistent=true
|
||||
AccuracySec=1s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
}
|
||||
|
||||
# Lights stay off at ALL times, not just during lockdown. The board firmware
|
||||
# re-lights RAM/board/GPU on every power-on, so without this the RGB would be
|
||||
# back after each reboot and only go dark again at the next 21:00 lock.
|
||||
install_rgb_off_service() {
|
||||
log_info "Installing boot-time RGB-off service"
|
||||
cat >"$RGB_OFF_SERVICE" <<EOF
|
||||
$MANAGED_BANNER
|
||||
[Unit]
|
||||
Description=Turn all RGB lighting off (lights are meant to be off at all times)
|
||||
After=multi-user.target
|
||||
# The i2c DRAM/GPU controllers need their buses; the ASRock controller is USB.
|
||||
Wants=systemd-modules-load.service
|
||||
After=systemd-modules-load.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
# HOME pinned for the same reason as in the lock/unlock scripts: openrgb keeps
|
||||
# its state under HOME and systemd would otherwise differ from an interactive
|
||||
# sudo run. Detection takes ~2s before it applies. static+black rather than
|
||||
# "off": the ZOTAC GPU has no Off mode and the ASRock board ignores it.
|
||||
ExecStart=/usr/bin/env HOME=${RGB_HOME_DEFAULT} /usr/bin/openrgb --mode static --color 000000
|
||||
SuccessExitStatus=0 1
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
install_i2c_modules() {
|
||||
log_info "Installing i2c module-load for future RGB support"
|
||||
cat >"$I2C_MODULES_FILE" <<EOF
|
||||
$MANAGED_BANNER
|
||||
# SMBus/i2c so OpenRGB can eventually reach motherboard/RAM RGB controllers.
|
||||
i2c-dev
|
||||
i2c-piix4
|
||||
EOF
|
||||
chmod 0644 "$I2C_MODULES_FILE"
|
||||
# Load now too (harmless if already loaded); ignore failure on odd hardware.
|
||||
modprobe i2c-dev 2>/dev/null || true
|
||||
modprobe i2c-piix4 2>/dev/null || true
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Install verification
|
||||
# =============================================================================
|
||||
|
||||
verify_install() {
|
||||
log_info "Verifying installation"
|
||||
local ok=true
|
||||
local path
|
||||
for path in "$ENTER_SCRIPT" "$UNLOCK_SCRIPT" "$CONF_FILE" \
|
||||
"$UNLOCK_SERVICE" "$UNLOCK_TIMER" "$RGB_OFF_SERVICE" "$I2C_MODULES_FILE"; do
|
||||
if [[ -e "$path" ]]; then
|
||||
log_ok "present: $path"
|
||||
else
|
||||
log_error "missing: $path"
|
||||
ok=false
|
||||
fi
|
||||
done
|
||||
if is_service_enabled night-lockdown-unlock.timer; then
|
||||
log_ok "night-lockdown-unlock.timer is enabled"
|
||||
else
|
||||
log_error "night-lockdown-unlock.timer is NOT enabled"
|
||||
ok=false
|
||||
fi
|
||||
# The action swap in the fortress check script is what actually invokes us.
|
||||
if grep -q "$ENTER_SCRIPT" /usr/local/bin/day-specific-shutdown-check.sh 2>/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 <<EOF
|
||||
Night Lockdown — replace the midnight power-off with a GUI lockout that keeps
|
||||
background servers running.
|
||||
|
||||
Usage:
|
||||
sudo $0 setup Install/refresh the lock+unlock action and morning timer
|
||||
$0 status Show current lockdown state and timer status
|
||||
sudo $0 unlock Emergency: lift the lockdown right now (use over SSH)
|
||||
$0 help Show this help
|
||||
|
||||
After 'setup', re-run setup_midnight_shutdown.sh so the scheduled check calls
|
||||
$ENTER_SCRIPT instead of powering off.
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
local cmd="${1:-setup}"
|
||||
# Elevate BEFORE shifting so the subcommand survives the sudo re-exec.
|
||||
case "$cmd" in
|
||||
setup | unlock) require_root "$@" ;;
|
||||
esac
|
||||
shift || true
|
||||
case "$cmd" in
|
||||
setup) cmd_setup "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
unlock) cmd_unlock "$@" ;;
|
||||
help | -h | --help) usage ;;
|
||||
*)
|
||||
log_error "Unknown command: $cmd"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
Reference in New Issue
Block a user