mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:27:02 +01:00
setup_night_lockdown: split into lib/ under the 250-line cap
918 -> 138 lines, across five libs plus two data-file payloads. The two generated scripts became data files rather than heredocs. ENTER_EOF alone was 279 lines, so any lib containing it would have been over the cap however the rest was arranged. Both heredocs were quoted, so their bodies were already literal text with no build-time interpolation, and copying them verbatim is exactly equivalent. The payloads are named *.sh.in on purpose. The pre-commit hook globs staged files by '\.(sh|bash|zsh)$', runs shfmt -w on them and re-stages the result -- and shfmt does reformat these bodies. That would have changed their bytes and destroyed the property this split is verified by. The .in extension keeps them out of every .sh-globbing hook without a suppression or an exclude regex. cat + chmod 0755 became a single `install -m 0755`, closing the window the original left between creating the file and making it executable. The load-bearing check: running install_enter_script and install_unlock_script against a scratch directory produces mode-0755 files whose sha256 sums are identical to the copies already deployed in /usr/local/bin on this machine. The split provably emits the exact bytes that are running today, and it touched no system file -- sha256sum -c on both deployed artifacts passes unchanged. help, status and an unknown command are byte-identical to their pre-split baselines. A line-multiset check leaves exactly six lines unaccounted for: the two cat/heredoc openers, the two terminators and the two chmod calls that install -m now performs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
72e1b6a8b1
commit
f6bfdff76a
@ -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."
|
||||
}
|
||||
@ -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...)."
|
||||
]
|
||||
}
|
||||
@ -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 <<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
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# Helpers sourced by the entry script.
|
||||
|
||||
# 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"
|
||||
|
||||
# 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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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" <<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
|
||||
}
|
||||
|
||||
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 ]]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
@ -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"
|
||||
@ -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" <<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"
|
||||
|
||||
# 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" <<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
|
||||
@ -782,119 +100,10 @@ EOF
|
||||
# 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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user