From d72f03ea93a81400a1c2c58964663515a4a25a24 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 21 Aug 2026 16:27:54 +0200 Subject: [PATCH] =?UTF-8?q?setup=5Fmidnight=5Fshutdown:=20split=20into=20l?= =?UTF-8?q?ib/=20=E2=80=94=20the=20cap=20is=20now=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1734 -> 109 lines across eight libs plus one payload file. This was the last file over the 250-line cap; check_file_length.sh --all now reports "all checked files are within 250 lines". This file needed a different proof from the others. Its product is scripts generated into /usr/local/bin via heredocs, so "did the functions move verbatim" is not the question that matters — "does it still emit the same bytes" is. Every heredoc body was hashed before the split: 11 of 12 are byte-identical afterwards, and the twelfth (the 224-line config-unlock body) now lives in lib/payloads/shutdown-config-unlock.sh.in at hash 1a5c67cfd0244101, exactly matching what it replaced. No new bodies appeared. The "~39 top-level functions" this file is documented as having are not real. log(), now_epoch(), cmd_add() and friends sit at column 0 because they are inside the generated scripts. The brace-matching extractor keeps each heredoc with its owning generator, so they moved as part of it. That is also why verify_shell_split.sh reports a bare "-log": it cannot tell a heredoc-internal definition from a top-level one. log() is not lost — it is line 17 of the payload, which is where it always ran from. $_MS_PAYLOAD_DIR is defined in the same file as its use, directly above it, following the _NL_PAYLOAD_DIR lesson from earlier in this campaign. --help is byte-identical, shellcheck is clean with no suppression added. `setup` itself is not executed — it installs systemd units, makes the config immutable and arms a shutdown timer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01F3PzGc75gBcdxP3PkHXGcV --- ...up-midnight-shutdown-split-2026-08-21.json | 17 + ...up-midnight-shutdown-split-2026-08-21.json | 52 + .../digital_wellbeing/lib/ms_config_guard.sh | 42 + .../digital_wellbeing/lib/ms_guard.sh | 131 ++ .../digital_wellbeing/lib/ms_monitor.sh | 238 +++ .../digital_wellbeing/lib/ms_override_mgr.sh | 239 +++ .../digital_wellbeing/lib/ms_report.sh | 182 ++ .../digital_wellbeing/lib/ms_scripts.sh | 157 ++ .../digital_wellbeing/lib/ms_setup_flow.sh | 231 +++ .../digital_wellbeing/lib/ms_units.sh | 247 +++ .../lib/payloads/shutdown-config-unlock.sh.in | 224 +++ .../setup_midnight_shutdown.sh | 1663 +---------------- 12 files changed, 1779 insertions(+), 1644 deletions(-) create mode 100644 docs/superpowers/contracts/setup-midnight-shutdown-split-2026-08-21.json create mode 100644 docs/superpowers/evidence/setup-midnight-shutdown-split-2026-08-21.json create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_config_guard.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_guard.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_monitor.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_override_mgr.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_report.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_scripts.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_setup_flow.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_units.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/shutdown-config-unlock.sh.in diff --git a/docs/superpowers/contracts/setup-midnight-shutdown-split-2026-08-21.json b/docs/superpowers/contracts/setup-midnight-shutdown-split-2026-08-21.json new file mode 100644 index 00000000..11084cec --- /dev/null +++ b/docs/superpowers/contracts/setup-midnight-shutdown-split-2026-08-21.json @@ -0,0 +1,17 @@ +{ + "title": "Split setup_midnight_shutdown.sh under the 250-line cap", + "objective": "Bring the last over-cap file (1734 lines) under 250 by moving its 20 real top-level functions into eight libs and its largest quoted heredoc into a verbatim payload, without altering any of the scripts it generates into /usr/local/bin.", + "acceptance_criteria": [ + "check_file_length.sh --all reports zero files over the cap, with the payloads git-tracked.", + "Every heredoc body is byte-identical before and after, except the one deliberately moved to a payload file whose hash matches it exactly.", + "verify_shell_split.sh shows no unexplained body change; the -log entry is a known heredoc-internal false positive and must be justified, not ignored.", + "--help is byte-identical to the pre-split baseline.", + "shellcheck passes without -x, shfmt is a no-op, no suppression added." + ], + "out_of_scope": [ + "Running `setup`: it installs systemd units, chattr +i's the config and arms a shutdown timer.", + "Rewriting the deployed /usr/local/bin scripts — unnecessary, since the generated content is provably unchanged.", + "Phase 2 (wiring the cap into pre-commit), which starts only once the cap is at zero." + ], + "verifier": "Per-file heredoc body hashing before vs after (the decisive check); verify_shell_split.sh with the -log entry explained; line-multiset check including the payload; diff of --help; shellcheck + shfmt + check_file_length --all." +} diff --git a/docs/superpowers/evidence/setup-midnight-shutdown-split-2026-08-21.json b/docs/superpowers/evidence/setup-midnight-shutdown-split-2026-08-21.json new file mode 100644 index 00000000..039f54ea --- /dev/null +++ b/docs/superpowers/evidence/setup-midnight-shutdown-split-2026-08-21.json @@ -0,0 +1,52 @@ +{ + "intent": "Bring setup_midnight_shutdown.sh (1734 lines) under the 250-line cap — the last file over it — without changing a byte of the five scripts it generates into /usr/local/bin.", + "scope": [ + "setup_midnight_shutdown.sh: 1734 -> ~105 lines", + "New libs: ms_guard, ms_config_guard, ms_scripts, ms_override_mgr, ms_monitor, ms_units, ms_report, ms_setup_flow", + "New payload: lib/payloads/shutdown-config-unlock.sh.in (224 lines)", + "Non-goal: running `setup`. It installs systemd units, chattr +i's the config and arms a shutdown timer." + ], + "changes": [ + "All 20 real top-level functions moved into eight libs. The prompt's '~39 functions' count included definitions that only LOOK top-level: log(), now_epoch(), cmd_add() and friends appear at column 0 because they are inside heredocs that generate standalone scripts. The brace-matching extractor keeps each heredoc inside its owning function, so those moved as part of their generator rather than being treated as separate functions.", + "create_config_guard's 224-line quoted heredoc became a verbatim payload file, the same convention already used for lib/payloads/night-lockdown-*.sh.in. It was the last thing keeping a lib over the cap.", + "$_MS_PAYLOAD_DIR is defined in the same file as its use, immediately above it — the placement lesson from _NL_PAYLOAD_DIR, which was stripped from the entry script by an autoformat pass three times." + ], + "verification": [ + { + "command": "per-file heredoc body hashing, before vs after", + "result": "pass", + "evidence": "THE decisive check for a file whose product is generated scripts. All 12 heredoc bodies were hashed in the pre-split file. After the split, 11 are byte-identical and the only one absent is the 224-line config-unlock body — which hashes to 1a5c67cfd0244101, exactly matching the payload file that now holds it. No new bodies appeared. Re-run after shfmt with the same result.", + "note": "An earlier run reported a spurious 302-line body and a spurious 9-line one. Both were artifacts of my own measurement: concatenating the libs joined one terminator to the next opener, and a `<<'EOF'` inside a comment I had written was parsed as a real heredoc. Measuring per-file and rewording the comment resolved both; neither was a change to the code." + }, + { + "command": "bash meta/scripts/verify_shell_split.sh HEAD <8 libs>", + "result": "pass", + "evidence": "Two entries, both explained. +create_config_guard is the deliberate heredoc-to-payload change. -log is the trap this file is known for: log() was counted as top level because it appears at column 0 twice INSIDE generated scripts. It is not lost — it now sits at line 17 of the payload, which is where it always executed from." + }, + { + "command": "python3 line-multiset check against original (payload included)", + "result": "pass", + "evidence": "Two lines differ and both are the heredoc mechanics being replaced: `cat >\"$unlock_script\" <<'EOF'` and its `EOF` terminator, now a single `cat \"$_MS_PAYLOAD_DIR/...\"`. Every other code line is accounted for." + }, + { + "command": "diff of --help against the pre-split baseline", + "result": "pass", + "evidence": "Byte-identical, rc=0, re-checked after shfmt. `status` was not used as a baseline: it re-execs under sudo and fails on PATH when invoked by relative path." + }, + { + "command": "shellcheck (no -x), shfmt idempotence, canary, check_file_length --all", + "result": "pass", + "evidence": "shellcheck rc=0 with no suppression added; shfmt stable after one formatting pass; hyphenated-key canary clean. check_file_length reports 'all checked files are within 250 lines' — the campaign goal, with the payloads git-tracked so the gate genuinely sees them." + } + ], + "risks": [ + "`setup` is never executed under test: it writes systemd units, makes the config immutable and arms a real shutdown timer. Correctness rests on the heredoc-hash equality (which is what determines the generated scripts) plus the unchanged-function proof.", + "The deployed /usr/local/bin scripts are not rewritten by this commit and do not need to be — the split provably emits the same content.", + "verify_shell_split.sh cannot distinguish a heredoc-internal function from a top-level one. That is why the heredoc hashing exists; anyone re-verifying this file should treat the -log entry as expected rather than as a lost function." + ], + "rollback": [ + "git revert the commit; setup_midnight_shutdown.sh returns to one 1734-line file and lib/ms_*.sh plus the payload disappear.", + "Nothing to undo on the live system — no deployed file was written.", + "After rollback, re-run the heredoc hashing and confirm the combined fingerprint returns to dcf8be138f55dced." + ] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_config_guard.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_config_guard.sh new file mode 100755 index 00000000..e21e651a --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_config_guard.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Function to install guard-lib protection (path watcher + enforcement) +# and the bespoke ratchet-aware unlock script. + +# Defined in the same file as its use, immediately above it: a definition +# placed in the entry script was stripped by an autoformat pass three times +# during this campaign. +: "${_MS_PAYLOAD_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")/payloads" && pwd)}" + +create_config_guard() { + echo "" + echo "2. Installing Config Guard (guard-lib + unlock script)..." + echo "==========================================================" + + command -v guardctl >/dev/null 2>&1 || { + echo "Error: guardctl not found on PATH. Set up ~/guard-lib first (run its install.sh)." >&2 + exit 1 + } + + if guardctl file-guard status "$GUARD_NAME" >/dev/null 2>&1; then + echo "✓ guard-lib instance '$GUARD_NAME' already installed (content applied above)" + else + guardctl file-guard install "$GUARD_NAME" --target "$CONFIG_FILE" + echo "✓ Installed guard-lib file-guard '$GUARD_NAME' (canonical snapshot, chattr +i, path watcher, initial enforcement)" + fi + + # Obscure name for unlock script - not documented anywhere + local unlock_script="/usr/local/sbin/.sd-sched-mgmt" + + # Create unlock script with psychological delay + # The unlock script is a verbatim payload file, not an inline heredoc: + # the original heredoc was quoted, so its body was already literal, and + # inlining it put this lib over the 250-line cap. Same convention as + # lib/payloads/night-lockdown-*.sh.in. Equivalence is proven by hashing + # the emitted file, not by inspection. + cat "$_MS_PAYLOAD_DIR/shutdown-config-unlock.sh.in" >"$unlock_script" + + chmod +x "$unlock_script" + # Silently create unlock script - do not announce its existence +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_guard.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_guard.sh new file mode 100755 index 00000000..31b2cc39 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_guard.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Prints guard-lib's canonical path for our instance, or nothing if the +# instance isn't installed yet (first run on this machine). +canonical_config_path() { + if command -v guardctl >/dev/null 2>&1 && guardctl file-guard status "$GUARD_NAME" >/dev/null 2>&1; then + guardctl file-guard canonical-path "$GUARD_NAME" + fi +} + +validate_minimum_usage_window() { + local mon_wed_window thu_sun_window + mon_wed_window=$((SCHEDULE_MON_WED_HOUR - SCHEDULE_MORNING_END_HOUR)) + thu_sun_window=$((SCHEDULE_THU_SUN_HOUR - SCHEDULE_MORNING_END_HOUR)) + + local errors=() + + if [[ $mon_wed_window -le 0 ]]; then + errors+=("Mon-Wed: morning end (${SCHEDULE_MORNING_END_HOUR}:00) is at or after shutdown (${SCHEDULE_MON_WED_HOUR}:00) — 0 usable hours") + elif [[ $mon_wed_window -lt $MIN_USAGE_HOURS ]]; then + errors+=("Mon-Wed: only ${mon_wed_window}h of usable time (${SCHEDULE_MORNING_END_HOUR}:00–${SCHEDULE_MON_WED_HOUR}:00), need at least ${MIN_USAGE_HOURS}h") + fi + + if [[ $thu_sun_window -le 0 ]]; then + errors+=("Thu-Sun: morning end (${SCHEDULE_MORNING_END_HOUR}:00) is at or after shutdown (${SCHEDULE_THU_SUN_HOUR}:00) — 0 usable hours") + elif [[ $thu_sun_window -lt $MIN_USAGE_HOURS ]]; then + errors+=("Thu-Sun: only ${thu_sun_window}h of usable time (${SCHEDULE_MORNING_END_HOUR}:00–${SCHEDULE_THU_SUN_HOUR}:00), need at least ${MIN_USAGE_HOURS}h") + fi + + if [[ ${#errors[@]} -gt 0 ]]; then + echo "" + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ ❌ INVALID SCHEDULE CONFIGURATION ❌ ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo "" + echo "The schedule constants do not guarantee at least ${MIN_USAGE_HOURS} hours of" + echo "continuous PC availability. This would cause the PC to shut down" + echo "immediately or very shortly after it becomes usable." + echo "" + for err in "${errors[@]}"; do + echo " ✗ $err" + done + echo "" + echo "Fix: ensure (SHUTDOWN_HOUR - MORNING_END_HOUR) >= ${MIN_USAGE_HOURS} for both windows." + echo " Example: MORNING_END_HOUR=6, SHUTDOWN_HOUR=22 → 16 usable hours ✓" + echo "" + exit 1 + fi +} + +# Check if trying to make schedule more lenient (later shutdown / earlier morning end) +check_schedule_protection() { + local canonical_config + canonical_config="$(canonical_config_path)" + + # Skip check if no canonical config exists yet (first install) + if [[ -z $canonical_config ]] || [[ ! -f $canonical_config ]]; then + return 0 + fi + + # Load canonical values + local canonical_mon_wed canonical_thu_sun canonical_morning_end + # shellcheck source=/dev/null + source "$canonical_config" 2>/dev/null || return 0 + canonical_mon_wed="${MON_WED_HOUR:-}" + canonical_thu_sun="${THU_SUN_HOUR:-}" + canonical_morning_end="${MORNING_END_HOUR:-}" + + # If canonical values are empty, skip check + if [[ -z $canonical_mon_wed ]]; then + return 0 + fi + if [[ -z $canonical_thu_sun ]]; then + return 0 + fi + if [[ -z $canonical_morning_end ]]; then + return 0 + fi + + local violations=() + + # Check if Mon-Wed hour is being made LATER (more lenient) + if [[ $SCHEDULE_MON_WED_HOUR -gt $canonical_mon_wed ]]; then + violations+=("Mon-Wed shutdown: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (later)") + fi + + # Check if Thu-Sun hour is being made LATER (more lenient) + if [[ $SCHEDULE_THU_SUN_HOUR -gt $canonical_thu_sun ]]; then + violations+=("Thu-Sun shutdown: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (later)") + fi + + # Check if morning end is being made EARLIER (more lenient - shorter shutdown window) + if [[ $SCHEDULE_MORNING_END_HOUR -lt $canonical_morning_end ]]; then + violations+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (earlier)") + fi + + if [[ ${#violations[@]} -gt 0 ]]; then + echo "" + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ ❌ OPERATION NOT PERMITTED ❌ ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo "" + echo "The requested schedule modification has been denied." + echo "" + exit 1 + fi + + # Making schedule STRICTER is always allowed + local stricter=() + if [[ $SCHEDULE_MON_WED_HOUR -lt $canonical_mon_wed ]]; then + stricter+=("Mon-Wed: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (earlier)") + fi + if [[ $SCHEDULE_THU_SUN_HOUR -lt $canonical_thu_sun ]]; then + stricter+=("Thu-Sun: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (earlier)") + fi + if [[ $SCHEDULE_MORNING_END_HOUR -gt $canonical_morning_end ]]; then + stricter+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (later)") + fi + + if [[ ${#stricter[@]} -gt 0 ]]; then + echo "" + echo "ℹ️ Schedule is being made STRICTER (allowed without unlock):" + for s in "${stricter[@]}"; do + echo " • $s" + done + echo "" + fi + + return 0 +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_monitor.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_monitor.sh new file mode 100755 index 00000000..a7b67b3b --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_monitor.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Function to install the monitor service +install_monitor_service() { + echo "" + echo "7. Installing Shutdown Timer Monitor Service..." + echo "==============================================" + + local monitor_script="/usr/local/bin/shutdown-timer-monitor.sh" + local monitor_service="/etc/systemd/system/shutdown-timer-monitor.service" + local monitor_timer="/etc/systemd/system/shutdown-timer-monitor-watchdog.timer" + local monitor_watchdog_service="/etc/systemd/system/shutdown-timer-monitor-watchdog.service" + + # Create the monitor script + cat >"$monitor_script" <<'EOF' +#!/bin/bash +# Shutdown timer monitor script +# Watches the day-specific-shutdown timer and re-enables it if disabled +# Also ensures the monitor service itself stays running + +set -euo pipefail + +LOG_FILE="/var/log/shutdown-timer-monitor.log" +TIMER_NAME="day-specific-shutdown.timer" +SERVICE_NAME="day-specific-shutdown.service" +MONITOR_SERVICE="shutdown-timer-monitor.service" +CHECK_INTERVAL=30 + +wait_seconds() { + local timeout_s=$1 + local start_ts end_ts elapsed_s remaining_s + + printf -v start_ts '%(%s)T' -1 + IFS= read -r -t "$timeout_s" || true + printf -v end_ts '%(%s)T' -1 + + elapsed_s=$((end_ts - start_ts)) + if (( elapsed_s < timeout_s )); then + remaining_s=$((timeout_s - elapsed_s)) + sleep "$remaining_s" + fi +} + +current_epoch() { + local out_var="${1:-}" + if [[ -n $out_var ]]; then + printf -v "$out_var" '%(%s)T' -1 + else + printf '%(%s)T\n' -1 + fi +} + +log_message() { + local _ts + local msg + printf -v _ts '%(%Y-%m-%d %H:%M:%S)T' -1 + printf -v msg '%s [shutdown-monitor] %s' "$_ts" "$1" + printf '%s\n' "$msg" >&2 + printf '%s\n' "$msg" >> "$LOG_FILE" 2>/dev/null || true +} + +timer_needs_restoration() { + if ! systemctl is-enabled "$TIMER_NAME" &>/dev/null; then + log_message "Timer $TIMER_NAME is not enabled" + return 0 + fi + if ! systemctl is-active "$TIMER_NAME" &>/dev/null; then + log_message "Timer $TIMER_NAME is not active" + return 0 + fi + if [[ ! -f "/etc/systemd/system/$TIMER_NAME" ]]; then + log_message "Timer unit file missing" + return 0 + fi + if [[ ! -f "/etc/systemd/system/$SERVICE_NAME" ]]; then + log_message "Service unit file missing" + return 0 + fi + if [[ ! -f "/usr/local/bin/day-specific-shutdown-check.sh" ]]; then + log_message "Check script missing" + return 0 + fi + return 1 +} + +restore_timer() { + log_message "Shutdown timer tampering detected - initiating restoration" + systemctl daemon-reload + if ! systemctl is-enabled "$TIMER_NAME" &>/dev/null; then + log_message "Re-enabling $TIMER_NAME" + systemctl enable "$TIMER_NAME" 2>/dev/null || true + fi + if ! systemctl is-active "$TIMER_NAME" &>/dev/null; then + log_message "Re-starting $TIMER_NAME" + systemctl start "$TIMER_NAME" 2>/dev/null || true + fi + if systemctl is-active "$TIMER_NAME" &>/dev/null; then + log_message "Timer restoration completed successfully" + else + log_message "WARNING: Timer restoration may have failed" + fi +} + +monitor_with_dbus() { + log_message "Starting shutdown timer monitoring with D-Bus events" + local last_check_ts=0 + + if command -v busctl &>/dev/null; then + busctl monitor --system org.freedesktop.systemd1 2>/dev/null | + while read -r line; do + if [[ $line == *"$TIMER_NAME"* || $line == *"$SERVICE_NAME"* ]]; then + local now_ts + current_epoch now_ts + if (( now_ts - last_check_ts < CHECK_INTERVAL )); then + continue + fi + last_check_ts=$now_ts + log_message "Systemd event detected for shutdown timer" + if timer_needs_restoration; then + restore_timer + fi + fi + done + else + log_message "busctl not available, falling back to polling" + monitor_with_polling + fi +} + +monitor_with_polling() { + log_message "Starting shutdown timer monitoring with polling (interval: ${CHECK_INTERVAL}s)" + + while true; do + if timer_needs_restoration; then + restore_timer + fi + wait_seconds "$CHECK_INTERVAL" + done +} + +start_monitoring() { + log_message "=== Shutdown Timer Monitor Started ===" + log_message "Monitoring timer: $TIMER_NAME" + log_message "Monitoring service: $SERVICE_NAME" + + if timer_needs_restoration; then + log_message "Initial check: Timer needs restoration" + restore_timer + else + log_message "Initial check: Timer is properly configured" + fi + + if command -v busctl &>/dev/null; then + monitor_with_dbus + else + log_message "busctl not available, falling back to polling" + monitor_with_polling + fi +} + +start_monitoring +EOF + + chmod +x "$monitor_script" + echo "✓ Created monitor script: $monitor_script" + + # Create the monitor service with RefuseManualStop to prevent manual stopping + cat >"$monitor_service" <<'EOF' +[Unit] +Description=Shutdown Timer Monitor and Auto-Restore Service +After=network-online.target day-specific-shutdown.timer +Wants=network-online.target +# Make it hard to stop - refuse manual stop/restart +RefuseManualStop=true +RefuseManualStart=false + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/shutdown-timer-monitor.sh +Restart=always +RestartSec=5 +# Restart even on success exit +RestartForceExitStatus=0 1 2 SIGTERM SIGKILL +StandardOutput=journal +StandardError=journal +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +NoNewPrivileges=false +PrivateTmp=true +MemoryMax=50M +CPUQuota=10% + +[Install] +WantedBy=multi-user.target +EOF + + echo "✓ Created monitor service: $monitor_service" + + # Create a watchdog timer that ensures the monitor stays running + cat >"$monitor_watchdog_service" <<'EOF' +[Unit] +Description=Watchdog for Shutdown Timer Monitor +After=multi-user.target + +[Service] +Type=oneshot +ExecStart=/bin/bash -c 'systemctl is-active shutdown-timer-monitor.service || systemctl start shutdown-timer-monitor.service' +ExecStart=/bin/bash -c 'systemctl is-active day-specific-shutdown.timer || systemctl start day-specific-shutdown.timer' +EOF + + echo "✓ Created watchdog service: $monitor_watchdog_service" + + cat >"$monitor_timer" <<'EOF' +[Unit] +Description=Watchdog Timer for Shutdown Timer Monitor +After=multi-user.target + +[Timer] +OnBootSec=60 +OnUnitActiveSec=300 +Persistent=true + +[Install] +WantedBy=timers.target +EOF + + echo "✓ Created watchdog timer: $monitor_timer" + + # Reload and enable everything + systemctl daemon-reload + systemctl enable shutdown-timer-monitor.service + systemctl enable shutdown-timer-monitor-watchdog.timer + systemctl start shutdown-timer-monitor.service + systemctl start shutdown-timer-monitor-watchdog.timer + echo "✓ Enabled and started shutdown-timer-monitor.service" + echo "✓ Enabled and started shutdown-timer-monitor-watchdog.timer" +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_override_mgr.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_override_mgr.sh new file mode 100755 index 00000000..a4111ad8 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_override_mgr.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Function to create the time-boxed override manager (event exceptions like +# watching a live match that runs past the normal shutdown window). Overrides +# are absolute-epoch-bound so they expire on their own; the friction (typed +# confirmation + delay) lives entirely in this CLI, matching the existing +# unlock script's philosophy for the permanent schedule ratchet. +create_override_manager_script() { + echo "" + echo "9. Creating Shutdown Override Manager..." + echo "========================================" + + local overrides_file="/etc/shutdown-schedule-overrides.conf" + local log_file="/var/log/shutdown-schedule-overrides.log" + local manager_script="/usr/local/bin/shutdown-override-manager.sh" + + touch "$overrides_file" + # World-readable (like shutdown-schedule.conf): the i3blocks countdown + # script runs as the regular user and needs to read active overrides to + # display them. Only root can write to it (via this manager script). + chmod 644 "$overrides_file" + touch "$log_file" + chmod 600 "$log_file" + + cat >"$manager_script" <<'EOF' +#!/bin/bash +# Shutdown Override Manager +# Registers time-boxed exceptions to the day-specific-shutdown schedule, e.g. +# to watch a live event that runs past the normal shutdown window. Entries are +# absolute-epoch-bound and expire on their own - there is nothing to re-lock. + +set -euo pipefail + +OVERRIDES_FILE="/etc/shutdown-schedule-overrides.conf" +LOG_FILE="/var/log/shutdown-schedule-overrides.log" +MAX_OVERRIDE_HOURS=12 +MAX_ACTIVE_OVERRIDES=3 +MIN_REASON_LEN=10 +CONFIRM_PHRASE="I am deliberately bypassing my shutdown protection" +DELAY_SECONDS=30 + +log() { + printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2 +} + +require_root() { + if [[ $EUID -ne 0 ]]; then + echo "This command requires sudo." >&2 + exit 1 + fi +} + +now_epoch() { + printf '%(%s)T' -1 +} + +prune_and_read() { + # Prints kept (non-expired) lines; overwrites OVERRIDES_FILE with them. + local now kept="" + now=$(now_epoch) + if [[ -f "$OVERRIDES_FILE" ]]; then + local start_epoch end_epoch created reason + while IFS='|' read -r start_epoch end_epoch created reason; do + [[ -n "$start_epoch" ]] || continue + [[ "$end_epoch" -lt "$now" ]] && continue + kept+="${start_epoch}|${end_epoch}|${created}|${reason}"$'\n' + done <"$OVERRIDES_FILE" + fi + printf '%s' "$kept" >"$OVERRIDES_FILE" + printf '%s' "$kept" +} + +cmd_list() { + local kept + kept="$(prune_and_read)" + if [[ -z "$kept" ]]; then + echo "No active or upcoming overrides." + return 0 + fi + echo "Active/upcoming overrides:" + local i=1 + local start_epoch end_epoch created reason + while IFS='|' read -r start_epoch end_epoch created reason; do + [[ -n "$start_epoch" ]] || continue + printf ' [%d] %s -> %s (reason: %s)\n' \ + "$i" \ + "$(date -d "@$start_epoch" '+%Y-%m-%d %H:%M')" \ + "$(date -d "@$end_epoch" '+%Y-%m-%d %H:%M')" \ + "$reason" + i=$((i + 1)) + done <<<"$kept" +} + +cmd_remove() { + require_root + local target="${1:-}" + [[ -n "$target" ]] || { echo "Usage: $0 remove " >&2; exit 1; } + + local kept + kept="$(prune_and_read)" + if [[ -z "$kept" ]]; then + echo "No overrides to remove." >&2 + exit 1 + fi + + local new_content="" i=1 start_epoch end_epoch created reason removed=false + while IFS='|' read -r start_epoch end_epoch created reason; do + [[ -n "$start_epoch" ]] || continue + if [[ "$i" == "$target" ]]; then + removed=true + else + new_content+="${start_epoch}|${end_epoch}|${created}|${reason}"$'\n' + fi + i=$((i + 1)) + done <<<"$kept" + + if [[ "$removed" != true ]]; then + echo "No override at index $target" >&2 + exit 1 + fi + + printf '%s' "$new_content" >"$OVERRIDES_FILE" + log "Removed override index $target (by $(logname 2>/dev/null || echo unknown))" + echo "✓ Removed override $target" +} + +cmd_add() { + require_root + local start_str="${1:-}" end_str="${2:-}" + local reason="${*:3}" + + if [[ -z "$start_str" ]] || [[ -z "$end_str" ]] || [[ -z "$reason" ]]; then + echo "Usage: $0 add '' '' " >&2 + echo "Example: $0 add '2026-07-10 21:30' '2026-07-11 00:30' 'World Cup match'" >&2 + exit 1 + fi + + if [[ "${#reason}" -lt "$MIN_REASON_LEN" ]]; then + echo "Reason must be at least $MIN_REASON_LEN characters." >&2 + exit 1 + fi + + local start_epoch end_epoch + start_epoch=$(date -d "$start_str" +%s) || { echo "Could not parse start datetime: $start_str" >&2; exit 1; } + end_epoch=$(date -d "$end_str" +%s) || { echo "Could not parse end datetime: $end_str" >&2; exit 1; } + + if [[ "$end_epoch" -le "$start_epoch" ]]; then + echo "End must be after start." >&2 + exit 1 + fi + + local duration_hours=$(( (end_epoch - start_epoch) / 3600 )) + if [[ "$duration_hours" -gt "$MAX_OVERRIDE_HOURS" ]]; then + echo "Override duration (${duration_hours}h) exceeds the max of ${MAX_OVERRIDE_HOURS}h." >&2 + exit 1 + fi + + local kept active_count + kept="$(prune_and_read)" + active_count=$(grep -c . <<<"$kept" 2>/dev/null || true) + active_count="${active_count:-0}" + if [[ "$active_count" -ge "$MAX_ACTIVE_OVERRIDES" ]]; then + echo "Already at the max of $MAX_ACTIVE_OVERRIDES active/upcoming overrides. Remove one first." >&2 + cmd_list + exit 1 + fi + + echo "" + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ SHUTDOWN SCHEDULE OVERRIDE REQUEST ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo "" + echo "This suspends the shutdown enforcement you built for yourself during:" + echo " $(date -d "@$start_epoch" '+%Y-%m-%d %H:%M') -> $(date -d "@$end_epoch" '+%Y-%m-%d %H:%M') (${duration_hours}h)" + echo " Reason: $reason" + echo "" + echo "Current overrides:" + cmd_list + echo "" + echo "To confirm this is a deliberate, one-off exception (not just wanting to" + echo "stay up), type the following phrase exactly:" + echo "" + echo " $CONFIRM_PHRASE" + echo "" + read -r -p "> " typed_phrase + if [[ "$typed_phrase" != "$CONFIRM_PHRASE" ]]; then + echo "Phrase did not match. Aborting." >&2 + log "ABORTED: confirmation phrase mismatch for window $start_str -> $end_str" + exit 1 + fi + + echo "" + echo "Waiting $DELAY_SECONDS seconds before committing (Ctrl+C to cancel)..." + local i + for ((i = DELAY_SECONDS; i > 0; i--)); do + printf "\r Waiting: %2d seconds remaining..." "$i" + sleep 1 + done + echo "" + + local created + created=$(now_epoch) + printf '%s|%s|%s|%s\n' "$start_epoch" "$end_epoch" "$created" "$reason" >>"$OVERRIDES_FILE" + + log "ADDED override by $(logname 2>/dev/null || echo unknown) from TTY $(tty 2>/dev/null || echo unknown): $start_str -> $end_str (reason: $reason)" + echo "✓ Override registered: $(date -d "@$start_epoch" '+%Y-%m-%d %H:%M') -> $(date -d "@$end_epoch" '+%Y-%m-%d %H:%M')" +} + +case "${1:-}" in + add) + shift + cmd_add "$@" + ;; + list) + cmd_list + ;; + remove) + shift + cmd_remove "$@" + ;; + *) + echo "Shutdown Override Manager" + echo "Usage: $0 {add|list|remove}" + echo "" + echo " add '' '' Register a time-boxed exception" + echo " list Show active/upcoming overrides" + echo " remove Cancel an override (no friction)" + echo "" + cmd_list + ;; +esac +EOF + + chmod +x "$manager_script" + echo "✓ Created override manager: $manager_script" + echo "✓ Overrides file: $overrides_file (root-only)" + echo "✓ Override log: $log_file" +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_report.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_report.sh new file mode 100755 index 00000000..047a8443 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_report.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Function to show usage +show_usage() { + echo "Day-Specific Auto-Shutdown Setup for Arch Linux" + echo "===============================================" + echo "Usage: $0 [enable|status]" + echo "" + echo "Commands:" + echo " enable - Set up automatic shutdown with day-specific windows (default)" + echo " status - Show current status" + echo "" + echo "Shutdown Schedule:" + echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00" + echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00" + echo "" + echo "NOTE: There is no 'disable' option. This is intentional." + echo " The shutdown timer is protected by a monitor service." + echo "" +} + +# Function to check and request sudo privileges +check_sudo() { + if [[ $EUID -ne 0 ]]; then + echo "This script requires sudo privileges to manage systemd services." + echo "Requesting sudo access..." + exec sudo "$0" "$@" + fi +} + +# Function to show current status +show_current_status() { + echo "Day-Specific Auto-Shutdown Status" + echo "=================================" + echo "Current Date: $(date)" + echo "User: $ACTUAL_USER" + echo "" + + local timer_exists=false + + # Check if files exist + if [[ -f "/etc/systemd/system/day-specific-shutdown.timer" ]]; then + timer_exists=true + echo "✓ Timer file exists" + else + echo "✗ Timer file missing" + fi + + if [[ -f "/etc/systemd/system/day-specific-shutdown.service" ]]; then + echo "✓ Service file exists" + else + echo "✗ Service file missing" + fi + + if [[ -f "/usr/local/bin/day-specific-shutdown-manager.sh" ]]; then + echo "✓ Management script exists" + else + echo "✗ Management script missing" + fi + + if [[ -f "/usr/local/bin/shutdown-timer-monitor.sh" ]]; then + echo "✓ Monitor script exists" + else + echo "✗ Monitor script missing" + fi + + echo "" + + # Check systemd status + if $timer_exists; then + if systemctl is-enabled day-specific-shutdown.timer &>/dev/null; then + echo "✓ Timer is enabled" + if systemctl is-active day-specific-shutdown.timer &>/dev/null; then + echo "✓ Timer is active" + echo "" + echo "Next scheduled shutdown check:" + systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | grep day-specific-shutdown || echo "Timer information not available" + else + echo "✗ Timer is not active" + fi + else + echo "✗ Timer is not enabled" + fi + else + echo "Status: NOT CONFIGURED" + fi + + echo "" + + # Check monitor service status + echo "Monitor Service Status:" + if systemctl is-enabled shutdown-timer-monitor.service &>/dev/null; then + echo "✓ Monitor is enabled" + if systemctl is-active shutdown-timer-monitor.service &>/dev/null; then + echo "✓ Monitor is active (will re-enable timer if disabled)" + else + echo "✗ Monitor is not active" + fi + else + echo "✗ Monitor is not enabled" + fi + + echo "" + + # Check config file protection status (via guard-lib) + echo "Config File Protection Status:" + local canonical_file + canonical_file="$(canonical_config_path)" + + if [[ -f $CONFIG_FILE ]]; then + echo "✓ Config file exists" + # Check immutable attribute + if lsattr "$CONFIG_FILE" 2>/dev/null | grep -q '^....i'; then + echo "✓ Config file is immutable (chattr +i)" + else + echo "✗ Config file is NOT immutable" + fi + else + echo "✗ Config file missing" + fi + + if [[ -n $canonical_file ]] && [[ -f $canonical_file ]]; then + echo "✓ Canonical copy exists ($canonical_file)" + else + echo "✗ Canonical copy missing (guard-lib instance not installed?)" + fi + + if systemctl is-enabled "guard-file@${GUARD_NAME}.path" &>/dev/null; then + echo "✓ Config path watcher is enabled" + if systemctl is-active "guard-file@${GUARD_NAME}.path" &>/dev/null; then + echo "✓ Config path watcher is active" + else + echo "✗ Config path watcher is not active" + fi + else + echo "✗ Config path watcher is not enabled" + fi + + echo "" + echo "Shutdown Schedule:" + echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00" + echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00" + echo "" + echo "NOTE: The shutdown timer is protected by a monitor service." + echo " If you try to disable the timer, it will be automatically re-enabled." + echo "" + echo "NOTE: The config file is protected by:" + echo " - Immutable attribute (chattr +i)" + echo " - Canonical copy that auto-restores on modification" + echo " - Path watcher service" + echo "" + + echo "Active Overrides:" + if command -v /usr/local/bin/shutdown-override-manager.sh >/dev/null 2>&1; then + /usr/local/bin/shutdown-override-manager.sh list | sed 's/^/ /' + else + echo " (override manager not installed)" + fi + echo "" +} + +# Display the shutdown schedule (used in multiple places) +print_shutdown_schedule() { + # Convert 24h to 12h format for display + local mon_wed_12h thu_sun_12h morning_12h + if [[ $SCHEDULE_MON_WED_HOUR -gt 12 ]]; then + mon_wed_12h="$((SCHEDULE_MON_WED_HOUR - 12)):00 PM" + else + mon_wed_12h="${SCHEDULE_MON_WED_HOUR}:00 AM" + fi + if [[ $SCHEDULE_THU_SUN_HOUR -gt 12 ]]; then + thu_sun_12h="$((SCHEDULE_THU_SUN_HOUR - 12)):00 PM" + else + thu_sun_12h="${SCHEDULE_THU_SUN_HOUR}:00 AM" + fi + morning_12h="${SCHEDULE_MORNING_END_HOUR}:00 AM" + + echo "Shutdown Schedule:" + echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00 (${mon_wed_12h} to ${morning_12h})" + echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00 (${thu_sun_12h} to ${morning_12h})" +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_scripts.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_scripts.sh new file mode 100755 index 00000000..e0ad9ba2 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_scripts.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Function to create smart shutdown check script +create_shutdown_check_script() { + echo "" + echo "6. Creating Smart Shutdown Check Script..." + echo "========================================" + + local check_script="/usr/local/bin/day-specific-shutdown-check.sh" + + cat >"$check_script" <<'EOF' +#!/bin/bash +# Smart day-specific shutdown check script +# Reads shutdown windows from /etc/shutdown-schedule.conf + +CONFIG_FILE="/etc/shutdown-schedule.conf" +OVERRIDES_FILE="/etc/shutdown-schedule-overrides.conf" + +# Time-boxed exceptions (e.g. watching a live event that runs past the normal +# shutdown window) are registered via shutdown-override-manager.sh, which +# appends "start_epoch|end_epoch|created_epoch|reason" lines to OVERRIDES_FILE. +# Entries are absolute-epoch-bound so they expire on their own; this function +# prunes stale lines and returns success (skip shutdown) if now falls inside +# any remaining window. +check_active_override() { + [[ -f "$OVERRIDES_FILE" ]] || return 1 + + local now + now=$(printf '%(%s)T' -1) + + local kept="" + local active=false + local active_reason="" + local start_epoch end_epoch _created reason + + while IFS='|' read -r start_epoch end_epoch _created reason; do + [[ -n "$start_epoch" ]] || continue + if [[ $end_epoch -lt $now ]]; then + continue # expired, drop it + fi + kept+="${start_epoch}|${end_epoch}|${_created}|${reason}"$'\n' + if [[ $now -ge $start_epoch ]] && [[ $now -le $end_epoch ]]; then + active=true + active_reason="$reason" + fi + done <"$OVERRIDES_FILE" + + printf '%s' "$kept" >"$OVERRIDES_FILE" + + if [[ $active == true ]]; then + logger -t day-specific-shutdown "Active override in effect (reason: ${active_reason}) - skipping shutdown check" + return 0 + fi + return 1 +} + +if check_active_override; then + exit 0 +fi + +# Load config +if [[ ! -f "$CONFIG_FILE" ]]; then + logger -t day-specific-shutdown "ERROR: Config file $CONFIG_FILE not found" + exit 1 +fi +# shellcheck source=/dev/null +source "$CONFIG_FILE" + +# Validate config +if [[ -z "${MON_WED_HOUR:-}" ]]; then + logger -t day-specific-shutdown "ERROR: Config file missing required variables" + exit 1 +fi +if [[ -z "${THU_SUN_HOUR:-}" ]]; then + logger -t day-specific-shutdown "ERROR: Config file missing required variables" + exit 1 +fi +if [[ -z "${MORNING_END_HOUR:-}" ]]; then + logger -t day-specific-shutdown "ERROR: Config file missing required variables" + exit 1 +fi + +# Get current time and day (fork-free bash builtins) +current_hour=$(printf '%(%H)T' -1) +current_minute=$(printf '%(%M)T' -1) +current_time_minutes=$((10#$current_hour * 60 + 10#$current_minute)) +day_of_week=$(printf '%(%u)T' -1) # 1=Monday, 7=Sunday +day_name=$(printf '%(%A)T' -1) + +# Calculate minute thresholds from config +mon_wed_minutes=$((MON_WED_HOUR * 60)) +thu_sun_minutes=$((THU_SUN_HOUR * 60)) +morning_end_minutes=$((MORNING_END_HOUR * 60)) + +logger -t day-specific-shutdown "Checking shutdown conditions at $(printf '%(%Y-%m-%d %H:%M:%S)T' -1) - Day: $day_name ($day_of_week), Time: $current_hour:$current_minute" + +# Determine if we should shutdown based on day and time +should_shutdown=false + +if [[ $day_of_week -ge 1 ]] && [[ $day_of_week -le 3 ]]; then + # Monday (1), Tuesday (2), Wednesday (3) + 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 -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)" + else + logger -t day-specific-shutdown "Time $current_hour:$current_minute is within morning shutdown window (00:00-0${MORNING_END_HOUR}:00)" + fi + else + logger -t day-specific-shutdown "Time $current_hour:$current_minute is outside shutdown window (${MON_WED_HOUR}:00-0${MORNING_END_HOUR}:00)" + fi +else + # Thursday (4), Friday (5), Saturday (6), Sunday (7) + 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 -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)" + else + logger -t day-specific-shutdown "Time $current_hour:$current_minute is within morning shutdown window (00:00-0${MORNING_END_HOUR}:00)" + fi + else + logger -t day-specific-shutdown "Time $current_hour:$current_minute is outside shutdown window (${THU_SUN_HOUR}:00-0${MORNING_END_HOUR}:00)" + fi +fi + +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)" + + # 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)" +fi +EOF + + chmod +x "$check_script" + echo "✓ Created smart shutdown check script: $check_script" +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_setup_flow.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_setup_flow.sh new file mode 100755 index 00000000..e9f09e08 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_setup_flow.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Function to test the setup +test_setup() { + echo "" + echo "8. Testing Setup..." + echo "==================" + + echo "Service files:" + if [[ -f "/etc/systemd/system/day-specific-shutdown.service" ]]; then + echo "✓ Service file exists" + else + echo "✗ Service file missing" + fi + + if [[ -f "/etc/systemd/system/day-specific-shutdown.timer" ]]; then + echo "✓ Timer file exists" + else + echo "✗ Timer file missing" + fi + + if [[ -f "/etc/systemd/system/shutdown-timer-monitor.service" ]]; then + echo "✓ Monitor service file exists" + else + echo "✗ Monitor service file missing" + fi + + echo "" + echo "Timer status:" + if systemctl is-enabled day-specific-shutdown.timer &>/dev/null; then + echo "✓ Timer is enabled" + else + echo "✗ Timer is not enabled" + fi + + if systemctl is-active day-specific-shutdown.timer &>/dev/null; then + echo "✓ Timer is active" + else + echo "✗ Timer is not active" + fi + + echo "" + echo "Monitor status:" + if systemctl is-enabled shutdown-timer-monitor.service &>/dev/null; then + echo "✓ Monitor is enabled" + else + echo "✗ Monitor is not enabled" + fi + + if systemctl is-active shutdown-timer-monitor.service &>/dev/null; then + echo "✓ Monitor is active" + else + echo "✗ Monitor is not active" + fi + + echo "" + echo "Watchdog timer status:" + if systemctl is-enabled shutdown-timer-monitor-watchdog.timer &>/dev/null; then + echo "✓ Watchdog timer is enabled" + else + echo "✗ Watchdog timer is not enabled" + fi + + if systemctl is-active shutdown-timer-monitor-watchdog.timer &>/dev/null; then + echo "✓ Watchdog timer is active" + else + echo "✗ Watchdog timer is not active" + fi + + echo "" + echo "Config file protection status:" + local canonical_file + canonical_file="$(canonical_config_path)" + + if [[ -f $CONFIG_FILE ]]; then + echo "✓ Config file exists" + if lsattr "$CONFIG_FILE" 2>/dev/null | grep -q '^....i'; then + echo "✓ Config file is immutable" + else + echo "✗ Config file is NOT immutable" + fi + else + echo "✗ Config file missing" + fi + + if [[ -n $canonical_file ]] && [[ -f $canonical_file ]]; then + echo "✓ Canonical copy exists" + else + echo "✗ Canonical copy missing" + fi + + if systemctl is-enabled "guard-file@${GUARD_NAME}.path" &>/dev/null; then + echo "✓ Config guard path watcher is enabled" + else + echo "✗ Config guard path watcher is not enabled" + fi + + if systemctl is-active "guard-file@${GUARD_NAME}.path" &>/dev/null; then + echo "✓ Config guard path watcher is active" + else + echo "✗ Config guard path watcher is not active" + fi + + echo "" + echo "Next scheduled checks:" + if ! systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | head -5 | grep day-specific-shutdown; then + echo "Timer information not available" + fi +} + +# Function to show final instructions +show_instructions() { + echo "" + echo "=================================================" + echo "Day-Specific Auto-Shutdown Setup Complete" + echo "=================================================" + echo "Summary:" + echo "✓ Systemd service created (/etc/systemd/system/day-specific-shutdown.service)" + echo "✓ Systemd timer created (/etc/systemd/system/day-specific-shutdown.timer)" + echo "✓ Management script created (/usr/local/bin/day-specific-shutdown-manager.sh)" + echo "✓ Smart check script created (/usr/local/bin/day-specific-shutdown-check.sh)" + echo "✓ Timer enabled and started" + echo "✓ Monitor service installed (protects timer from being disabled)" + echo "✓ Watchdog timer installed (restarts monitor if stopped)" + echo "✓ Config file protected (immutable + path watcher + canonical copy)" + echo "" + print_shutdown_schedule + echo "" + echo "Management commands:" + echo " sudo day-specific-shutdown-manager.sh status - Check status" + echo " sudo day-specific-shutdown-manager.sh logs - View shutdown logs" + echo "" + echo "How it works:" + echo "• Timer checks every 30 minutes during potential shutdown windows" + echo "• Smart logic determines shutdown eligibility based on day and time" + echo "• Monitor service watches the timer and re-enables it if disabled" + echo "• Watchdog timer restarts the monitor every 60 seconds if stopped" + echo "• Monitor has RefuseManualStop=true to prevent easy stopping" + echo "• Config file is protected by multiple security layers" + echo "• There is NO disable option - this is intentional for digital wellbeing" + echo "" + echo "WARNING: This will automatically shutdown your PC during designated hours." + echo "Make sure to save your work before the shutdown windows!" + echo "" +} + +# Function to prompt for confirmation +confirm_setup() { + echo "" + echo "WARNING: Day-Specific Auto-Shutdown Confirmation" + echo "===============================================" + echo "This will set up your PC to automatically shutdown during specific time windows." + echo "" + print_shutdown_schedule + echo "" + echo "Important considerations:" + echo "- Any unsaved work will be lost during shutdown windows" + echo "- Running processes will be terminated" + echo "- Downloads/uploads in progress will be interrupted" + echo "- You'll need to manually power on your PC each day" + echo "- Timer checks every 30 minutes during potential shutdown windows" + echo "- There is NO disable option - this is protected by a monitor service" + echo "" + read -r -p "Do you want to proceed? (y/N): " confirm + + case "$confirm" in + [yY] | [yY][eE][sS]) + echo "Proceeding with setup..." + return 0 + ;; + *) + echo "Setup cancelled." + exit 0 + ;; + esac +} + +# Main execution flow for enable +enable_midnight_shutdown() { + echo "Day-Specific Auto-Shutdown Setup for Arch Linux" + echo "===============================================" + echo "Current Date: $(date)" + echo "User: $ACTUAL_USER" + echo "Target user: $ACTUAL_USER" + echo "User home: $USER_HOME" + + # Check if trying to cheat by making schedule more lenient + check_schedule_protection + + # Confirm setup + confirm_setup + + # Create config file (shared with i3blocks countdown script) + create_shutdown_config + + # Create config guard (path watcher, enforcement, unlock script) + create_config_guard + + # Create systemd files + create_shutdown_service + create_shutdown_timer + create_management_script + create_shutdown_check_script + create_override_manager_script + + # Enable and start timer + enable_timer + + # Install monitor service (protects timer from being disabled) + install_monitor_service + + # Test setup + test_setup + + # NOTE: this used to `chattr +i` its own source ("lock this setup script so + # values + checks can't be silently edited"). Do NOT reintroduce that: making + # a git-tracked file immutable breaks git. pre-commit clears unstaged changes + # with `git checkout -- .`; that unlink fails with "Operation not permitted" + # on an immutable file, the context manager aborts, and the restore never + # runs — silently reverting your OTHER unstaged edits on every commit/push. + # + # Enforcement does not depend on it: /etc/shutdown-schedule.conf is chattr +i, + # guard-lib keeps a canonical snapshot + path watcher that re-enforces it, the + # monitor service protects the timer, and screen_locker's ratchet only accepts + # same-or-stricter values. Editing this source changes nothing until setup is + # re-run as root, which rewrites the guarded config anyway. + + # Show instructions + show_instructions +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_units.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_units.sh new file mode 100755 index 00000000..dacc4339 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/ms_units.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Function to create/update shutdown schedule config file (shared with +# i3blocks countdown). Mechanical protection (canonical snapshot, chattr, +# path watcher) is guard-lib's job via create_config_guard() below; this +# function only decides what content should exist. +create_shutdown_config() { + echo "" + echo "1. Creating Shutdown Schedule Config..." + echo "=======================================" + + local new_content + new_content="$( + cat </dev/null 2>&1; then + # Already installed and this content already passed + # check_schedule_protection's ratchet check above - apply it + # directly, canonical first then target (same race-avoidance + # order adjust_shutdown_schedule.sh uses), then re-lock both. + local canonical_file + canonical_file="$(guardctl file-guard canonical-path "$GUARD_NAME")" + chattr -i "$canonical_file" 2>/dev/null || true + chattr -i "$CONFIG_FILE" 2>/dev/null || true + echo "$new_content" >"$canonical_file" + chmod 644 "$canonical_file" + chattr +i "$canonical_file" || echo "⚠ Warning: Could not set immutable attribute on $canonical_file" + echo "$new_content" >"$CONFIG_FILE" + chmod 644 "$CONFIG_FILE" + chattr +i "$CONFIG_FILE" || echo "⚠ Warning: Could not set immutable attribute on $CONFIG_FILE" + echo "✓ Updated config and canonical copy: $CONFIG_FILE" + else + # First install: guard-lib's install snapshots this content as + # the canonical copy, so just write the plain file here. + echo "$new_content" >"$CONFIG_FILE" + chmod 644 "$CONFIG_FILE" + echo "✓ Created shutdown schedule config: $CONFIG_FILE" + fi +} + +# Function to create the shutdown service +create_shutdown_service() { + echo "" + echo "3. Creating Systemd Shutdown Service..." + echo "======================================" + + local service_file="/etc/systemd/system/day-specific-shutdown.service" + + cat >"$service_file" <<'EOF' +[Unit] +Description=Automatic PC shutdown with day-specific time windows +DefaultDependencies=false +Before=shutdown.target reboot.target halt.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/day-specific-shutdown-check.sh +TimeoutStartSec=0 +StandardOutput=journal +StandardError=journal +EOF + + echo "✓ Created systemd service: $service_file" +} + +# Function to create the shutdown timer +create_shutdown_timer() { + echo "" + echo "4. Creating Systemd Shutdown Timer..." + echo "===================================" + + local timer_file="/etc/systemd/system/day-specific-shutdown.timer" + + # Calculate earliest shutdown hour (minimum of MON_WED and THU_SUN) + local earliest_hour=$SCHEDULE_MON_WED_HOUR + if [[ $SCHEDULE_THU_SUN_HOUR -lt $earliest_hour ]]; then + earliest_hour=$SCHEDULE_THU_SUN_HOUR + fi + + # Generate timer entries dynamically from earliest_hour to MORNING_END_HOUR + # This ensures timer fires at all possible shutdown times + { + cat <"$timer_file" + + echo "✓ Created systemd timer: $timer_file" + echo " Timer covers: ${earliest_hour}:00 to 0${SCHEDULE_MORNING_END_HOUR}:00" +} + +# Function to create management script +create_management_script() { + echo "" + echo "5. Creating Management Script..." + echo "==============================" + + local script_file="/usr/local/bin/day-specific-shutdown-manager.sh" + + cat >"$script_file" <<'EOF' +#!/bin/bash +# Day-Specific Auto-Shutdown Manager +# Provides easy management of the day-specific shutdown feature + +TIMER_NAME="day-specific-shutdown.timer" +SERVICE_NAME="day-specific-shutdown.service" +CONFIG_FILE="/etc/shutdown-schedule.conf" + +# Load config for schedule display +load_config() { + if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck source=/dev/null + source "$CONFIG_FILE" + else + echo "Warning: Config file $CONFIG_FILE not found" + MON_WED_HOUR="??" + THU_SUN_HOUR="??" + MORNING_END_HOUR="??" + fi +} + +print_schedule() { + load_config + echo "Shutdown Schedule:" + echo " Monday-Wednesday: ${MON_WED_HOUR}:00-0${MORNING_END_HOUR}:00" + echo " Thursday-Sunday: ${THU_SUN_HOUR}:00-0${MORNING_END_HOUR}:00" +} + +show_status() { + echo "Day-Specific Auto-Shutdown Status" + echo "=================================" + + if systemctl is-enabled "$TIMER_NAME" &>/dev/null; then + echo "Status: ENABLED" + if systemctl is-active "$TIMER_NAME" &>/dev/null; then + echo "Timer: ACTIVE" + else + echo "Timer: INACTIVE" + fi + else + echo "Status: NOT ENABLED" + fi + + echo "" + print_schedule + + echo "" + echo "Next scheduled checks:" + systemctl list-timers "$TIMER_NAME" --no-pager 2>/dev/null | grep "$TIMER_NAME" || echo "Timer not active" + + echo "" + echo "Recent logs:" + journalctl -u "$SERVICE_NAME" --no-pager -n 5 2>/dev/null || echo "No recent logs" +} + +case "$1" in + "status") + show_status + ;; + "logs") + echo "Day-Specific Auto-Shutdown Logs" + echo "===============================" + journalctl -u "$SERVICE_NAME" --no-pager -n 20 + ;; + *) + echo "Day-Specific Auto-Shutdown Manager" + echo "Usage: $0 {status|logs}" + echo "" + echo "Commands:" + echo " status - Show current status and next shutdown checks" + echo " logs - Show recent shutdown logs" + echo "" + print_schedule + echo "" + show_status + ;; +esac +EOF + + chmod +x "$script_file" + echo "✓ Created management script: $script_file" +} + +# Function to enable the timer +enable_timer() { + echo "" + echo "5. Enabling Shutdown Timer..." + echo "============================" + + # Reload systemd daemon + systemctl daemon-reload + echo "✓ Reloaded systemd daemon" + + # Enable the timer + systemctl enable day-specific-shutdown.timer + echo "✓ Enabled day-specific-shutdown timer" + + # Start the timer + systemctl start day-specific-shutdown.timer + echo "✓ Started day-specific-shutdown timer" +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/shutdown-config-unlock.sh.in b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/shutdown-config-unlock.sh.in new file mode 100755 index 00000000..7cc33718 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/payloads/shutdown-config-unlock.sh.in @@ -0,0 +1,224 @@ +#!/bin/bash +# Unlock shutdown schedule config for editing with smart friction +# This script: +# - NO delay if making schedule STRICTER (earlier shutdown hours) +# - DELAY if making schedule more LENIENT (later shutdown hours) +# - BLOCKS lowering MORNING_END_HOUR (that would shorten the shutdown window) + +set -euo pipefail + +DELAY_SECONDS=45 +GUARD_NAME="shutdown-schedule" +CONFIG_FILE="/etc/shutdown-schedule.conf" +LOG_FILE="/var/log/shutdown-schedule-guard.log" +EDITOR="${EDITOR:-nano}" +TEMP_FILE="/tmp/shutdown-schedule-edit.$$" + +log() { + printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2 +} + +# Must be root +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root (sudo)" + exit 1 +fi + +CANONICAL_FILE="$(guardctl file-guard canonical-path "$GUARD_NAME")" +if [[ -z "$CANONICAL_FILE" ]]; then + echo "Error: guard-lib instance '$GUARD_NAME' is not installed (guardctl file-guard canonical-path returned empty)" >&2 + exit 1 +fi + +# Log the unlock attempt +log "=== UNLOCK ATTEMPT by $(logname 2>/dev/null || echo 'unknown') from TTY $(tty 2>/dev/null || echo 'unknown') ===" + +# Load current values +OLD_MON_WED="" +OLD_THU_SUN="" +OLD_MORNING_END="" +if [[ -f "$CANONICAL_FILE" ]]; then + # shellcheck source=/dev/null + source "$CANONICAL_FILE" 2>/dev/null || true + OLD_MON_WED="${MON_WED_HOUR:-}" + OLD_THU_SUN="${THU_SUN_HOUR:-}" + OLD_MORNING_END="${MORNING_END_HOUR:-}" +fi + +echo "" +echo "╔══════════════════════════════════════════════════════════════════╗" +echo "║ SHUTDOWN SCHEDULE CONFIG UNLOCK ║" +echo "╚══════════════════════════════════════════════════════════════════╝" +echo "" +echo "Current schedule:" +echo " Monday-Wednesday: ${OLD_MON_WED:-??}:00 - 0${OLD_MORNING_END:-?}:00" +echo " Thursday-Sunday: ${OLD_THU_SUN:-??}:00 - 0${OLD_MORNING_END:-?}:00" +echo "" +echo "Rules:" +echo " ✓ Making shutdown EARLIER (stricter) = No delay" +echo " ⏳ Making shutdown LATER (lenient) = ${DELAY_SECONDS}s delay required" +echo " ❌ Lowering MORNING_END_HOUR = BLOCKED (would shorten shutdown window)" +echo "" + +# Stop the path watcher temporarily. This is NOT optional: `chattr -i` +# below is itself enough to fire guard-file@shutdown-schedule.path (its +# PathModified reacts to attribute changes, not just content writes), and +# that watcher's enforce pass unconditionally re-locks the target at the +# end even when no drift is found - which would silently re-lock the file +# out from under us during the 45s delay below. Confirmed live: without +# this stop, the delayed-apply cp failed with "Operation not permitted". +systemctl stop "guard-file@${GUARD_NAME}.path" 2>/dev/null || true + +# Remove immutable attributes +chattr -i -a "$CONFIG_FILE" 2>/dev/null || true +chattr -i -a "$CANONICAL_FILE" 2>/dev/null || true + +# Copy config to temp file for editing +cp "$CONFIG_FILE" "$TEMP_FILE" + +echo "Opening editor..." +echo "" + +# Open editor on temp file +$EDITOR "$TEMP_FILE" + +# Load new values from edited file +NEW_MON_WED="" +NEW_THU_SUN="" +NEW_MORNING_END="" +# shellcheck source=/dev/null +source "$TEMP_FILE" 2>/dev/null || true +NEW_MON_WED="${MON_WED_HOUR:-}" +NEW_THU_SUN="${THU_SUN_HOUR:-}" +NEW_MORNING_END="${MORNING_END_HOUR:-}" + +echo "" +echo "Checking changes..." + +# Check for blocked changes (lowering MORNING_END_HOUR) +if [[ -n "$OLD_MORNING_END" ]] && [[ -n "$NEW_MORNING_END" ]]; then + if [[ "$NEW_MORNING_END" -lt "$OLD_MORNING_END" ]]; then + echo "" + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ ❌ CHANGE BLOCKED - CANNOT LOWER MORNING_END_HOUR ❌ ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo "" + echo "You tried to lower MORNING_END_HOUR from $OLD_MORNING_END to $NEW_MORNING_END" + echo "This would SHORTEN the shutdown window, making it more lenient." + echo "" + echo "This change is NOT allowed. The shutdown window must end at" + echo "0${OLD_MORNING_END}:00 or later." + echo "" + rm -f "$TEMP_FILE" + # Re-apply protection + chattr +i "$CONFIG_FILE" 2>/dev/null || true + chattr +i "$CANONICAL_FILE" 2>/dev/null || true + systemctl start "guard-file@${GUARD_NAME}.path" 2>/dev/null || true + log "BLOCKED: User tried to lower MORNING_END_HOUR from $OLD_MORNING_END to $NEW_MORNING_END" + exit 1 + fi +fi + +# Check if changes require delay (making schedule more lenient) +NEEDS_DELAY=false +LENIENT_CHANGES=() + +if [[ -n "$OLD_MON_WED" ]] && [[ -n "$NEW_MON_WED" ]]; then + if [[ "$NEW_MON_WED" -gt "$OLD_MON_WED" ]]; then + NEEDS_DELAY=true + LENIENT_CHANGES+=("Mon-Wed: ${OLD_MON_WED}:00 → ${NEW_MON_WED}:00 (later)") + fi +fi + +if [[ -n "$OLD_THU_SUN" ]] && [[ -n "$NEW_THU_SUN" ]]; then + if [[ "$NEW_THU_SUN" -gt "$OLD_THU_SUN" ]]; then + NEEDS_DELAY=true + LENIENT_CHANGES+=("Thu-Sun: ${OLD_THU_SUN}:00 → ${NEW_THU_SUN}:00 (later)") + fi +fi + +# Check for stricter changes (allowed without delay) +STRICTER_CHANGES=() + +if [[ -n "$OLD_MON_WED" ]] && [[ -n "$NEW_MON_WED" ]]; then + if [[ "$NEW_MON_WED" -lt "$OLD_MON_WED" ]]; then + STRICTER_CHANGES+=("Mon-Wed: ${OLD_MON_WED}:00 → ${NEW_MON_WED}:00 (earlier)") + fi +fi + +if [[ -n "$OLD_THU_SUN" ]] && [[ -n "$NEW_THU_SUN" ]]; then + if [[ "$NEW_THU_SUN" -lt "$OLD_THU_SUN" ]]; then + STRICTER_CHANGES+=("Thu-Sun: ${OLD_THU_SUN}:00 → ${NEW_THU_SUN}:00 (earlier)") + fi +fi + +if [[ -n "$OLD_MORNING_END" ]] && [[ -n "$NEW_MORNING_END" ]]; then + if [[ "$NEW_MORNING_END" -gt "$OLD_MORNING_END" ]]; then + STRICTER_CHANGES+=("Morning end: 0${OLD_MORNING_END}:00 → 0${NEW_MORNING_END}:00 (later = longer window)") + fi +fi + +# Report stricter changes +if [[ ${#STRICTER_CHANGES[@]} -gt 0 ]]; then + echo "" + echo "✓ Stricter changes detected (no delay needed):" + for s in "${STRICTER_CHANGES[@]}"; do + echo " • $s" + done +fi + +# Handle lenient changes +if [[ "$NEEDS_DELAY" == true ]]; then + echo "" + echo "⚠️ More lenient changes detected:" + for l in "${LENIENT_CHANGES[@]}"; do + echo " • $l" + done + echo "" + echo "Are you making this change for a good reason, or are you just" + echo "trying to stay up later? Remember why you set these limits." + echo "" + echo "To proceed, you must wait $DELAY_SECONDS seconds..." + echo "" + + # Countdown with opportunity to cancel + for ((i=DELAY_SECONDS; i>0; i--)); do + printf "\r ⏳ Waiting: %2d seconds remaining... (Ctrl+C to cancel)" "$i" + sleep 1 + done + echo "" + echo "" + log "User waited through delay for lenient changes: ${LENIENT_CHANGES[*]}" +else + echo "" + echo "✓ No delay required (schedule is same or stricter)" +fi + +# Apply the changes +cp "$TEMP_FILE" "$CONFIG_FILE" +cp "$TEMP_FILE" "$CANONICAL_FILE" +rm -f "$TEMP_FILE" + +chmod 644 "$CONFIG_FILE" +chmod 644 "$CANONICAL_FILE" + +# Re-apply immutable +chattr +i "$CONFIG_FILE" || echo "Warning: Could not set immutable attribute" +chattr +i "$CANONICAL_FILE" || echo "Warning: Could not set immutable attribute" + +# Restart path watcher (stopped near the start of this script) +systemctl start "guard-file@${GUARD_NAME}.path" 2>/dev/null || true + +log "Config updated and re-locked by user" + +echo "" +echo "✓ Config file updated and re-protected" +echo "✓ Canonical copy updated" +echo "✓ Path watcher re-enabled" +echo "" +echo "New schedule (will take effect on next timer check):" +# shellcheck source=/dev/null +source "$CONFIG_FILE" 2>/dev/null || true +echo " Monday-Wednesday: ${MON_WED_HOUR:-??}:00 - 0${MORNING_END_HOUR:-?}:00" +echo " Thursday-Sunday: ${THU_SUN_HOUR:-??}:00 - 0${MORNING_END_HOUR:-?}:00" +echo "" diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh index a2fb3ebd..01254914 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh @@ -11,6 +11,25 @@ SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" # shellcheck source=../../lib/common.sh source "$SCRIPT_DIR/../../lib/common.sh" +# The phases live in lib/ms_*.sh. SCRIPT_DIR is resolved with readlink -f +# above, so this resolves through a symlinked entry point too. +# shellcheck source=lib/ms_guard.sh +source "$SCRIPT_DIR/lib/ms_guard.sh" +# shellcheck source=lib/ms_config_guard.sh +source "$SCRIPT_DIR/lib/ms_config_guard.sh" +# shellcheck source=lib/ms_scripts.sh +source "$SCRIPT_DIR/lib/ms_scripts.sh" +# shellcheck source=lib/ms_override_mgr.sh +source "$SCRIPT_DIR/lib/ms_override_mgr.sh" +# shellcheck source=lib/ms_monitor.sh +source "$SCRIPT_DIR/lib/ms_monitor.sh" +# shellcheck source=lib/ms_units.sh +source "$SCRIPT_DIR/lib/ms_units.sh" +# shellcheck source=lib/ms_report.sh +source "$SCRIPT_DIR/lib/ms_report.sh" +# shellcheck source=lib/ms_setup_flow.sh +source "$SCRIPT_DIR/lib/ms_setup_flow.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 @@ -37,1680 +56,36 @@ SCHEDULE_MORNING_END_HOUR=5 GUARD_NAME="shutdown-schedule" CONFIG_FILE="/etc/shutdown-schedule.conf" -# Prints guard-lib's canonical path for our instance, or nothing if the -# instance isn't installed yet (first run on this machine). -canonical_config_path() { - if command -v guardctl >/dev/null 2>&1 && guardctl file-guard status "$GUARD_NAME" >/dev/null 2>&1; then - guardctl file-guard canonical-path "$GUARD_NAME" - fi -} # Validate that the schedule allows at least MIN_USAGE_HOURS of continuous PC usage. # The usable window is from SCHEDULE_MORNING_END_HOUR until each shutdown hour. # Both shutdown hours must independently satisfy the minimum (10 hours). MIN_USAGE_HOURS=10 -validate_minimum_usage_window() { - local mon_wed_window thu_sun_window - mon_wed_window=$(( SCHEDULE_MON_WED_HOUR - SCHEDULE_MORNING_END_HOUR )) - thu_sun_window=$(( SCHEDULE_THU_SUN_HOUR - SCHEDULE_MORNING_END_HOUR )) - - local errors=() - - if [[ $mon_wed_window -le 0 ]]; then - errors+=("Mon-Wed: morning end (${SCHEDULE_MORNING_END_HOUR}:00) is at or after shutdown (${SCHEDULE_MON_WED_HOUR}:00) — 0 usable hours") - elif [[ $mon_wed_window -lt $MIN_USAGE_HOURS ]]; then - errors+=("Mon-Wed: only ${mon_wed_window}h of usable time (${SCHEDULE_MORNING_END_HOUR}:00–${SCHEDULE_MON_WED_HOUR}:00), need at least ${MIN_USAGE_HOURS}h") - fi - - if [[ $thu_sun_window -le 0 ]]; then - errors+=("Thu-Sun: morning end (${SCHEDULE_MORNING_END_HOUR}:00) is at or after shutdown (${SCHEDULE_THU_SUN_HOUR}:00) — 0 usable hours") - elif [[ $thu_sun_window -lt $MIN_USAGE_HOURS ]]; then - errors+=("Thu-Sun: only ${thu_sun_window}h of usable time (${SCHEDULE_MORNING_END_HOUR}:00–${SCHEDULE_THU_SUN_HOUR}:00), need at least ${MIN_USAGE_HOURS}h") - fi - - if [[ ${#errors[@]} -gt 0 ]]; then - echo "" - echo "╔══════════════════════════════════════════════════════════════════╗" - echo "║ ❌ INVALID SCHEDULE CONFIGURATION ❌ ║" - echo "╚══════════════════════════════════════════════════════════════════╝" - echo "" - echo "The schedule constants do not guarantee at least ${MIN_USAGE_HOURS} hours of" - echo "continuous PC availability. This would cause the PC to shut down" - echo "immediately or very shortly after it becomes usable." - echo "" - for err in "${errors[@]}"; do - echo " ✗ $err" - done - echo "" - echo "Fix: ensure (SHUTDOWN_HOUR - MORNING_END_HOUR) >= ${MIN_USAGE_HOURS} for both windows." - echo " Example: MORNING_END_HOUR=6, SHUTDOWN_HOUR=22 → 16 usable hours ✓" - echo "" - exit 1 - fi -} # Validate schedule constants immediately (before any sudo escalation or file writes) validate_minimum_usage_window -# Check if trying to make schedule more lenient (later shutdown / earlier morning end) -check_schedule_protection() { - local canonical_config - canonical_config="$(canonical_config_path)" - # Skip check if no canonical config exists yet (first install) - if [[ -z $canonical_config ]] || [[ ! -f $canonical_config ]]; then - return 0 - fi - # Load canonical values - local canonical_mon_wed canonical_thu_sun canonical_morning_end - # shellcheck source=/dev/null - source "$canonical_config" 2>/dev/null || return 0 - canonical_mon_wed="${MON_WED_HOUR:-}" - canonical_thu_sun="${THU_SUN_HOUR:-}" - canonical_morning_end="${MORNING_END_HOUR:-}" - - # If canonical values are empty, skip check - if [[ -z $canonical_mon_wed ]]; then - return 0 - fi - if [[ -z $canonical_thu_sun ]]; then - return 0 - fi - if [[ -z $canonical_morning_end ]]; then - return 0 - fi - - local violations=() - - # Check if Mon-Wed hour is being made LATER (more lenient) - if [[ $SCHEDULE_MON_WED_HOUR -gt $canonical_mon_wed ]]; then - violations+=("Mon-Wed shutdown: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (later)") - fi - - # Check if Thu-Sun hour is being made LATER (more lenient) - if [[ $SCHEDULE_THU_SUN_HOUR -gt $canonical_thu_sun ]]; then - violations+=("Thu-Sun shutdown: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (later)") - fi - - # Check if morning end is being made EARLIER (more lenient - shorter shutdown window) - if [[ $SCHEDULE_MORNING_END_HOUR -lt $canonical_morning_end ]]; then - violations+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (earlier)") - fi - - if [[ ${#violations[@]} -gt 0 ]]; then - echo "" - echo "╔══════════════════════════════════════════════════════════════════╗" - echo "║ ❌ OPERATION NOT PERMITTED ❌ ║" - echo "╚══════════════════════════════════════════════════════════════════╝" - echo "" - echo "The requested schedule modification has been denied." - echo "" - exit 1 - fi - - # Making schedule STRICTER is always allowed - local stricter=() - if [[ $SCHEDULE_MON_WED_HOUR -lt $canonical_mon_wed ]]; then - stricter+=("Mon-Wed: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (earlier)") - fi - if [[ $SCHEDULE_THU_SUN_HOUR -lt $canonical_thu_sun ]]; then - stricter+=("Thu-Sun: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (earlier)") - fi - if [[ $SCHEDULE_MORNING_END_HOUR -gt $canonical_morning_end ]]; then - stricter+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (later)") - fi - - if [[ ${#stricter[@]} -gt 0 ]]; then - echo "" - echo "ℹ️ Schedule is being made STRICTER (allowed without unlock):" - for s in "${stricter[@]}"; do - echo " • $s" - done - echo "" - fi - - return 0 -} - -# Function to show usage -show_usage() { - echo "Day-Specific Auto-Shutdown Setup for Arch Linux" - echo "===============================================" - echo "Usage: $0 [enable|status]" - echo "" - echo "Commands:" - echo " enable - Set up automatic shutdown with day-specific windows (default)" - echo " status - Show current status" - echo "" - echo "Shutdown Schedule:" - echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00" - echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00" - echo "" - echo "NOTE: There is no 'disable' option. This is intentional." - echo " The shutdown timer is protected by a monitor service." - echo "" -} - -# Function to check and request sudo privileges -check_sudo() { - if [[ $EUID -ne 0 ]]; then - echo "This script requires sudo privileges to manage systemd services." - echo "Requesting sudo access..." - exec sudo "$0" "$@" - fi -} # Get the actual user (even when running with sudo) set_actual_user_vars -# Function to show current status -show_current_status() { - echo "Day-Specific Auto-Shutdown Status" - echo "=================================" - echo "Current Date: $(date)" - echo "User: $ACTUAL_USER" - echo "" - local timer_exists=false - # Check if files exist - if [[ -f "/etc/systemd/system/day-specific-shutdown.timer" ]]; then - timer_exists=true - echo "✓ Timer file exists" - else - echo "✗ Timer file missing" - fi - if [[ -f "/etc/systemd/system/day-specific-shutdown.service" ]]; then - echo "✓ Service file exists" - else - echo "✗ Service file missing" - fi - if [[ -f "/usr/local/bin/day-specific-shutdown-manager.sh" ]]; then - echo "✓ Management script exists" - else - echo "✗ Management script missing" - fi - if [[ -f "/usr/local/bin/shutdown-timer-monitor.sh" ]]; then - echo "✓ Monitor script exists" - else - echo "✗ Monitor script missing" - fi - echo "" - # Check systemd status - if $timer_exists; then - if systemctl is-enabled day-specific-shutdown.timer &>/dev/null; then - echo "✓ Timer is enabled" - if systemctl is-active day-specific-shutdown.timer &>/dev/null; then - echo "✓ Timer is active" - echo "" - echo "Next scheduled shutdown check:" - systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | grep day-specific-shutdown || echo "Timer information not available" - else - echo "✗ Timer is not active" - fi - else - echo "✗ Timer is not enabled" - fi - else - echo "Status: NOT CONFIGURED" - fi - echo "" - # Check monitor service status - echo "Monitor Service Status:" - if systemctl is-enabled shutdown-timer-monitor.service &>/dev/null; then - echo "✓ Monitor is enabled" - if systemctl is-active shutdown-timer-monitor.service &>/dev/null; then - echo "✓ Monitor is active (will re-enable timer if disabled)" - else - echo "✗ Monitor is not active" - fi - else - echo "✗ Monitor is not enabled" - fi - echo "" - # Check config file protection status (via guard-lib) - echo "Config File Protection Status:" - local canonical_file - canonical_file="$(canonical_config_path)" - if [[ -f $CONFIG_FILE ]]; then - echo "✓ Config file exists" - # Check immutable attribute - if lsattr "$CONFIG_FILE" 2>/dev/null | grep -q '^....i'; then - echo "✓ Config file is immutable (chattr +i)" - else - echo "✗ Config file is NOT immutable" - fi - else - echo "✗ Config file missing" - fi - if [[ -n $canonical_file ]] && [[ -f $canonical_file ]]; then - echo "✓ Canonical copy exists ($canonical_file)" - else - echo "✗ Canonical copy missing (guard-lib instance not installed?)" - fi - if systemctl is-enabled "guard-file@${GUARD_NAME}.path" &>/dev/null; then - echo "✓ Config path watcher is enabled" - if systemctl is-active "guard-file@${GUARD_NAME}.path" &>/dev/null; then - echo "✓ Config path watcher is active" - else - echo "✗ Config path watcher is not active" - fi - else - echo "✗ Config path watcher is not enabled" - fi - - echo "" - echo "Shutdown Schedule:" - echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00" - echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00" - echo "" - echo "NOTE: The shutdown timer is protected by a monitor service." - echo " If you try to disable the timer, it will be automatically re-enabled." - echo "" - echo "NOTE: The config file is protected by:" - echo " - Immutable attribute (chattr +i)" - echo " - Canonical copy that auto-restores on modification" - echo " - Path watcher service" - echo "" - - echo "Active Overrides:" - if command -v /usr/local/bin/shutdown-override-manager.sh >/dev/null 2>&1; then - /usr/local/bin/shutdown-override-manager.sh list | sed 's/^/ /' - else - echo " (override manager not installed)" - fi - echo "" -} - -# Function to create/update shutdown schedule config file (shared with -# i3blocks countdown). Mechanical protection (canonical snapshot, chattr, -# path watcher) is guard-lib's job via create_config_guard() below; this -# function only decides what content should exist. -create_shutdown_config() { - echo "" - echo "1. Creating Shutdown Schedule Config..." - echo "=======================================" - - local new_content - new_content="$(cat </dev/null 2>&1; then - # Already installed and this content already passed - # check_schedule_protection's ratchet check above - apply it - # directly, canonical first then target (same race-avoidance - # order adjust_shutdown_schedule.sh uses), then re-lock both. - local canonical_file - canonical_file="$(guardctl file-guard canonical-path "$GUARD_NAME")" - chattr -i "$canonical_file" 2>/dev/null || true - chattr -i "$CONFIG_FILE" 2>/dev/null || true - echo "$new_content" >"$canonical_file" - chmod 644 "$canonical_file" - chattr +i "$canonical_file" || echo "⚠ Warning: Could not set immutable attribute on $canonical_file" - echo "$new_content" >"$CONFIG_FILE" - chmod 644 "$CONFIG_FILE" - chattr +i "$CONFIG_FILE" || echo "⚠ Warning: Could not set immutable attribute on $CONFIG_FILE" - echo "✓ Updated config and canonical copy: $CONFIG_FILE" - else - # First install: guard-lib's install snapshots this content as - # the canonical copy, so just write the plain file here. - echo "$new_content" >"$CONFIG_FILE" - chmod 644 "$CONFIG_FILE" - echo "✓ Created shutdown schedule config: $CONFIG_FILE" - fi -} - -# Function to install guard-lib protection (path watcher + enforcement) -# and the bespoke ratchet-aware unlock script. -create_config_guard() { - echo "" - echo "2. Installing Config Guard (guard-lib + unlock script)..." - echo "==========================================================" - - command -v guardctl >/dev/null 2>&1 || { - echo "Error: guardctl not found on PATH. Set up ~/guard-lib first (run its install.sh)." >&2 - exit 1 - } - - if guardctl file-guard status "$GUARD_NAME" >/dev/null 2>&1; then - echo "✓ guard-lib instance '$GUARD_NAME' already installed (content applied above)" - else - guardctl file-guard install "$GUARD_NAME" --target "$CONFIG_FILE" - echo "✓ Installed guard-lib file-guard '$GUARD_NAME' (canonical snapshot, chattr +i, path watcher, initial enforcement)" - fi - - # Obscure name for unlock script - not documented anywhere - local unlock_script="/usr/local/sbin/.sd-sched-mgmt" - - # Create unlock script with psychological delay - cat >"$unlock_script" <<'EOF' -#!/bin/bash -# Unlock shutdown schedule config for editing with smart friction -# This script: -# - NO delay if making schedule STRICTER (earlier shutdown hours) -# - DELAY if making schedule more LENIENT (later shutdown hours) -# - BLOCKS lowering MORNING_END_HOUR (that would shorten the shutdown window) - -set -euo pipefail - -DELAY_SECONDS=45 -GUARD_NAME="shutdown-schedule" -CONFIG_FILE="/etc/shutdown-schedule.conf" -LOG_FILE="/var/log/shutdown-schedule-guard.log" -EDITOR="${EDITOR:-nano}" -TEMP_FILE="/tmp/shutdown-schedule-edit.$$" - -log() { - printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2 -} - -# Must be root -if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root (sudo)" - exit 1 -fi - -CANONICAL_FILE="$(guardctl file-guard canonical-path "$GUARD_NAME")" -if [[ -z "$CANONICAL_FILE" ]]; then - echo "Error: guard-lib instance '$GUARD_NAME' is not installed (guardctl file-guard canonical-path returned empty)" >&2 - exit 1 -fi - -# Log the unlock attempt -log "=== UNLOCK ATTEMPT by $(logname 2>/dev/null || echo 'unknown') from TTY $(tty 2>/dev/null || echo 'unknown') ===" - -# Load current values -OLD_MON_WED="" -OLD_THU_SUN="" -OLD_MORNING_END="" -if [[ -f "$CANONICAL_FILE" ]]; then - # shellcheck source=/dev/null - source "$CANONICAL_FILE" 2>/dev/null || true - OLD_MON_WED="${MON_WED_HOUR:-}" - OLD_THU_SUN="${THU_SUN_HOUR:-}" - OLD_MORNING_END="${MORNING_END_HOUR:-}" -fi - -echo "" -echo "╔══════════════════════════════════════════════════════════════════╗" -echo "║ SHUTDOWN SCHEDULE CONFIG UNLOCK ║" -echo "╚══════════════════════════════════════════════════════════════════╝" -echo "" -echo "Current schedule:" -echo " Monday-Wednesday: ${OLD_MON_WED:-??}:00 - 0${OLD_MORNING_END:-?}:00" -echo " Thursday-Sunday: ${OLD_THU_SUN:-??}:00 - 0${OLD_MORNING_END:-?}:00" -echo "" -echo "Rules:" -echo " ✓ Making shutdown EARLIER (stricter) = No delay" -echo " ⏳ Making shutdown LATER (lenient) = ${DELAY_SECONDS}s delay required" -echo " ❌ Lowering MORNING_END_HOUR = BLOCKED (would shorten shutdown window)" -echo "" - -# Stop the path watcher temporarily. This is NOT optional: `chattr -i` -# below is itself enough to fire guard-file@shutdown-schedule.path (its -# PathModified reacts to attribute changes, not just content writes), and -# that watcher's enforce pass unconditionally re-locks the target at the -# end even when no drift is found - which would silently re-lock the file -# out from under us during the 45s delay below. Confirmed live: without -# this stop, the delayed-apply cp failed with "Operation not permitted". -systemctl stop "guard-file@${GUARD_NAME}.path" 2>/dev/null || true - -# Remove immutable attributes -chattr -i -a "$CONFIG_FILE" 2>/dev/null || true -chattr -i -a "$CANONICAL_FILE" 2>/dev/null || true - -# Copy config to temp file for editing -cp "$CONFIG_FILE" "$TEMP_FILE" - -echo "Opening editor..." -echo "" - -# Open editor on temp file -$EDITOR "$TEMP_FILE" - -# Load new values from edited file -NEW_MON_WED="" -NEW_THU_SUN="" -NEW_MORNING_END="" -# shellcheck source=/dev/null -source "$TEMP_FILE" 2>/dev/null || true -NEW_MON_WED="${MON_WED_HOUR:-}" -NEW_THU_SUN="${THU_SUN_HOUR:-}" -NEW_MORNING_END="${MORNING_END_HOUR:-}" - -echo "" -echo "Checking changes..." - -# Check for blocked changes (lowering MORNING_END_HOUR) -if [[ -n "$OLD_MORNING_END" ]] && [[ -n "$NEW_MORNING_END" ]]; then - if [[ "$NEW_MORNING_END" -lt "$OLD_MORNING_END" ]]; then - echo "" - echo "╔══════════════════════════════════════════════════════════════════╗" - echo "║ ❌ CHANGE BLOCKED - CANNOT LOWER MORNING_END_HOUR ❌ ║" - echo "╚══════════════════════════════════════════════════════════════════╝" - echo "" - echo "You tried to lower MORNING_END_HOUR from $OLD_MORNING_END to $NEW_MORNING_END" - echo "This would SHORTEN the shutdown window, making it more lenient." - echo "" - echo "This change is NOT allowed. The shutdown window must end at" - echo "0${OLD_MORNING_END}:00 or later." - echo "" - rm -f "$TEMP_FILE" - # Re-apply protection - chattr +i "$CONFIG_FILE" 2>/dev/null || true - chattr +i "$CANONICAL_FILE" 2>/dev/null || true - systemctl start "guard-file@${GUARD_NAME}.path" 2>/dev/null || true - log "BLOCKED: User tried to lower MORNING_END_HOUR from $OLD_MORNING_END to $NEW_MORNING_END" - exit 1 - fi -fi - -# Check if changes require delay (making schedule more lenient) -NEEDS_DELAY=false -LENIENT_CHANGES=() - -if [[ -n "$OLD_MON_WED" ]] && [[ -n "$NEW_MON_WED" ]]; then - if [[ "$NEW_MON_WED" -gt "$OLD_MON_WED" ]]; then - NEEDS_DELAY=true - LENIENT_CHANGES+=("Mon-Wed: ${OLD_MON_WED}:00 → ${NEW_MON_WED}:00 (later)") - fi -fi - -if [[ -n "$OLD_THU_SUN" ]] && [[ -n "$NEW_THU_SUN" ]]; then - if [[ "$NEW_THU_SUN" -gt "$OLD_THU_SUN" ]]; then - NEEDS_DELAY=true - LENIENT_CHANGES+=("Thu-Sun: ${OLD_THU_SUN}:00 → ${NEW_THU_SUN}:00 (later)") - fi -fi - -# Check for stricter changes (allowed without delay) -STRICTER_CHANGES=() - -if [[ -n "$OLD_MON_WED" ]] && [[ -n "$NEW_MON_WED" ]]; then - if [[ "$NEW_MON_WED" -lt "$OLD_MON_WED" ]]; then - STRICTER_CHANGES+=("Mon-Wed: ${OLD_MON_WED}:00 → ${NEW_MON_WED}:00 (earlier)") - fi -fi - -if [[ -n "$OLD_THU_SUN" ]] && [[ -n "$NEW_THU_SUN" ]]; then - if [[ "$NEW_THU_SUN" -lt "$OLD_THU_SUN" ]]; then - STRICTER_CHANGES+=("Thu-Sun: ${OLD_THU_SUN}:00 → ${NEW_THU_SUN}:00 (earlier)") - fi -fi - -if [[ -n "$OLD_MORNING_END" ]] && [[ -n "$NEW_MORNING_END" ]]; then - if [[ "$NEW_MORNING_END" -gt "$OLD_MORNING_END" ]]; then - STRICTER_CHANGES+=("Morning end: 0${OLD_MORNING_END}:00 → 0${NEW_MORNING_END}:00 (later = longer window)") - fi -fi - -# Report stricter changes -if [[ ${#STRICTER_CHANGES[@]} -gt 0 ]]; then - echo "" - echo "✓ Stricter changes detected (no delay needed):" - for s in "${STRICTER_CHANGES[@]}"; do - echo " • $s" - done -fi - -# Handle lenient changes -if [[ "$NEEDS_DELAY" == true ]]; then - echo "" - echo "⚠️ More lenient changes detected:" - for l in "${LENIENT_CHANGES[@]}"; do - echo " • $l" - done - echo "" - echo "Are you making this change for a good reason, or are you just" - echo "trying to stay up later? Remember why you set these limits." - echo "" - echo "To proceed, you must wait $DELAY_SECONDS seconds..." - echo "" - - # Countdown with opportunity to cancel - for ((i=DELAY_SECONDS; i>0; i--)); do - printf "\r ⏳ Waiting: %2d seconds remaining... (Ctrl+C to cancel)" "$i" - sleep 1 - done - echo "" - echo "" - log "User waited through delay for lenient changes: ${LENIENT_CHANGES[*]}" -else - echo "" - echo "✓ No delay required (schedule is same or stricter)" -fi - -# Apply the changes -cp "$TEMP_FILE" "$CONFIG_FILE" -cp "$TEMP_FILE" "$CANONICAL_FILE" -rm -f "$TEMP_FILE" - -chmod 644 "$CONFIG_FILE" -chmod 644 "$CANONICAL_FILE" - -# Re-apply immutable -chattr +i "$CONFIG_FILE" || echo "Warning: Could not set immutable attribute" -chattr +i "$CANONICAL_FILE" || echo "Warning: Could not set immutable attribute" - -# Restart path watcher (stopped near the start of this script) -systemctl start "guard-file@${GUARD_NAME}.path" 2>/dev/null || true - -log "Config updated and re-locked by user" - -echo "" -echo "✓ Config file updated and re-protected" -echo "✓ Canonical copy updated" -echo "✓ Path watcher re-enabled" -echo "" -echo "New schedule (will take effect on next timer check):" -# shellcheck source=/dev/null -source "$CONFIG_FILE" 2>/dev/null || true -echo " Monday-Wednesday: ${MON_WED_HOUR:-??}:00 - 0${MORNING_END_HOUR:-?}:00" -echo " Thursday-Sunday: ${THU_SUN_HOUR:-??}:00 - 0${MORNING_END_HOUR:-?}:00" -echo "" -EOF - - chmod +x "$unlock_script" - # Silently create unlock script - do not announce its existence -} - -# Function to create the shutdown service -create_shutdown_service() { - echo "" - echo "3. Creating Systemd Shutdown Service..." - echo "======================================" - - local service_file="/etc/systemd/system/day-specific-shutdown.service" - - cat >"$service_file" <<'EOF' -[Unit] -Description=Automatic PC shutdown with day-specific time windows -DefaultDependencies=false -Before=shutdown.target reboot.target halt.target - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/day-specific-shutdown-check.sh -TimeoutStartSec=0 -StandardOutput=journal -StandardError=journal -EOF - - echo "✓ Created systemd service: $service_file" -} - -# Function to create the shutdown timer -create_shutdown_timer() { - echo "" - echo "4. Creating Systemd Shutdown Timer..." - echo "===================================" - - local timer_file="/etc/systemd/system/day-specific-shutdown.timer" - - # Calculate earliest shutdown hour (minimum of MON_WED and THU_SUN) - local earliest_hour=$SCHEDULE_MON_WED_HOUR - if [[ $SCHEDULE_THU_SUN_HOUR -lt $earliest_hour ]]; then - earliest_hour=$SCHEDULE_THU_SUN_HOUR - fi - - # Generate timer entries dynamically from earliest_hour to MORNING_END_HOUR - # This ensures timer fires at all possible shutdown times - { - cat <"$timer_file" - - echo "✓ Created systemd timer: $timer_file" - echo " Timer covers: ${earliest_hour}:00 to 0${SCHEDULE_MORNING_END_HOUR}:00" -} - -# Function to create management script -create_management_script() { - echo "" - echo "5. Creating Management Script..." - echo "==============================" - - local script_file="/usr/local/bin/day-specific-shutdown-manager.sh" - - cat >"$script_file" <<'EOF' -#!/bin/bash -# Day-Specific Auto-Shutdown Manager -# Provides easy management of the day-specific shutdown feature - -TIMER_NAME="day-specific-shutdown.timer" -SERVICE_NAME="day-specific-shutdown.service" -CONFIG_FILE="/etc/shutdown-schedule.conf" - -# Load config for schedule display -load_config() { - if [[ -f "$CONFIG_FILE" ]]; then - # shellcheck source=/dev/null - source "$CONFIG_FILE" - else - echo "Warning: Config file $CONFIG_FILE not found" - MON_WED_HOUR="??" - THU_SUN_HOUR="??" - MORNING_END_HOUR="??" - fi -} - -print_schedule() { - load_config - echo "Shutdown Schedule:" - echo " Monday-Wednesday: ${MON_WED_HOUR}:00-0${MORNING_END_HOUR}:00" - echo " Thursday-Sunday: ${THU_SUN_HOUR}:00-0${MORNING_END_HOUR}:00" -} - -show_status() { - echo "Day-Specific Auto-Shutdown Status" - echo "=================================" - - if systemctl is-enabled "$TIMER_NAME" &>/dev/null; then - echo "Status: ENABLED" - if systemctl is-active "$TIMER_NAME" &>/dev/null; then - echo "Timer: ACTIVE" - else - echo "Timer: INACTIVE" - fi - else - echo "Status: NOT ENABLED" - fi - - echo "" - print_schedule - - echo "" - echo "Next scheduled checks:" - systemctl list-timers "$TIMER_NAME" --no-pager 2>/dev/null | grep "$TIMER_NAME" || echo "Timer not active" - - echo "" - echo "Recent logs:" - journalctl -u "$SERVICE_NAME" --no-pager -n 5 2>/dev/null || echo "No recent logs" -} - -case "$1" in - "status") - show_status - ;; - "logs") - echo "Day-Specific Auto-Shutdown Logs" - echo "===============================" - journalctl -u "$SERVICE_NAME" --no-pager -n 20 - ;; - *) - echo "Day-Specific Auto-Shutdown Manager" - echo "Usage: $0 {status|logs}" - echo "" - echo "Commands:" - echo " status - Show current status and next shutdown checks" - echo " logs - Show recent shutdown logs" - echo "" - print_schedule - echo "" - show_status - ;; -esac -EOF - - chmod +x "$script_file" - echo "✓ Created management script: $script_file" -} - -# Function to create smart shutdown check script -create_shutdown_check_script() { - echo "" - echo "6. Creating Smart Shutdown Check Script..." - echo "========================================" - - local check_script="/usr/local/bin/day-specific-shutdown-check.sh" - - cat >"$check_script" <<'EOF' -#!/bin/bash -# Smart day-specific shutdown check script -# Reads shutdown windows from /etc/shutdown-schedule.conf - -CONFIG_FILE="/etc/shutdown-schedule.conf" -OVERRIDES_FILE="/etc/shutdown-schedule-overrides.conf" - -# Time-boxed exceptions (e.g. watching a live event that runs past the normal -# shutdown window) are registered via shutdown-override-manager.sh, which -# appends "start_epoch|end_epoch|created_epoch|reason" lines to OVERRIDES_FILE. -# Entries are absolute-epoch-bound so they expire on their own; this function -# prunes stale lines and returns success (skip shutdown) if now falls inside -# any remaining window. -check_active_override() { - [[ -f "$OVERRIDES_FILE" ]] || return 1 - - local now - now=$(printf '%(%s)T' -1) - - local kept="" - local active=false - local active_reason="" - local start_epoch end_epoch _created reason - - while IFS='|' read -r start_epoch end_epoch _created reason; do - [[ -n "$start_epoch" ]] || continue - if [[ $end_epoch -lt $now ]]; then - continue # expired, drop it - fi - kept+="${start_epoch}|${end_epoch}|${_created}|${reason}"$'\n' - if [[ $now -ge $start_epoch ]] && [[ $now -le $end_epoch ]]; then - active=true - active_reason="$reason" - fi - done <"$OVERRIDES_FILE" - - printf '%s' "$kept" >"$OVERRIDES_FILE" - - if [[ $active == true ]]; then - logger -t day-specific-shutdown "Active override in effect (reason: ${active_reason}) - skipping shutdown check" - return 0 - fi - return 1 -} - -if check_active_override; then - exit 0 -fi - -# Load config -if [[ ! -f "$CONFIG_FILE" ]]; then - logger -t day-specific-shutdown "ERROR: Config file $CONFIG_FILE not found" - exit 1 -fi -# shellcheck source=/dev/null -source "$CONFIG_FILE" - -# Validate config -if [[ -z "${MON_WED_HOUR:-}" ]]; then - logger -t day-specific-shutdown "ERROR: Config file missing required variables" - exit 1 -fi -if [[ -z "${THU_SUN_HOUR:-}" ]]; then - logger -t day-specific-shutdown "ERROR: Config file missing required variables" - exit 1 -fi -if [[ -z "${MORNING_END_HOUR:-}" ]]; then - logger -t day-specific-shutdown "ERROR: Config file missing required variables" - exit 1 -fi - -# Get current time and day (fork-free bash builtins) -current_hour=$(printf '%(%H)T' -1) -current_minute=$(printf '%(%M)T' -1) -current_time_minutes=$((10#$current_hour * 60 + 10#$current_minute)) -day_of_week=$(printf '%(%u)T' -1) # 1=Monday, 7=Sunday -day_name=$(printf '%(%A)T' -1) - -# Calculate minute thresholds from config -mon_wed_minutes=$((MON_WED_HOUR * 60)) -thu_sun_minutes=$((THU_SUN_HOUR * 60)) -morning_end_minutes=$((MORNING_END_HOUR * 60)) - -logger -t day-specific-shutdown "Checking shutdown conditions at $(printf '%(%Y-%m-%d %H:%M:%S)T' -1) - Day: $day_name ($day_of_week), Time: $current_hour:$current_minute" - -# Determine if we should shutdown based on day and time -should_shutdown=false - -if [[ $day_of_week -ge 1 ]] && [[ $day_of_week -le 3 ]]; then - # Monday (1), Tuesday (2), Wednesday (3) - 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 -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)" - else - logger -t day-specific-shutdown "Time $current_hour:$current_minute is within morning shutdown window (00:00-0${MORNING_END_HOUR}:00)" - fi - else - logger -t day-specific-shutdown "Time $current_hour:$current_minute is outside shutdown window (${MON_WED_HOUR}:00-0${MORNING_END_HOUR}:00)" - fi -else - # Thursday (4), Friday (5), Saturday (6), Sunday (7) - 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 -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)" - else - logger -t day-specific-shutdown "Time $current_hour:$current_minute is within morning shutdown window (00:00-0${MORNING_END_HOUR}:00)" - fi - else - logger -t day-specific-shutdown "Time $current_hour:$current_minute is outside shutdown window (${THU_SUN_HOUR}:00-0${MORNING_END_HOUR}:00)" - fi -fi - -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)" - - # 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)" -fi -EOF - - chmod +x "$check_script" - echo "✓ Created smart shutdown check script: $check_script" -} - -# Function to create the time-boxed override manager (event exceptions like -# watching a live match that runs past the normal shutdown window). Overrides -# are absolute-epoch-bound so they expire on their own; the friction (typed -# confirmation + delay) lives entirely in this CLI, matching the existing -# unlock script's philosophy for the permanent schedule ratchet. -create_override_manager_script() { - echo "" - echo "9. Creating Shutdown Override Manager..." - echo "========================================" - - local overrides_file="/etc/shutdown-schedule-overrides.conf" - local log_file="/var/log/shutdown-schedule-overrides.log" - local manager_script="/usr/local/bin/shutdown-override-manager.sh" - - touch "$overrides_file" - # World-readable (like shutdown-schedule.conf): the i3blocks countdown - # script runs as the regular user and needs to read active overrides to - # display them. Only root can write to it (via this manager script). - chmod 644 "$overrides_file" - touch "$log_file" - chmod 600 "$log_file" - - cat >"$manager_script" <<'EOF' -#!/bin/bash -# Shutdown Override Manager -# Registers time-boxed exceptions to the day-specific-shutdown schedule, e.g. -# to watch a live event that runs past the normal shutdown window. Entries are -# absolute-epoch-bound and expire on their own - there is nothing to re-lock. - -set -euo pipefail - -OVERRIDES_FILE="/etc/shutdown-schedule-overrides.conf" -LOG_FILE="/var/log/shutdown-schedule-overrides.log" -MAX_OVERRIDE_HOURS=12 -MAX_ACTIVE_OVERRIDES=3 -MIN_REASON_LEN=10 -CONFIRM_PHRASE="I am deliberately bypassing my shutdown protection" -DELAY_SECONDS=30 - -log() { - printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2 -} - -require_root() { - if [[ $EUID -ne 0 ]]; then - echo "This command requires sudo." >&2 - exit 1 - fi -} - -now_epoch() { - printf '%(%s)T' -1 -} - -prune_and_read() { - # Prints kept (non-expired) lines; overwrites OVERRIDES_FILE with them. - local now kept="" - now=$(now_epoch) - if [[ -f "$OVERRIDES_FILE" ]]; then - local start_epoch end_epoch created reason - while IFS='|' read -r start_epoch end_epoch created reason; do - [[ -n "$start_epoch" ]] || continue - [[ "$end_epoch" -lt "$now" ]] && continue - kept+="${start_epoch}|${end_epoch}|${created}|${reason}"$'\n' - done <"$OVERRIDES_FILE" - fi - printf '%s' "$kept" >"$OVERRIDES_FILE" - printf '%s' "$kept" -} - -cmd_list() { - local kept - kept="$(prune_and_read)" - if [[ -z "$kept" ]]; then - echo "No active or upcoming overrides." - return 0 - fi - echo "Active/upcoming overrides:" - local i=1 - local start_epoch end_epoch created reason - while IFS='|' read -r start_epoch end_epoch created reason; do - [[ -n "$start_epoch" ]] || continue - printf ' [%d] %s -> %s (reason: %s)\n' \ - "$i" \ - "$(date -d "@$start_epoch" '+%Y-%m-%d %H:%M')" \ - "$(date -d "@$end_epoch" '+%Y-%m-%d %H:%M')" \ - "$reason" - i=$((i + 1)) - done <<<"$kept" -} - -cmd_remove() { - require_root - local target="${1:-}" - [[ -n "$target" ]] || { echo "Usage: $0 remove " >&2; exit 1; } - - local kept - kept="$(prune_and_read)" - if [[ -z "$kept" ]]; then - echo "No overrides to remove." >&2 - exit 1 - fi - - local new_content="" i=1 start_epoch end_epoch created reason removed=false - while IFS='|' read -r start_epoch end_epoch created reason; do - [[ -n "$start_epoch" ]] || continue - if [[ "$i" == "$target" ]]; then - removed=true - else - new_content+="${start_epoch}|${end_epoch}|${created}|${reason}"$'\n' - fi - i=$((i + 1)) - done <<<"$kept" - - if [[ "$removed" != true ]]; then - echo "No override at index $target" >&2 - exit 1 - fi - - printf '%s' "$new_content" >"$OVERRIDES_FILE" - log "Removed override index $target (by $(logname 2>/dev/null || echo unknown))" - echo "✓ Removed override $target" -} - -cmd_add() { - require_root - local start_str="${1:-}" end_str="${2:-}" - local reason="${*:3}" - - if [[ -z "$start_str" ]] || [[ -z "$end_str" ]] || [[ -z "$reason" ]]; then - echo "Usage: $0 add '' '' " >&2 - echo "Example: $0 add '2026-07-10 21:30' '2026-07-11 00:30' 'World Cup match'" >&2 - exit 1 - fi - - if [[ "${#reason}" -lt "$MIN_REASON_LEN" ]]; then - echo "Reason must be at least $MIN_REASON_LEN characters." >&2 - exit 1 - fi - - local start_epoch end_epoch - start_epoch=$(date -d "$start_str" +%s) || { echo "Could not parse start datetime: $start_str" >&2; exit 1; } - end_epoch=$(date -d "$end_str" +%s) || { echo "Could not parse end datetime: $end_str" >&2; exit 1; } - - if [[ "$end_epoch" -le "$start_epoch" ]]; then - echo "End must be after start." >&2 - exit 1 - fi - - local duration_hours=$(( (end_epoch - start_epoch) / 3600 )) - if [[ "$duration_hours" -gt "$MAX_OVERRIDE_HOURS" ]]; then - echo "Override duration (${duration_hours}h) exceeds the max of ${MAX_OVERRIDE_HOURS}h." >&2 - exit 1 - fi - - local kept active_count - kept="$(prune_and_read)" - active_count=$(grep -c . <<<"$kept" 2>/dev/null || true) - active_count="${active_count:-0}" - if [[ "$active_count" -ge "$MAX_ACTIVE_OVERRIDES" ]]; then - echo "Already at the max of $MAX_ACTIVE_OVERRIDES active/upcoming overrides. Remove one first." >&2 - cmd_list - exit 1 - fi - - echo "" - echo "╔══════════════════════════════════════════════════════════════════╗" - echo "║ SHUTDOWN SCHEDULE OVERRIDE REQUEST ║" - echo "╚══════════════════════════════════════════════════════════════════╝" - echo "" - echo "This suspends the shutdown enforcement you built for yourself during:" - echo " $(date -d "@$start_epoch" '+%Y-%m-%d %H:%M') -> $(date -d "@$end_epoch" '+%Y-%m-%d %H:%M') (${duration_hours}h)" - echo " Reason: $reason" - echo "" - echo "Current overrides:" - cmd_list - echo "" - echo "To confirm this is a deliberate, one-off exception (not just wanting to" - echo "stay up), type the following phrase exactly:" - echo "" - echo " $CONFIRM_PHRASE" - echo "" - read -r -p "> " typed_phrase - if [[ "$typed_phrase" != "$CONFIRM_PHRASE" ]]; then - echo "Phrase did not match. Aborting." >&2 - log "ABORTED: confirmation phrase mismatch for window $start_str -> $end_str" - exit 1 - fi - - echo "" - echo "Waiting $DELAY_SECONDS seconds before committing (Ctrl+C to cancel)..." - local i - for ((i = DELAY_SECONDS; i > 0; i--)); do - printf "\r Waiting: %2d seconds remaining..." "$i" - sleep 1 - done - echo "" - - local created - created=$(now_epoch) - printf '%s|%s|%s|%s\n' "$start_epoch" "$end_epoch" "$created" "$reason" >>"$OVERRIDES_FILE" - - log "ADDED override by $(logname 2>/dev/null || echo unknown) from TTY $(tty 2>/dev/null || echo unknown): $start_str -> $end_str (reason: $reason)" - echo "✓ Override registered: $(date -d "@$start_epoch" '+%Y-%m-%d %H:%M') -> $(date -d "@$end_epoch" '+%Y-%m-%d %H:%M')" -} - -case "${1:-}" in - add) - shift - cmd_add "$@" - ;; - list) - cmd_list - ;; - remove) - shift - cmd_remove "$@" - ;; - *) - echo "Shutdown Override Manager" - echo "Usage: $0 {add|list|remove}" - echo "" - echo " add '' '' Register a time-boxed exception" - echo " list Show active/upcoming overrides" - echo " remove Cancel an override (no friction)" - echo "" - cmd_list - ;; -esac -EOF - - chmod +x "$manager_script" - echo "✓ Created override manager: $manager_script" - echo "✓ Overrides file: $overrides_file (root-only)" - echo "✓ Override log: $log_file" -} - -# Function to enable the timer -enable_timer() { - echo "" - echo "5. Enabling Shutdown Timer..." - echo "============================" - - # Reload systemd daemon - systemctl daemon-reload - echo "✓ Reloaded systemd daemon" - - # Enable the timer - systemctl enable day-specific-shutdown.timer - echo "✓ Enabled day-specific-shutdown timer" - - # Start the timer - systemctl start day-specific-shutdown.timer - echo "✓ Started day-specific-shutdown timer" -} - -# Function to install the monitor service -install_monitor_service() { - echo "" - echo "7. Installing Shutdown Timer Monitor Service..." - echo "==============================================" - - local monitor_script="/usr/local/bin/shutdown-timer-monitor.sh" - local monitor_service="/etc/systemd/system/shutdown-timer-monitor.service" - local monitor_timer="/etc/systemd/system/shutdown-timer-monitor-watchdog.timer" - local monitor_watchdog_service="/etc/systemd/system/shutdown-timer-monitor-watchdog.service" - - # Create the monitor script - cat >"$monitor_script" <<'EOF' -#!/bin/bash -# Shutdown timer monitor script -# Watches the day-specific-shutdown timer and re-enables it if disabled -# Also ensures the monitor service itself stays running - -set -euo pipefail - -LOG_FILE="/var/log/shutdown-timer-monitor.log" -TIMER_NAME="day-specific-shutdown.timer" -SERVICE_NAME="day-specific-shutdown.service" -MONITOR_SERVICE="shutdown-timer-monitor.service" -CHECK_INTERVAL=30 - -wait_seconds() { - local timeout_s=$1 - local start_ts end_ts elapsed_s remaining_s - - printf -v start_ts '%(%s)T' -1 - IFS= read -r -t "$timeout_s" || true - printf -v end_ts '%(%s)T' -1 - - elapsed_s=$((end_ts - start_ts)) - if (( elapsed_s < timeout_s )); then - remaining_s=$((timeout_s - elapsed_s)) - sleep "$remaining_s" - fi -} - -current_epoch() { - local out_var="${1:-}" - if [[ -n $out_var ]]; then - printf -v "$out_var" '%(%s)T' -1 - else - printf '%(%s)T\n' -1 - fi -} - -log_message() { - local _ts - local msg - printf -v _ts '%(%Y-%m-%d %H:%M:%S)T' -1 - printf -v msg '%s [shutdown-monitor] %s' "$_ts" "$1" - printf '%s\n' "$msg" >&2 - printf '%s\n' "$msg" >> "$LOG_FILE" 2>/dev/null || true -} - -timer_needs_restoration() { - if ! systemctl is-enabled "$TIMER_NAME" &>/dev/null; then - log_message "Timer $TIMER_NAME is not enabled" - return 0 - fi - if ! systemctl is-active "$TIMER_NAME" &>/dev/null; then - log_message "Timer $TIMER_NAME is not active" - return 0 - fi - if [[ ! -f "/etc/systemd/system/$TIMER_NAME" ]]; then - log_message "Timer unit file missing" - return 0 - fi - if [[ ! -f "/etc/systemd/system/$SERVICE_NAME" ]]; then - log_message "Service unit file missing" - return 0 - fi - if [[ ! -f "/usr/local/bin/day-specific-shutdown-check.sh" ]]; then - log_message "Check script missing" - return 0 - fi - return 1 -} - -restore_timer() { - log_message "Shutdown timer tampering detected - initiating restoration" - systemctl daemon-reload - if ! systemctl is-enabled "$TIMER_NAME" &>/dev/null; then - log_message "Re-enabling $TIMER_NAME" - systemctl enable "$TIMER_NAME" 2>/dev/null || true - fi - if ! systemctl is-active "$TIMER_NAME" &>/dev/null; then - log_message "Re-starting $TIMER_NAME" - systemctl start "$TIMER_NAME" 2>/dev/null || true - fi - if systemctl is-active "$TIMER_NAME" &>/dev/null; then - log_message "Timer restoration completed successfully" - else - log_message "WARNING: Timer restoration may have failed" - fi -} - -monitor_with_dbus() { - log_message "Starting shutdown timer monitoring with D-Bus events" - local last_check_ts=0 - - if command -v busctl &>/dev/null; then - busctl monitor --system org.freedesktop.systemd1 2>/dev/null | - while read -r line; do - if [[ $line == *"$TIMER_NAME"* || $line == *"$SERVICE_NAME"* ]]; then - local now_ts - current_epoch now_ts - if (( now_ts - last_check_ts < CHECK_INTERVAL )); then - continue - fi - last_check_ts=$now_ts - log_message "Systemd event detected for shutdown timer" - if timer_needs_restoration; then - restore_timer - fi - fi - done - else - log_message "busctl not available, falling back to polling" - monitor_with_polling - fi -} - -monitor_with_polling() { - log_message "Starting shutdown timer monitoring with polling (interval: ${CHECK_INTERVAL}s)" - - while true; do - if timer_needs_restoration; then - restore_timer - fi - wait_seconds "$CHECK_INTERVAL" - done -} - -start_monitoring() { - log_message "=== Shutdown Timer Monitor Started ===" - log_message "Monitoring timer: $TIMER_NAME" - log_message "Monitoring service: $SERVICE_NAME" - - if timer_needs_restoration; then - log_message "Initial check: Timer needs restoration" - restore_timer - else - log_message "Initial check: Timer is properly configured" - fi - - if command -v busctl &>/dev/null; then - monitor_with_dbus - else - log_message "busctl not available, falling back to polling" - monitor_with_polling - fi -} - -start_monitoring -EOF - - chmod +x "$monitor_script" - echo "✓ Created monitor script: $monitor_script" - - # Create the monitor service with RefuseManualStop to prevent manual stopping - cat >"$monitor_service" <<'EOF' -[Unit] -Description=Shutdown Timer Monitor and Auto-Restore Service -After=network-online.target day-specific-shutdown.timer -Wants=network-online.target -# Make it hard to stop - refuse manual stop/restart -RefuseManualStop=true -RefuseManualStart=false - -[Service] -Type=simple -User=root -ExecStart=/usr/local/bin/shutdown-timer-monitor.sh -Restart=always -RestartSec=5 -# Restart even on success exit -RestartForceExitStatus=0 1 2 SIGTERM SIGKILL -StandardOutput=journal -StandardError=journal -Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -NoNewPrivileges=false -PrivateTmp=true -MemoryMax=50M -CPUQuota=10% - -[Install] -WantedBy=multi-user.target -EOF - - echo "✓ Created monitor service: $monitor_service" - - # Create a watchdog timer that ensures the monitor stays running - cat >"$monitor_watchdog_service" <<'EOF' -[Unit] -Description=Watchdog for Shutdown Timer Monitor -After=multi-user.target - -[Service] -Type=oneshot -ExecStart=/bin/bash -c 'systemctl is-active shutdown-timer-monitor.service || systemctl start shutdown-timer-monitor.service' -ExecStart=/bin/bash -c 'systemctl is-active day-specific-shutdown.timer || systemctl start day-specific-shutdown.timer' -EOF - - echo "✓ Created watchdog service: $monitor_watchdog_service" - - cat >"$monitor_timer" <<'EOF' -[Unit] -Description=Watchdog Timer for Shutdown Timer Monitor -After=multi-user.target - -[Timer] -OnBootSec=60 -OnUnitActiveSec=300 -Persistent=true - -[Install] -WantedBy=timers.target -EOF - - echo "✓ Created watchdog timer: $monitor_timer" - - # Reload and enable everything - systemctl daemon-reload - systemctl enable shutdown-timer-monitor.service - systemctl enable shutdown-timer-monitor-watchdog.timer - systemctl start shutdown-timer-monitor.service - systemctl start shutdown-timer-monitor-watchdog.timer - echo "✓ Enabled and started shutdown-timer-monitor.service" - echo "✓ Enabled and started shutdown-timer-monitor-watchdog.timer" -} - -# Function to test the setup -test_setup() { - echo "" - echo "8. Testing Setup..." - echo "==================" - - echo "Service files:" - if [[ -f "/etc/systemd/system/day-specific-shutdown.service" ]]; then - echo "✓ Service file exists" - else - echo "✗ Service file missing" - fi - - if [[ -f "/etc/systemd/system/day-specific-shutdown.timer" ]]; then - echo "✓ Timer file exists" - else - echo "✗ Timer file missing" - fi - - if [[ -f "/etc/systemd/system/shutdown-timer-monitor.service" ]]; then - echo "✓ Monitor service file exists" - else - echo "✗ Monitor service file missing" - fi - - echo "" - echo "Timer status:" - if systemctl is-enabled day-specific-shutdown.timer &>/dev/null; then - echo "✓ Timer is enabled" - else - echo "✗ Timer is not enabled" - fi - - if systemctl is-active day-specific-shutdown.timer &>/dev/null; then - echo "✓ Timer is active" - else - echo "✗ Timer is not active" - fi - - echo "" - echo "Monitor status:" - if systemctl is-enabled shutdown-timer-monitor.service &>/dev/null; then - echo "✓ Monitor is enabled" - else - echo "✗ Monitor is not enabled" - fi - - if systemctl is-active shutdown-timer-monitor.service &>/dev/null; then - echo "✓ Monitor is active" - else - echo "✗ Monitor is not active" - fi - - echo "" - echo "Watchdog timer status:" - if systemctl is-enabled shutdown-timer-monitor-watchdog.timer &>/dev/null; then - echo "✓ Watchdog timer is enabled" - else - echo "✗ Watchdog timer is not enabled" - fi - - if systemctl is-active shutdown-timer-monitor-watchdog.timer &>/dev/null; then - echo "✓ Watchdog timer is active" - else - echo "✗ Watchdog timer is not active" - fi - - echo "" - echo "Config file protection status:" - local canonical_file - canonical_file="$(canonical_config_path)" - - if [[ -f $CONFIG_FILE ]]; then - echo "✓ Config file exists" - if lsattr "$CONFIG_FILE" 2>/dev/null | grep -q '^....i'; then - echo "✓ Config file is immutable" - else - echo "✗ Config file is NOT immutable" - fi - else - echo "✗ Config file missing" - fi - - if [[ -n $canonical_file ]] && [[ -f $canonical_file ]]; then - echo "✓ Canonical copy exists" - else - echo "✗ Canonical copy missing" - fi - - if systemctl is-enabled "guard-file@${GUARD_NAME}.path" &>/dev/null; then - echo "✓ Config guard path watcher is enabled" - else - echo "✗ Config guard path watcher is not enabled" - fi - - if systemctl is-active "guard-file@${GUARD_NAME}.path" &>/dev/null; then - echo "✓ Config guard path watcher is active" - else - echo "✗ Config guard path watcher is not active" - fi - - echo "" - echo "Next scheduled checks:" - if ! systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | head -5 | grep day-specific-shutdown; then - echo "Timer information not available" - fi -} - -# Display the shutdown schedule (used in multiple places) -print_shutdown_schedule() { - # Convert 24h to 12h format for display - local mon_wed_12h thu_sun_12h morning_12h - if [[ $SCHEDULE_MON_WED_HOUR -gt 12 ]]; then - mon_wed_12h="$((SCHEDULE_MON_WED_HOUR - 12)):00 PM" - else - mon_wed_12h="${SCHEDULE_MON_WED_HOUR}:00 AM" - fi - if [[ $SCHEDULE_THU_SUN_HOUR -gt 12 ]]; then - thu_sun_12h="$((SCHEDULE_THU_SUN_HOUR - 12)):00 PM" - else - thu_sun_12h="${SCHEDULE_THU_SUN_HOUR}:00 AM" - fi - morning_12h="${SCHEDULE_MORNING_END_HOUR}:00 AM" - - echo "Shutdown Schedule:" - echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00 (${mon_wed_12h} to ${morning_12h})" - echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00 (${thu_sun_12h} to ${morning_12h})" -} - -# Function to show final instructions -show_instructions() { - echo "" - echo "=================================================" - echo "Day-Specific Auto-Shutdown Setup Complete" - echo "=================================================" - echo "Summary:" - echo "✓ Systemd service created (/etc/systemd/system/day-specific-shutdown.service)" - echo "✓ Systemd timer created (/etc/systemd/system/day-specific-shutdown.timer)" - echo "✓ Management script created (/usr/local/bin/day-specific-shutdown-manager.sh)" - echo "✓ Smart check script created (/usr/local/bin/day-specific-shutdown-check.sh)" - echo "✓ Timer enabled and started" - echo "✓ Monitor service installed (protects timer from being disabled)" - echo "✓ Watchdog timer installed (restarts monitor if stopped)" - echo "✓ Config file protected (immutable + path watcher + canonical copy)" - echo "" - print_shutdown_schedule - echo "" - echo "Management commands:" - echo " sudo day-specific-shutdown-manager.sh status - Check status" - echo " sudo day-specific-shutdown-manager.sh logs - View shutdown logs" - echo "" - echo "How it works:" - echo "• Timer checks every 30 minutes during potential shutdown windows" - echo "• Smart logic determines shutdown eligibility based on day and time" - echo "• Monitor service watches the timer and re-enables it if disabled" - echo "• Watchdog timer restarts the monitor every 60 seconds if stopped" - echo "• Monitor has RefuseManualStop=true to prevent easy stopping" - echo "• Config file is protected by multiple security layers" - echo "• There is NO disable option - this is intentional for digital wellbeing" - echo "" - echo "WARNING: This will automatically shutdown your PC during designated hours." - echo "Make sure to save your work before the shutdown windows!" - echo "" -} - -# Function to prompt for confirmation -confirm_setup() { - echo "" - echo "WARNING: Day-Specific Auto-Shutdown Confirmation" - echo "===============================================" - echo "This will set up your PC to automatically shutdown during specific time windows." - echo "" - print_shutdown_schedule - echo "" - echo "Important considerations:" - echo "- Any unsaved work will be lost during shutdown windows" - echo "- Running processes will be terminated" - echo "- Downloads/uploads in progress will be interrupted" - echo "- You'll need to manually power on your PC each day" - echo "- Timer checks every 30 minutes during potential shutdown windows" - echo "- There is NO disable option - this is protected by a monitor service" - echo "" - read -r -p "Do you want to proceed? (y/N): " confirm - - case "$confirm" in - [yY] | [yY][eE][sS]) - echo "Proceeding with setup..." - return 0 - ;; - *) - echo "Setup cancelled." - exit 0 - ;; - esac -} - -# Main execution flow for enable -enable_midnight_shutdown() { - echo "Day-Specific Auto-Shutdown Setup for Arch Linux" - echo "===============================================" - echo "Current Date: $(date)" - echo "User: $ACTUAL_USER" - echo "Target user: $ACTUAL_USER" - echo "User home: $USER_HOME" - - # Check if trying to cheat by making schedule more lenient - check_schedule_protection - - # Confirm setup - confirm_setup - - # Create config file (shared with i3blocks countdown script) - create_shutdown_config - - # Create config guard (path watcher, enforcement, unlock script) - create_config_guard - - # Create systemd files - create_shutdown_service - create_shutdown_timer - create_management_script - create_shutdown_check_script - create_override_manager_script - - # Enable and start timer - enable_timer - - # Install monitor service (protects timer from being disabled) - install_monitor_service - - # Test setup - test_setup - - # NOTE: this used to `chattr +i` its own source ("lock this setup script so - # values + checks can't be silently edited"). Do NOT reintroduce that: making - # a git-tracked file immutable breaks git. pre-commit clears unstaged changes - # with `git checkout -- .`; that unlink fails with "Operation not permitted" - # on an immutable file, the context manager aborts, and the restore never - # runs — silently reverting your OTHER unstaged edits on every commit/push. - # - # Enforcement does not depend on it: /etc/shutdown-schedule.conf is chattr +i, - # guard-lib keeps a canonical snapshot + path watcher that re-enforces it, the - # monitor service protects the timer, and screen_locker's ratchet only accepts - # same-or-stricter values. Editing this source changes nothing until setup is - # re-run as root, which rewrites the guarded config anyway. - - # Show instructions - show_instructions -} # Parse command line arguments case "${1:-enable}" in