setup_midnight_shutdown: split into lib/ — the cap is now zero

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3PzGc75gBcdxP3PkHXGcV
This commit is contained in:
Krzysztof Rudnicki 2026-08-21 16:27:54 +02:00
parent ef12e9c6ad
commit d72f03ea93
12 changed files with 1779 additions and 1644 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <line-number-from-list>" >&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 '<start datetime>' '<end datetime>' <reason>" >&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 '<start>' '<end>' <reason> Register a time-boxed exception"
echo " list Show active/upcoming overrides"
echo " remove <index> 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"
}

View File

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

View File

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

View File

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

View File

@ -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 <<EOF
# Shutdown schedule configuration
# This file is managed by setup_midnight_shutdown.sh
# Used by: day-specific-shutdown-check.sh, shutdown_countdown.sh (i3blocks)
#
# WARNING: This file is protected by guard-lib (guardctl): immutable
# attribute, a canonical copy, and a path watcher that auto-restores it
# if modified outside the sanctioned unlock flow.
# Shutdown hour for Monday-Wednesday (24-hour format)
MON_WED_HOUR=${SCHEDULE_MON_WED_HOUR}
# Shutdown hour for Thursday-Sunday (24-hour format)
THU_SUN_HOUR=${SCHEDULE_THU_SUN_HOUR}
# Morning end hour (shutdown window ends at this hour)
MORNING_END_HOUR=${SCHEDULE_MORNING_END_HOUR}
EOF
)"
if guardctl file-guard status "$GUARD_NAME" >/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 <<EOF
[Unit]
Description=Timer for automatic PC shutdown with day-specific windows
Requires=day-specific-shutdown.service
[Timer]
EOF
# Evening hours: from earliest shutdown hour to 23:30
for hour in $(seq "$earliest_hour" 23); do
printf 'OnCalendar=*-*-* %02d:00:00\n' "$hour"
printf 'OnCalendar=*-*-* %02d:30:00\n' "$hour"
done
# Morning hours: from 00:00 to MORNING_END_HOUR
for hour in $(seq 0 "$SCHEDULE_MORNING_END_HOUR"); do
printf 'OnCalendar=*-*-* %02d:00:00\n' "$hour"
if [[ $hour -lt $SCHEDULE_MORNING_END_HOUR ]]; then
printf 'OnCalendar=*-*-* %02d:30:00\n' "$hour"
fi
done
cat <<EOF
Persistent=false
AccuracySec=1s
WakeSystem=false
RandomizedDelaySec=0
[Install]
WantedBy=timers.target
EOF
} >"$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"
}

View File

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