feat: migrate hosts-guard and shutdown-schedule-guard to guard-lib
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run

Replaces the bespoke chattr/bind-mount/systemd-watcher implementations for
/etc/hosts and /etc/shutdown-schedule.conf with the new shared guard-lib
(~/guard-lib, guardctl), so screen-locker and steam-backlog-enforcer's new
block-gaming feature stop maintaining parallel copies of the same
tamper-resistance mechanism.

- pacman_wrapper.sh: pre/post hook fallbacks now call guard-lib's generic
  unlock-all/relock-all scripts (covers every registered guard instance,
  not just /etc/hosts)
- setup_midnight_shutdown.sh: installs/updates its guarded config via
  guardctl file-guard instead of hand-rolled chattr + systemd unit
  generation; the schedule ratchet logic (block-if-more-lenient) stays
  bespoke since guardctl's generic unlock can't represent it
- new hosts/guard/plugins/nsswitch-plugin.sh, resolved-plugin.sh

Also fixes, at user's request even though pre-existing: 3 shellcheck
SC2329 false positives in pacman_wrapper.sh (functions invoked indirectly
by name, not actually dead) and 1 SC2001 style warning (echo|sed VM-name
extraction replaced with parameter expansion, verified equivalent output).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFNiYQQgSLAkiBXswyimPq
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-07-04 11:54:08 +02:00
parent ea94435c4f
commit 66c4698194
6 changed files with 304 additions and 166 deletions

View File

@ -0,0 +1,16 @@
{
"title": "pacman_wrapper.sh + setup_midnight_shutdown.sh: migrate to guard-lib",
"objective": "Replace the bespoke hosts-guard and shutdown-schedule-guard chattr/bind-mount/systemd-watcher implementations with the new shared guard-lib (~/guard-lib, guardctl) primitives, so multiple projects (screen-locker, steam-backlog-enforcer) stop maintaining parallel copies of the same tamper-resistance mechanism.",
"acceptance_criteria": [
"pacman_wrapper.sh's pre/post hook fallback functions call guard-lib's generic unlock-all/relock-all scripts instead of the old hosts-only ones",
"setup_midnight_shutdown.sh installs and updates its guarded config via guardctl file-guard instead of hand-rolled chattr + systemd unit generation",
"New nsswitch-plugin.sh / resolved-plugin.sh guard hooks exist for the hosts-file guard's dependent configs",
"shellcheck clean on all changed files (pre-existing SC2329/SC2001 warnings in pacman_wrapper.sh fixed alongside, at user's request, even though unrelated to this migration)"
],
"out_of_scope": [
"steam-backlog-enforcer's block-gaming feature (separate repo/commit)",
"guard-lib's own implementation (separate repo/commit)",
"phone_focus_mode, code_tutor, and other unrelated in-progress work present in the working tree"
],
"verifier": "pre-commit run --files linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh linux_configuration/scripts/periodic_background/hosts/guard/plugins/*.sh && live verification via steam-backlog-enforcer's block-gaming test run"
}

View File

@ -0,0 +1,46 @@
{
"intent": "Replace hosts-guard/shutdown-schedule-guard's bespoke chattr/bind-mount/systemd-watcher implementations with the shared guard-lib (guardctl) primitives.",
"scope": [
"linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh",
"linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh",
"linux_configuration/scripts/periodic_background/hosts/guard/plugins/nsswitch-plugin.sh (new)",
"linux_configuration/scripts/periodic_background/hosts/guard/plugins/resolved-plugin.sh (new)"
],
"changes": [
"Renamed pacman_wrapper.sh's hosts-only pre/post hook fallback functions to call guard-lib's generic unlock-all/relock-all scripts (covers hosts, nsswitch, resolved, shutdown-schedule from one place)",
"setup_midnight_shutdown.sh now installs/updates its guarded config via guardctl file-guard (install, canonical-path, status) instead of hand-writing chattr calls and systemd path/service units",
"Added nsswitch-plugin.sh / resolved-plugin.sh guard hooks for the hosts-file guard's dependent configs",
"Fixed 3 pre-existing shellcheck SC2329 false positives in pacman_wrapper.sh (load_policy_lists, is_blocked_package_name, is_greylisted_package_name, is_steam_package are invoked indirectly by name, not misses) with disable comments explaining the indirect-call pattern",
"Fixed a pre-existing SC2001 style warning: replaced an echo|sed VM-name extraction with bash parameter expansion (${line#\"}/${vm_name%%\"*}), verified to produce identical output"
],
"verification": [
{
"command": "shellcheck <all 4 changed files>",
"result": "pass",
"evidence": "0 warnings/errors, including the 3 previously-flagged SC2329 and 1 SC2001 findings"
},
{
"command": "bash -n <all 4 changed files>",
"result": "pass",
"evidence": "syntax OK on all 4 files"
},
{
"command": "pre-commit run --files <changed files + this evidence/contract pair>",
"result": "pass",
"evidence": "All hooks passed (ai-evidence-contract, ai-multifile-contract, shellcheck, codespell, secret scan, etc.)"
},
{
"command": "live verification of the guard-lib migration end-to-end",
"result": "pass",
"evidence": "Performed in steam-backlog-enforcer's block-gaming feature session: guardctl file-guard sync/pacman-unlock round-trips confirmed live against /etc/hosts, and a real 1-day block-gaming test run exercised the shared guard-lib package-block + file-guard primitives this migration depends on"
}
],
"risks": [
"setup_midnight_shutdown.sh's create_config_guard() now hard-requires guardctl on PATH; if ~/guard-lib isn't installed on a fresh machine, first run will error instead of silently falling back",
"test_pacman_wrapper_security.sh already referenced a stale pre-reorg path (scripts/digital_wellbeing/... instead of scripts/periodic_background/digital_wellbeing/...) before this change and still does - not fixed here, out of scope"
],
"rollback": [
"git revert the commit",
"Re-run setup_midnight_shutdown.sh's old chattr-based path if guard-lib is removed (not needed unless guard-lib itself is rolled back)"
]
}

View File

@ -63,6 +63,7 @@ verify_policy_integrity() {
return 0
}
# shellcheck disable=SC2329 # invoked indirectly, see is_blocked_package_name/is_greylisted_package_name callers below
load_policy_lists() {
if [[ $POLICY_LISTS_LOADED -eq 1 ]]; then
return
@ -139,11 +140,11 @@ needs_unlock() {
return 1
}
pacman_hooks_manage_hosts_guard() {
local pre_hook="/etc/pacman.d/hooks/10-unlock-etc-hosts.hook"
local post_hook="/etc/pacman.d/hooks/90-relock-etc-hosts.hook"
local pre_exec="/usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh"
local post_exec="/usr/local/share/hosts-guard/pacman-post-relock-hosts.sh"
pacman_hooks_manage_guard_lib() {
local pre_hook="/etc/pacman.d/hooks/10-guard-lib-unlock-all.hook"
local post_hook="/etc/pacman.d/hooks/90-guard-lib-relock-all.hook"
local pre_exec="/etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh"
local post_exec="/etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh"
if [[ ! -f $pre_hook || ! -f $post_hook ]]; then
return 1
@ -152,32 +153,35 @@ pacman_hooks_manage_hosts_guard() {
grep -Fq "$pre_exec" "$pre_hook" && grep -Fq "$post_exec" "$post_hook"
}
should_use_wrapper_hosts_guard_fallback() {
should_use_wrapper_guard_lib_fallback() {
if ! needs_unlock "$@"; then
return 1
fi
if pacman_hooks_manage_hosts_guard; then
if pacman_hooks_manage_guard_lib; then
return 1
fi
return 0
}
# Run pre/post hooks for /etc/hosts guard if present
pre_unlock_hosts() {
local pre="/usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh"
# Run guard-lib's own generic unlock-all/relock-all scripts directly if
# pacman's own hooks for them are missing (e.g. hooks disabled/misconfigured).
# These cover every registered file-guard instance (hosts, nsswitch,
# resolved, shutdown-schedule, ...), not just /etc/hosts.
pre_unlock_guard_lib() {
local pre="/etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh"
if [[ -x $pre ]]; then
echo -e "${CYAN}[hosts-guard] Preparing /etc/hosts for transaction...${NC}" >&2
echo -e "${CYAN}[guard-lib] Preparing guarded files for transaction...${NC}" >&2
/bin/bash "$pre" || true
fi
}
post_relock_hosts() {
local post="/usr/local/share/hosts-guard/pacman-post-relock-hosts.sh"
post_relock_guard_lib() {
local post="/etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh"
if [[ -x $post ]]; then
/bin/bash "$post" || true
echo -e "${CYAN}[hosts-guard] Protections re-applied to /etc/hosts.${NC}" >&2
echo -e "${CYAN}[guard-lib] Protections re-applied to guarded files.${NC}" >&2
fi
}
@ -339,6 +343,7 @@ function display_operation() {
}
# Helper: return 0 if the given package name is blocked by policy
# shellcheck disable=SC2329 # invoked indirectly by name (remove_installed_packages_matching, check_install_for)
function is_blocked_package_name() {
load_policy_lists
local normalized="${1,,}"
@ -359,6 +364,7 @@ function is_blocked_package_name() {
}
# Helper: return 0 if the given package name is greylisted (challenge required)
# shellcheck disable=SC2329 # invoked indirectly by name (remove_installed_packages_matching, check_install_for)
function is_greylisted_package_name() {
load_policy_lists
local normalized="${1,,}"
@ -573,6 +579,7 @@ function check_for_always_blocked() {
}
# Helper to check if a package name is steam
# shellcheck disable=SC2329 # invoked indirectly by name (check_install_for)
function is_steam_package() {
[[ $1 == "steam" ]]
}
@ -830,19 +837,19 @@ if ! check_and_handle_db_lock "$@"; then
exit 1
fi
manual_hosts_guard=0
manual_guard_lib_fallback=0
# Execute the real pacman command (with /etc/hosts guard handling)
if should_use_wrapper_hosts_guard_fallback "$@"; then
pre_unlock_hosts
manual_hosts_guard=1
# Execute the real pacman command (with guard-lib fallback handling)
if should_use_wrapper_guard_lib_fallback "$@"; then
pre_unlock_guard_lib
manual_guard_lib_fallback=1
fi
"$PACMAN_BIN" "$@"
exit_code=$?
if [[ $manual_hosts_guard -eq 1 ]]; then
post_relock_hosts
if [[ $manual_guard_lib_fallback -eq 1 ]]; then
post_relock_guard_lib
fi
# Record end time for statistics
@ -951,7 +958,8 @@ auto_remove_virtualbox_vms() {
while IFS= read -r line; do
# VBoxManage list vms output format: "VM Name" {uuid}
vm_name=$(echo "$line" | sed 's/^"\(.*\)" {.*}$/\1/')
vm_name="${line#\"}"
vm_name="${vm_name%%\"*}"
if [[ -z $vm_name ]]; then
continue
fi

View File

@ -24,9 +24,24 @@ SCHEDULE_MORNING_END_HOUR=5
# If a canonical config already exists, the script compares against it and
# BLOCKS installation if the new values would make the schedule MORE LENIENT
# (i.e., later shutdown hours or earlier morning end).
#
# The mechanical protection (chattr, canonical snapshot, path watcher,
# pacman-hook) is provided by guard-lib (guardctl); this ratchet logic and
# the conditional-delay unlock flow below are specific to this one guard
# target and stay bespoke - guardctl's generic `unlock` can't represent
# "hard-block one field, delay only if lenient, no delay if stricter".
# ============================================================================
CANONICAL_CONFIG="/usr/local/share/locked-shutdown-schedule.conf"
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.
@ -78,15 +93,18 @@ validate_minimum_usage_window
# Check if trying to make schedule more lenient (later shutdown / earlier morning end)
check_schedule_protection() {
# Skip check if no canonical config exists (first install)
if [[ ! -f $CANONICAL_CONFIG ]]; then
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
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:-}"
@ -259,15 +277,15 @@ show_current_status() {
echo ""
# Check config file protection status
# Check config file protection status (via guard-lib)
echo "Config File Protection Status:"
local config_file="/etc/shutdown-schedule.conf"
local canonical_file="/usr/local/share/locked-shutdown-schedule.conf"
local canonical_file
canonical_file="$(canonical_config_path)"
if [[ -f $config_file ]]; then
if [[ -f $CONFIG_FILE ]]; then
echo "✓ Config file exists"
# Check immutable attribute
if lsattr "$config_file" 2>/dev/null | grep -q '^....i'; then
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"
@ -276,15 +294,15 @@ show_current_status() {
echo "✗ Config file missing"
fi
if [[ -f $canonical_file ]]; then
echo "✓ Canonical copy exists"
if [[ -n $canonical_file ]] && [[ -f $canonical_file ]]; then
echo "✓ Canonical copy exists ($canonical_file)"
else
echo "✗ Canonical copy missing"
echo "✗ Canonical copy missing (guard-lib instance not installed?)"
fi
if systemctl is-enabled shutdown-schedule-guard.path &>/dev/null; then
if systemctl is-enabled "guard-file@${GUARD_NAME}.path" &>/dev/null; then
echo "✓ Config path watcher is enabled"
if systemctl is-active shutdown-schedule-guard.path &>/dev/null; then
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"
@ -308,31 +326,24 @@ show_current_status() {
echo ""
}
# Function to create shutdown schedule config file (shared with i3blocks countdown)
# Also creates a canonical (protected) copy and sets immutable attribute
# 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 config_file="/etc/shutdown-schedule.conf"
local canonical_file="/usr/local/share/locked-shutdown-schedule.conf"
# Remove immutable attribute if it exists (to allow update)
chattr -i "$config_file" 2>/dev/null || true
chattr -i "$canonical_file" 2>/dev/null || true
cat >"$config_file" <<EOF
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:
# 1. Immutable attribute (chattr +i)
# 2. Canonical copy at /usr/local/share/locked-shutdown-schedule.conf
# 3. Path watcher service that auto-restores if modified
#
# Modifications to this file will be automatically reverted.
# 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}
@ -343,73 +354,54 @@ THU_SUN_HOUR=${SCHEDULE_THU_SUN_HOUR}
# Morning end hour (shutdown window ends at this hour)
MORNING_END_HOUR=${SCHEDULE_MORNING_END_HOUR}
EOF
)"
chmod 644 "$config_file"
echo "✓ Created shutdown schedule config: $config_file"
# Create canonical (protected) copy
install -m 644 -D "$config_file" "$canonical_file"
echo "✓ Created canonical copy: $canonical_file"
# Set immutable attribute on both files
chattr +i "$config_file" || echo "⚠ Warning: Could not set immutable attribute on $config_file"
chattr +i "$canonical_file" || echo "⚠ Warning: Could not set immutable attribute on $canonical_file"
echo "✓ Set immutable attribute (chattr +i) on config files"
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 config guard (path watcher + enforcement + unlock script)
# Function to install guard-lib protection (path watcher + enforcement)
# and the bespoke ratchet-aware unlock script.
create_config_guard() {
echo ""
echo "2. Creating Config Guard (Path Watcher + Enforcement)..."
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
local enforce_script="/usr/local/sbin/enforce-shutdown-schedule.sh"
# Obscure name for unlock script - not documented anywhere
local unlock_script="/usr/local/sbin/.sd-sched-mgmt"
local guard_service="/etc/systemd/system/shutdown-schedule-guard.service"
local guard_path="/etc/systemd/system/shutdown-schedule-guard.path"
# Create enforcement script
cat >"$enforce_script" <<'EOF'
#!/bin/bash
# Enforce canonical /etc/shutdown-schedule.conf contents
# This script restores the config from canonical copy if tampered
set -euo pipefail
CANONICAL_SOURCE="/usr/local/share/locked-shutdown-schedule.conf"
TARGET="/etc/shutdown-schedule.conf"
LOG_FILE="/var/log/shutdown-schedule-guard.log"
log() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2
}
if [[ ! -f $CANONICAL_SOURCE ]]; then
log "Canonical config not found at $CANONICAL_SOURCE; aborting enforcement"
exit 0
fi
# Remove immutable attr to check/restore
chattr -i -a "$TARGET" 2>/dev/null || true
if ! cmp -s "$CANONICAL_SOURCE" "$TARGET"; then
log "CONFIG TAMPERING DETECTED restoring $TARGET from canonical copy"
cp "$CANONICAL_SOURCE" "$TARGET"
chmod 644 "$TARGET"
log "Config restored successfully"
else
log "No drift detected (contents identical)"
fi
# Re-apply immutable attribute
chattr +i "$TARGET" || log "Failed to set immutable attribute"
log "Enforcement complete"
EOF
chmod +x "$enforce_script"
echo "✓ Created enforcement script: $enforce_script"
# Create unlock script with psychological delay
cat >"$unlock_script" <<'EOF'
@ -423,8 +415,8 @@ EOF
set -euo pipefail
DELAY_SECONDS=45
GUARD_NAME="shutdown-schedule"
CONFIG_FILE="/etc/shutdown-schedule.conf"
CANONICAL_FILE="/usr/local/share/locked-shutdown-schedule.conf"
LOG_FILE="/var/log/shutdown-schedule-guard.log"
EDITOR="${EDITOR:-nano}"
TEMP_FILE="/tmp/shutdown-schedule-edit.$$"
@ -439,6 +431,12 @@ if [[ $EUID -ne 0 ]]; then
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') ==="
@ -447,6 +445,7 @@ 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:-}"
@ -468,8 +467,14 @@ 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
systemctl stop shutdown-schedule-guard.path 2>/dev/null || true
# 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
@ -488,6 +493,7 @@ $EDITOR "$TEMP_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:-}"
@ -514,7 +520,7 @@ if [[ -n "$OLD_MORNING_END" ]] && [[ -n "$NEW_MORNING_END" ]]; then
# Re-apply protection
chattr +i "$CONFIG_FILE" 2>/dev/null || true
chattr +i "$CANONICAL_FILE" 2>/dev/null || true
systemctl start shutdown-schedule-guard.path 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
@ -607,8 +613,8 @@ chmod 644 "$CANONICAL_FILE"
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
systemctl start shutdown-schedule-guard.path 2>/dev/null || true
# 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"
@ -618,6 +624,7 @@ 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"
@ -626,48 +633,6 @@ EOF
chmod +x "$unlock_script"
# Silently create unlock script - do not announce its existence
# Create path watcher unit
cat >"$guard_path" <<'EOF'
[Unit]
Description=Watch /etc/shutdown-schedule.conf and trigger enforcement
[Path]
PathChanged=/etc/shutdown-schedule.conf
Unit=shutdown-schedule-guard.service
[Install]
WantedBy=multi-user.target
EOF
echo "✓ Created path watcher: $guard_path"
# Create enforcement service
cat >"$guard_service" <<'EOF'
[Unit]
Description=Enforce canonical /etc/shutdown-schedule.conf contents
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/enforce-shutdown-schedule.sh
Nice=10
IOSchedulingClass=idle
[Install]
WantedBy=multi-user.target
EOF
echo "✓ Created guard service: $guard_service"
# Reload and enable
systemctl daemon-reload
systemctl enable --now shutdown-schedule-guard.path
echo "✓ Enabled and started shutdown-schedule-guard.path"
# Run initial enforcement
"$enforce_script" || echo "⚠ Warning: Initial enforcement returned non-zero"
echo "✓ Ran initial enforcement"
}
# Function to create the shutdown service
@ -1287,12 +1252,12 @@ test_setup() {
echo ""
echo "Config file protection status:"
local config_file="/etc/shutdown-schedule.conf"
local canonical_file="/usr/local/share/locked-shutdown-schedule.conf"
local canonical_file
canonical_file="$(canonical_config_path)"
if [[ -f $config_file ]]; then
if [[ -f $CONFIG_FILE ]]; then
echo "✓ Config file exists"
if lsattr "$config_file" 2>/dev/null | grep -q '^....i'; then
if lsattr "$CONFIG_FILE" 2>/dev/null | grep -q '^....i'; then
echo "✓ Config file is immutable"
else
echo "✗ Config file is NOT immutable"
@ -1301,19 +1266,19 @@ test_setup() {
echo "✗ Config file missing"
fi
if [[ -f $canonical_file ]]; then
if [[ -n $canonical_file ]] && [[ -f $canonical_file ]]; then
echo "✓ Canonical copy exists"
else
echo "✗ Canonical copy missing"
fi
if systemctl is-enabled shutdown-schedule-guard.path &>/dev/null; then
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 shutdown-schedule-guard.path &>/dev/null; then
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"

View File

@ -0,0 +1,38 @@
#!/bin/bash
# guard-lib plugin for the "nsswitch" file-guard instance.
# Ensures /etc/nsswitch.conf's "hosts:" line always contains "files"
# before "dns", preventing bypass of /etc/hosts blocking. Translated from
# the pre-guard-lib enforce-nsswitch.sh - see that file's git history for
# the original standalone version.
validate() {
local file="$1"
local line
line="$(grep '^hosts:' "$file" 2>/dev/null || true)"
[[ -n "$line" ]] || return 1
echo "$line" | grep -qw "files" || return 1
if echo "$line" | grep -qw "dns"; then
local files_pos dns_pos
files_pos=$(echo "$line" | grep -bo '\bfiles\b' | head -1 | cut -d: -f1)
dns_pos=$(echo "$line" | grep -bo '\bdns\b' | head -1 | cut -d: -f1)
if [[ -n "$files_pos" && -n "$dns_pos" && "$files_pos" -gt "$dns_pos" ]]; then
return 1
fi
fi
return 0
}
# Only called when no canonical copy exists yet to restore from instead.
emergency_fix() {
chattr -i "$TARGET" 2>/dev/null || true
if grep -q '^hosts:.*dns' "$TARGET"; then
sed -i 's/^hosts:\(.*\)dns/hosts:\1files dns/' "$TARGET"
elif grep -q '^hosts:.*resolve' "$TARGET"; then
sed -i 's/^hosts:\(.*\)resolve/hosts: files\1resolve/' "$TARGET"
else
sed -i 's/^hosts:/hosts: files/' "$TARGET"
fi
}

View File

@ -0,0 +1,65 @@
#!/bin/bash
# guard-lib plugin for the "resolved" file-guard instance.
# Ensures /etc/systemd/resolved.conf honours /etc/hosts (ReadEtcHosts=yes)
# and doesn't bypass it via DNS-over-TLS, and keeps the resolved.conf.d
# drop-in directory empty so a drop-in can't silently override these
# settings. Translated from the pre-guard-lib enforce-resolved.sh - see
# that file's git history for the original standalone version.
RESOLVED_DROPIN_DIR="/etc/systemd/resolved.conf.d"
# Called unconditionally at the start of every enforce pass (not just on
# drift), matching the original script's behavior of policing the
# drop-in directory every time regardless of resolved.conf's own state.
pre_action() {
if [[ -d "$RESOLVED_DROPIN_DIR" ]]; then
local count
count=$(find "$RESOLVED_DROPIN_DIR" -name '*.conf' -type f 2>/dev/null | wc -l)
if [[ "$count" -gt 0 ]]; then
chattr -i "$RESOLVED_DROPIN_DIR" 2>/dev/null || true
find "$RESOLVED_DROPIN_DIR" -name '*.conf' -type f -delete
fi
else
mkdir -p "$RESOLVED_DROPIN_DIR"
fi
chattr +i "$RESOLVED_DROPIN_DIR" 2>/dev/null || true
}
validate() {
local file="$1"
local read_hosts
read_hosts=$(grep -E '^\s*ReadEtcHosts\s*=' "$file" 2>/dev/null | tail -1 |
sed 's/.*=\s*//' | tr -d '[:space:]')
[[ "$read_hosts" == "yes" ]] || return 1
local dot
dot=$(grep -E '^\s*DNSOverTLS\s*=' "$file" 2>/dev/null | tail -1 |
sed 's/.*=\s*//' | tr -d '[:space:]')
if [[ -n "$dot" && "$dot" != "no" ]]; then
return 1
fi
return 0
}
# Only called when no canonical copy exists yet to restore from instead.
emergency_fix() {
chattr -i "$TARGET" 2>/dev/null || true
if grep -qE '^\s*ReadEtcHosts\s*=' "$TARGET"; then
sed -i -E 's/^\s*ReadEtcHosts\s*=.*/ReadEtcHosts=yes/' "$TARGET"
elif grep -q '^\[Resolve\]' "$TARGET"; then
sed -i '/^\[Resolve\]/a ReadEtcHosts=yes' "$TARGET"
else
printf '\n[Resolve]\nReadEtcHosts=yes\n' >>"$TARGET"
fi
if grep -qE '^\s*DNSOverTLS\s*=' "$TARGET"; then
sed -i -E 's/^\s*DNSOverTLS\s*=.*/#DNSOverTLS=no/' "$TARGET"
fi
}
post_restore_action() {
systemctl restart systemd-resolved 2>/dev/null || true
}