diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8d71c7b..a167be1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -41,3 +41,23 @@ This repo automates Linux desktop bootstrap, hardening, and i3 setup. It’s pri - Add new periodic behaviors as templates under `scripts/system-maintenance/bin` and `.../systemd`, then extend `setup_periodic_system.sh` to install/enable them. - Extend package policy by updating `scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt` or by adding `check_for_` + `prompt_for__challenge` blocks in the wrapper. - Run `scripts/meta/shell_check.sh` to detect things to fix before committing. +## Detailed LLM Documentation + +For in-depth understanding of specific components, see these dedicated guides: + +- **Hosts Guard**: [hosts/guard/README_FOR_LLM.md](../hosts/guard/README_FOR_LLM.md) - Protection layers, canonical copies, path watchers +- **Pacman Wrapper**: [scripts/digital_wellbeing/pacman/README_FOR_LLM.md](../scripts/digital_wellbeing/pacman/README_FOR_LLM.md) - Policy files, integrity checks, challenges +- **Midnight Shutdown**: [scripts/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md](../scripts/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md) - Schedule protection, timer system +- **Compulsive Block**: [scripts/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md](../scripts/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md) - App launch limiting +- **Security Analysis**: [docs/SECURITY_HARDENING_ANALYSIS.md](../docs/SECURITY_HARDENING_ANALYSIS.md) - Vulnerabilities and implementation roadmap + +## Digital Wellbeing Components Summary + +| Component | Purpose | Key Files | +|-----------|---------|-----------| +| Hosts Guard | Block websites via /etc/hosts | `hosts/install.sh`, `hosts/guard/*` | +| Pacman Wrapper | Block package installation | `scripts/digital_wellbeing/pacman/*` | +| Midnight Shutdown | Auto-shutdown at night | `scripts/digital_wellbeing/setup_midnight_shutdown.sh` | +| Compulsive Block | Limit app launches | `scripts/digital_wellbeing/block_compulsive_opening.sh` | +| Music Wrapper | Block music during focus | `scripts/digital_wellbeing/youtube-music-wrapper.sh` | +| Screen Locker | Require workout to unlock | External: `~/testsAndMisc/python_pkg/screen_locker/` | \ No newline at end of file diff --git a/docs/SECURITY_HARDENING_ANALYSIS.md b/docs/SECURITY_HARDENING_ANALYSIS.md new file mode 100644 index 0000000..e4be8b5 --- /dev/null +++ b/docs/SECURITY_HARDENING_ANALYSIS.md @@ -0,0 +1,696 @@ +# Security Hardening Analysis & Implementation Prompt + +## Executive Summary + +This document analyzes six digital wellbeing/security scripts and provides a detailed implementation prompt for hardening them against tampering. The analysis is based on thorough code review of the entire codebase. + +--- + +## Part 1: Current State Analysis + +### 1. `/etc/hosts` Protection System + +**Files involved:** +- [hosts/install.sh](../hosts/install.sh) - Main hosts installer +- [hosts/guard/setup_hosts_guard.sh](../hosts/guard/setup_hosts_guard.sh) - Guard layer setup +- [hosts/guard/enforce-hosts.sh](../hosts/guard/enforce-hosts.sh) - Enforcement script +- [hosts/guard/psychological/unlock-hosts.sh](../hosts/guard/psychological/unlock-hosts.sh) - Delayed unlock + +**Current Protection Layers:** +1. ✅ Immutable attribute (`chattr +i`) +2. ✅ Canonical copy at `/usr/local/share/locked-hosts` +3. ✅ Path watcher (`hosts-guard.path`) auto-restores on modification +4. ✅ Read-only bind mount (`hosts-bind-mount.service`) +5. ✅ Custom entries protection (blocks removal of blocked domains) +6. ✅ Shell history suppression for `unlock-hosts` command + +**CRITICAL VULNERABILITY IDENTIFIED:** +- ❌ **NO protection for `/etc/nsswitch.conf`** - A user can simply edit nsswitch.conf and remove `files` from the `hosts:` line, completely bypassing ALL /etc/hosts protections without touching the hosts file itself! + +**Example bypass:** +```bash +# Original: hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns +# Tampered: hosts: mymachines resolve [!UNAVAIL=return] myhostname dns +# Result: /etc/hosts is completely ignored by the system +``` + +--- + +### 2. Midnight Shutdown System + +**Files involved:** +- [scripts/digital_wellbeing/setup_midnight_shutdown.sh](../scripts/digital_wellbeing/setup_midnight_shutdown.sh) (1359 lines) + +**Current Protection Layers:** +1. ✅ Immutable attribute on `/etc/shutdown-schedule.conf` +2. ✅ Canonical copy at `/usr/local/share/locked-shutdown-schedule.conf` +3. ✅ Path watcher restores config if tampered +4. ✅ Schedule protection blocks making schedule more lenient +5. ✅ Unlock script with psychological delay + +**VULNERABILITIES IDENTIFIED:** +- ❌ The unlock script **explicitly tells users how to bypass**: "sudo /usr/local/sbin/unlock-shutdown-schedule" +- ❌ The schedule change logic is communicated in the error message +- ❌ No protection against stopping/disabling the timer services +- ❌ No protection against modifying the check script at `/usr/local/bin/day-specific-shutdown-check.sh` + +--- + +### 3. Screen Locker (Python - External Repo) + +**File:** `/home/kuhy/testsAndMisc/python_pkg/screen_locker/screen_lock.py` + +**Current Workout Types:** +1. Running - distance, time, pace validation +2. Strength - exercises, sets, reps, weights, total calculation +3. Table Tennis - duration, sets, points won/lost + +**VULNERABILITIES IDENTIFIED:** +- ❌ **Running option too easy to fake** - just enter plausible numbers +- ❌ **Table Tennis lacks real verification** - no mathematical cross-check +- ❌ Users can close the window via keyboard shortcuts (Alt+F4, etc.) +- ❌ The unlock mechanism is too simple once you know the forms +- ❌ Shutdown time adjustment is a REWARD for working out (can be exploited) + +--- + +### 4. Pacman Wrapper + +**Files involved:** +- [scripts/digital_wellbeing/pacman/pacman_wrapper.sh](../scripts/digital_wellbeing/pacman/pacman_wrapper.sh) (823 lines) +- [scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt](../scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt) +- [scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh](../scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh) + +**Current Protection:** +1. ✅ Policy file integrity verification (SHA256) +2. ✅ Blocked keywords list +3. ✅ Greylist with challenge +4. ✅ VirtualBox hardcoded check (cannot bypass via policy files) +5. ✅ Steam weekend-only restriction + +**VULNERABILITIES IDENTIFIED:** +- ❌ **Google Chrome not blocked** - `google-chrome` and `google-chrome-stable` missing from blocked list +- ❌ No automatic LeechBlock installation when browsers are detected +- ❌ User can download `.deb`/`.tar.gz` and install manually + +--- + +### 5. Block Compulsive Opening + +**File:** [scripts/digital_wellbeing/block_compulsive_opening.sh](../scripts/digital_wellbeing/block_compulsive_opening.sh) (507 lines) + +**Current Behavior:** +- Records first open per hour in state file +- Blocks subsequent launches within same hour +- Shows notification when blocked + +**CRITICAL VULNERABILITY:** +- ❌ **App stays running indefinitely** - User can: + 1. Open app once per hour (allowed) + 2. Minimize/hide the window + 3. Keep it running forever in background + 4. Compulsive checking still happens, just via Alt+Tab instead of launcher + +--- + +### 6. YouTube Music Wrapper + +**File:** [scripts/digital_wellbeing/youtube-music-wrapper.sh](../scripts/digital_wellbeing/youtube-music-wrapper.sh) + +**Current Behavior:** +- Checks if focus apps (VSCode, games, etc.) are running +- Blocks YouTube Music launch if focus app detected + +**REQUESTED ENHANCEMENT:** +- When Steam is open → Block ALL browsers, close any open browsers +- When browsers open → Block Steam, close Steam if running +- This creates mutual exclusion between gaming and browsing + +--- + +## Part 2: Language Considerations + +### Shell (Bash) Limitations + +**Pros:** +- Native to the system, no dependencies +- Direct access to systemd, chattr, filesystem +- Fast for simple operations + +**Cons:** +- No persistent daemon capability (need systemd for that) +- Race conditions in file operations +- Complex state management is fragile +- No proper event loop for window monitoring +- Cannot easily monitor process list in real-time + +### Python Advantages for Certain Tasks + +**Where Python would be better:** +1. **Process monitoring daemon** - Watch for Steam/browsers in real-time with proper event loop +2. **Window management** - Using `python-xlib` for proper X11 interaction +3. **Complex state machines** - Like the screen locker +4. **Cross-repo integration** - The screen_lock.py already shows good patterns + +### Recommendation + +| Component | Keep Bash | Move to Python | Reason | +|-----------|-----------|----------------|--------| +| hosts guard | ✅ | | Simple file ops, systemd integration | +| shutdown schedule | ✅ | | Systemd timers, config files | +| screen locker | | ✅ Already | Complex UI, state machine | +| pacman wrapper | ✅ | | Must intercept pacman | +| compulsive block | | ✅ | Needs daemon for auto-close | +| music wrapper | | ✅ | Needs real-time process monitoring | + +**New Python Daemon Needed:** A single "digital wellbeing daemon" that: +1. Monitors running processes +2. Auto-closes apps after timeout +3. Enforces Steam/browser mutual exclusion +4. Can be controlled via DBus + +--- + +## Part 3: Implementation Prompt + +**Use this prompt in a new conversation to implement the changes:** + +--- + +### IMPLEMENTATION PROMPT + +``` +I need to implement comprehensive security hardening for a Linux digital wellbeing system. +The codebase is at ~/linux-configuration/ with these components needing changes: + +## 1. HOSTS PROTECTION - nsswitch.conf Guard + +Location: hosts/guard/ + +Create a new protection layer for /etc/nsswitch.conf that: +- Monitors nsswitch.conf for changes (systemd path watcher) +- Ensures the "hosts:" line ALWAYS contains "files" before "dns" +- Creates canonical copy at /usr/local/share/locked-nsswitch.conf +- Enforces with chattr +i +- Add to setup_hosts_guard.sh installer +- Must restore automatically if tampered + +The nsswitch.conf protection is CRITICAL because removing "files" from the +hosts line completely bypasses /etc/hosts without touching it. + +## 2. MIDNIGHT SHUTDOWN - Silent Denial + +Location: scripts/digital_wellbeing/setup_midnight_shutdown.sh + +Changes needed: +- Remove ALL helpful messages about how to bypass (unlock-shutdown-schedule path) +- When user tries to make schedule more lenient: + - Simply say "Operation not permitted" with NO explanation + - Do NOT mention the unlock script + - Do NOT explain what's being blocked + - Silently restore canonical values +- The unlock script should still exist but be undiscoverable +- Consider renaming unlock script to an obscure name +- Remove the unlock script path from any logs + +## 3. SCREEN LOCKER - External Repo + +Location: ~/testsAndMisc/python_pkg/screen_locker/screen_lock.py + +Changes needed: +- REMOVE the "Running" workout option entirely (too easy to fake) +- For "Table Tennis": + - Require minimum 15 sets played + - Add verification: total_points = points_won + points_lost + - Require that total_points >= sets_played * 11 (minimum points per set) + - Add random math verification question about the scores + - Increase submit delay to 60 seconds +- For "Strength": + - Already has good verification, keep as-is +- Add input focus grabbing to prevent Alt+Tab escape +- Disable window close keyboard shortcuts + +## 4. PACMAN WRAPPER - Chrome Block + LeechBlock Auto-Install + +Location: scripts/digital_wellbeing/pacman/ + +Changes needed to pacman_blocked_keywords.txt: +- Add: google-chrome +- Add: google-chrome-stable +- Add: chromium +- Add: ungoogled-chromium + +New behavior in pacman_wrapper.sh: +- After ANY browser is detected installed (via pacman -Qq check): + - Automatically run install_leechblock.sh if it exists + - LeechBlock installer should: + - Detect browser type + - Install extension with pre-configured blocking rules + - Use firefox-addon-install method or chrome native messaging +- If LeechBlock installation fails, BLOCK the browser binary (wrap it) + +## 5. BLOCK COMPULSIVE OPENING - Auto-Close Timer + +Location: scripts/digital_wellbeing/block_compulsive_opening.sh + +New behavior: +- After app is allowed to open, start a background timer +- After 10 minutes, forcefully close the app (pkill) +- Show warning notification at 8 minutes ("Closing in 2 minutes") +- The wrapper should spawn a detached monitoring process +- State tracking: record PID and launch time +- Check for zombie PIDs and clean up state + +Implementation approach: +```bash +# After exec line in wrapper_main, instead of direct exec: +launch_with_timer() { + local app="$1" + local timeout_minutes=10 + local real_binary="$2" + shift 2 + + # Launch app in background + "$real_binary" "$@" & + local app_pid=$! + + # Record state + echo "$app_pid $(date +%s)" > "$STATE_DIR/${app}.running" + + # Spawn killer daemon (detached) + ( + sleep $((timeout_minutes * 60)) + if kill -0 $app_pid 2>/dev/null; then + notify "$app" "Session timeout - closing now" critical + kill $app_pid 2>/dev/null + sleep 2 + kill -9 $app_pid 2>/dev/null || true + fi + rm -f "$STATE_DIR/${app}.running" + ) & + disown + + # Wait for app to exit + wait $app_pid 2>/dev/null || true +} +``` + +## 6. YOUTUBE MUSIC → STEAM/BROWSER MUTUAL EXCLUSION + +This requires a more sophisticated approach. Create a new Python daemon. + +Location: scripts/digital_wellbeing/focus_mode_daemon.py (new file) + +Behavior: +- Run as a systemd user service +- Monitor running processes continuously +- When Steam (steam_app_* or steam game processes) detected: + - Kill any running browsers (firefox, chrome, brave, etc.) + - Block browser launches (via wrapper modification or DBus signal) + - Show notification: "Gaming mode active - browsers disabled" +- When any browser detected: + - Kill Steam processes + - Block Steam launches + - Show notification: "Browsing mode active - Steam disabled" +- Mutual exclusion: whichever started first "wins" +- The youtube-music-wrapper.sh should also check for this daemon's signals + +## ADDITIONAL REQUIREMENTS + +1. All changes must be idempotent (can re-run safely) +2. All protection mechanisms should fail-closed (if service dies, restrictions remain) +3. Log all tampering attempts to /var/log/digital-wellbeing-guard.log +4. Create a single test script that verifies all protections work +5. Update the .github/copilot-instructions.md with the new components + +## FILES TO CREATE/MODIFY + +New files: +- hosts/guard/nsswitch-guard.path +- hosts/guard/nsswitch-guard.service +- hosts/guard/enforce-nsswitch.sh +- scripts/digital_wellbeing/focus_mode_daemon.py +- scripts/digital_wellbeing/install_focus_mode_daemon.sh +- tests/test_security_hardening.sh + +Modified files: +- hosts/guard/setup_hosts_guard.sh (add nsswitch protection) +- scripts/digital_wellbeing/setup_midnight_shutdown.sh (remove helpful messages) +- scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt (add chrome) +- scripts/digital_wellbeing/pacman/pacman_wrapper.sh (leechblock auto-install) +- scripts/digital_wellbeing/block_compulsive_opening.sh (auto-close timer) +- scripts/digital_wellbeing/youtube-music-wrapper.sh (daemon integration) + +External repo (separate changes): +- ~/testsAndMisc/python_pkg/screen_locker/screen_lock.py (remove running, harden table tennis) +``` + +--- + +## Part 4: Agent Personas + +### Agent: Hosts Guard Expert + +``` +You are an expert on the linux-configuration hosts guard system. You understand: + +FILES YOU KNOW: +- hosts/install.sh - Downloads StevenBlack hosts, adds custom entries, protects with chattr +- hosts/guard/setup_hosts_guard.sh - Installs all guard layers (path watcher, bind mount, unlock script) +- hosts/guard/enforce-hosts.sh - Called when tampering detected, restores from canonical +- hosts/guard/psychological/unlock-hosts.sh - 45-second delay, logs reason, opens editor +- hosts/guard/hosts-guard.path/.service - Systemd path watcher +- hosts/guard/hosts-bind-mount.service - Read-only bind mount +- hosts/guard/pacman-hooks/*.sh - Pre/post transaction hooks for pacman + +KEY CONCEPTS: +- Canonical copy at /usr/local/share/locked-hosts +- Custom entries state at /etc/hosts.custom-entries.state +- Multi-layer defense: chattr + path watcher + bind mount +- Shell history suppression for unlock commands + +COMMON TASKS: +- Adding new blocked domains: Edit hosts/install.sh heredoc section +- Temporarily allowing edits: sudo /usr/local/sbin/unlock-hosts +- Checking status: lsattr /etc/hosts, systemctl status hosts-guard.path + +GOTCHAS: +- Must run hosts/install.sh BEFORE setup_hosts_guard.sh +- Removing custom entries is blocked by protection mechanism +- nsswitch.conf bypass is currently unprotected (needs fix) +``` + +### Agent: Shutdown Schedule Expert + +``` +You are an expert on the midnight shutdown system. You understand: + +FILES YOU KNOW: +- scripts/digital_wellbeing/setup_midnight_shutdown.sh - Main installer (1300+ lines) +- /etc/shutdown-schedule.conf - Runtime config (MON_WED_HOUR, THU_SUN_HOUR, MORNING_END_HOUR) +- /usr/local/share/locked-shutdown-schedule.conf - Canonical protected copy +- /usr/local/bin/day-specific-shutdown-check.sh - Checks if in shutdown window +- /usr/local/bin/day-specific-shutdown-manager.sh - Status/management +- /etc/systemd/system/day-specific-shutdown.timer/.service - Systemd timer +- /etc/systemd/system/shutdown-schedule-guard.path/.service - Config protection + +KEY CONCEPTS: +- Day-specific windows: Mon-Wed vs Thu-Sun have different hours +- Making schedule STRICTER (earlier) = allowed without delay +- Making schedule MORE LENIENT (later) = blocked or requires unlock +- MORNING_END_HOUR cannot be lowered (would shorten window) +- Monitor service re-enables timer if user disables it + +PROTECTION LAYERS: +1. Script checks canonical config, blocks lenient changes +2. Config file has chattr +i +3. Path watcher restores if file modified +4. Canonical copy takes precedence + +INTEGRATION: +- i3blocks shutdown_countdown.sh reads the config +- screen_lock.py can adjust shutdown time (reward/punishment) +``` + +### Agent: Pacman Wrapper Expert + +``` +You are an expert on the pacman wrapper security system. You understand: + +FILES YOU KNOW: +- scripts/digital_wellbeing/pacman/pacman_wrapper.sh - Main wrapper (823 lines) +- scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh - Backs up real pacman +- scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt - Always blocked +- scripts/digital_wellbeing/pacman/pacman_whitelist.txt - Exceptions to keywords +- scripts/digital_wellbeing/pacman/pacman_greylist.txt - Challenge required +- scripts/digital_wellbeing/pacman/words.txt - Word scramble challenge words +- /var/lib/pacman-wrapper/policy.sha256 - Integrity checksums + +KEY CONCEPTS: +- Real pacman at /usr/bin/pacman.orig, wrapper symlinked to /usr/bin/pacman +- Policy integrity verification via SHA256 before ANY operation +- Three tiers: blocked (always denied), greylist (challenge), whitelist (bypass) +- VirtualBox check is HARDCODED (cannot bypass via policy files) +- Steam is weekend-only with word scramble challenge + +POLICY ENFORCEMENT: +1. Load policy lists from text files +2. Verify integrity hashes match +3. Check if package matches blocked keywords (unless whitelisted) +4. Check if greylisted (requires challenge) +5. After transaction, remove any blocked packages that got installed + +HOSTS INTEGRATION: +- Calls /usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh before transaction +- Calls pacman-post-relock-hosts.sh after transaction +- Enforces VirtualBox hosts sharing if vbox detected + +MAINTENANCE INTEGRATION: +- Auto-runs setup_periodic_system.sh if maintenance services missing +``` + +### Agent: Compulsive Opening Blocker Expert + +``` +You are an expert on the block_compulsive_opening.sh script. You understand: + +FILES YOU KNOW: +- scripts/digital_wellbeing/block_compulsive_opening.sh - Main script (507 lines) +- /usr/local/bin/block-compulsive-opening.sh - Installed location +- ~/.local/state/compulsive-block/*.lastopen - Per-app state files +- ~/.local/state/compulsive-block/compulsive-block.log - Activity log +- /etc/pacman.d/hooks/95-compulsive-block-rewrap.hook - Auto-rewrap hook + +MANAGED APPS: +- beeper → /opt/beeper/beepertexts +- signal-desktop → /usr/lib/signal-desktop/signal-desktop +- discord → /opt/discord/Discord + +KEY CONCEPTS: +- Wrapper replaces /usr/bin/, original saved as .orig or SYMLINK: marker +- Hour-based tracking: YYYY-MM-DD-HH format +- First launch per hour allowed, subsequent launches blocked +- Pacman hook re-installs wrappers after package updates + +WRAPPER FLOW: +1. wrapper_main() called with app name +2. Check was_opened_this_hour() +3. If yes: block_app() + notification + exit 1 +4. If no: record_opening() + exec real binary + +LIMITATION (needs fix): +- Once app is launched, it can run indefinitely +- User can minimize and keep checking via Alt+Tab +- Needs auto-close timer functionality +``` + +### Agent: Screen Locker Expert + +``` +You are an expert on the screen_lock.py workout locker. You understand: + +FILE LOCATION: ~/testsAndMisc/python_pkg/screen_locker/screen_lock.py (1261 lines) + +PURPOSE: +- Full-screen lock requiring workout verification to unlock +- Integrates with shutdown schedule system + +WORKOUT TYPES: +1. Running: distance, time, pace with cross-validation +2. Strength: exercises, sets, reps, weights with total calculation +3. Table Tennis: duration, sets, points won/lost +4. Sick Day: 2-minute wait, shutdown moved 1.5h earlier + +KEY FEATURES: +- 30-second delay before submit button enabled +- Cross-validation (e.g., pace = time / distance) +- 15% tolerance on calculated values +- Demo mode (10s lockout) vs Production mode (30min lockout) +- JSON workout log stored in same directory + +SHUTDOWN INTEGRATION: +- _adjust_shutdown_time_earlier() - sick day penalty +- _adjust_shutdown_time_later() - workout reward (+1.5h) +- Uses adjust_shutdown_schedule.sh helper script +- Sick day state tracked in sick_day_state.json + +SECURITY CONCERNS (needs fix): +- Running option too easy to fake +- Table tennis lacks rigorous validation +- Window can potentially be closed via keyboard +``` + +--- + +## Part 5: LLM README Files + +These should be created in the respective directories: + +### [hosts/guard/README_FOR_LLM.md](to be created) + +```markdown +# Hosts Guard System - LLM Reference + +## Purpose +Prevent tampering with /etc/hosts to maintain website blocking. + +## Architecture +``` +/etc/hosts (immutable) ←── canonical (/usr/local/share/locked-hosts) + ↑ + path watcher detects changes + ↓ + enforce-hosts.sh restores +``` + +## Critical Files +| File | Purpose | Protected By | +|------|---------|--------------| +| /etc/hosts | Actual hosts file | chattr +i, bind mount | +| /usr/local/share/locked-hosts | Canonical copy | chattr +i | +| /etc/hosts.custom-entries.state | Tracks blocked domains | chattr +i | + +## Commands to Know +```bash +# Check protection status +lsattr /etc/hosts +systemctl status hosts-guard.path hosts-bind-mount.service + +# Legitimate edit (with delay) +sudo /usr/local/sbin/unlock-hosts + +# Reinstall/repair +sudo ~/linux-configuration/hosts/install.sh +sudo ~/linux-configuration/hosts/guard/setup_hosts_guard.sh +``` + +## DO NOT +- Edit /etc/nsswitch.conf (bypasses hosts entirely) +- Stop hosts-guard.path without understanding consequences +- Remove entries from install.sh without state file cleanup +``` + +### [scripts/digital_wellbeing/pacman/README_FOR_LLM.md](to be created) + +```markdown +# Pacman Wrapper - LLM Reference + +## Purpose +Intercept pacman to enforce package installation policies. + +## Architecture +``` +/usr/bin/pacman (symlink) → pacman_wrapper.sh + ↓ + /usr/bin/pacman.orig (real) +``` + +## Policy Files +| File | Purpose | +|------|---------| +| pacman_blocked_keywords.txt | Substring match = always blocked | +| pacman_whitelist.txt | Exact names that bypass blocking | +| pacman_greylist.txt | Requires challenge to install | +| words.txt | Word scramble challenge source | + +## Hardcoded Checks (cannot bypass via files) +- VirtualBox → security challenge + hosts enforcement +- Steam → weekend-only + word scramble + +## Integration Points +1. Hosts guard (pre/post hooks) +2. Periodic maintenance (auto-setup if missing) +3. VirtualBox hosts enforcement + +## Adding Blocks +```bash +# Edit the blocked keywords file +echo "newpackage" >> pacman_blocked_keywords.txt + +# Re-run installer to update checksums +sudo ./install_pacman_wrapper.sh +``` +``` + +--- + +## Part 6: Test Script Template + +```bash +#!/bin/bash +# tests/test_security_hardening.sh +# Verify all security mechanisms are working + +set -euo pipefail + +PASS=0 +FAIL=0 + +test_result() { + local name="$1" + local result="$2" + if [[ $result == "pass" ]]; then + echo "✅ PASS: $name" + ((PASS++)) + else + echo "❌ FAIL: $name" + ((FAIL++)) + fi +} + +# Test 1: /etc/hosts is immutable +if lsattr /etc/hosts 2>/dev/null | grep -q '^....i'; then + test_result "/etc/hosts is immutable" "pass" +else + test_result "/etc/hosts is immutable" "fail" +fi + +# Test 2: hosts-guard.path is active +if systemctl is-active --quiet hosts-guard.path; then + test_result "hosts-guard.path is active" "pass" +else + test_result "hosts-guard.path is active" "fail" +fi + +# Test 3: shutdown-schedule.conf is immutable +if lsattr /etc/shutdown-schedule.conf 2>/dev/null | grep -q '^....i'; then + test_result "/etc/shutdown-schedule.conf is immutable" "pass" +else + test_result "/etc/shutdown-schedule.conf is immutable" "fail" +fi + +# Test 4: pacman wrapper is installed +if [[ -L /usr/bin/pacman ]] && [[ -f /usr/bin/pacman.orig ]]; then + test_result "pacman wrapper installed" "pass" +else + test_result "pacman wrapper installed" "fail" +fi + +# Test 5: google-chrome is blocked +if grep -qi "google-chrome" ~/linux-configuration/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt; then + test_result "google-chrome in blocked list" "pass" +else + test_result "google-chrome in blocked list" "fail" +fi + +# Summary +echo "" +echo "==========================================" +echo "Results: $PASS passed, $FAIL failed" +echo "==========================================" + +exit $FAIL +``` + +--- + +## Conclusion + +This analysis identifies critical vulnerabilities and provides a comprehensive implementation prompt. The most urgent issues are: + +1. **nsswitch.conf bypass** - Completely unprotected, defeats all hosts protections +2. **Information disclosure** - Shutdown system tells users how to bypass +3. **App lifetime** - Compulsive blockers don't limit session duration +4. **Browser gaps** - Chrome not blocked, no LeechBlock auto-install + +The implementation prompt above should be used in a focused coding session to address all issues systematically. diff --git a/hosts/guard/README_FOR_LLM.md b/hosts/guard/README_FOR_LLM.md new file mode 100644 index 0000000..a69183c --- /dev/null +++ b/hosts/guard/README_FOR_LLM.md @@ -0,0 +1,205 @@ +# Hosts Guard System - LLM Reference Guide + +> **For AI assistants**: This document explains how the hosts guard system works so you can make correct modifications. + +## System Purpose + +Prevent tampering with `/etc/hosts` to maintain website blocking (YouTube, social media, etc.) as part of a digital wellbeing system. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ PROTECTION LAYERS │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Layer 1: Immutable Attribute │ +│ ───────────────────────────── │ +│ /etc/hosts has chattr +i (cannot be modified even by root) │ +│ │ +│ Layer 2: Canonical Copy │ +│ ─────────────────────── │ +│ /usr/local/share/locked-hosts contains the "true" version │ +│ If /etc/hosts differs, it gets overwritten from this copy │ +│ │ +│ Layer 3: Path Watcher (systemd) │ +│ ────────────────────────────── │ +│ hosts-guard.path watches /etc/hosts for ANY change │ +│ hosts-guard.service runs enforce-hosts.sh when triggered │ +│ │ +│ Layer 4: Read-Only Bind Mount │ +│ ──────────────────────────── │ +│ hosts-bind-mount.service mounts /etc/hosts read-only │ +│ Even if chattr is removed, write operations fail │ +│ │ +│ Layer 5: Custom Entries Protection │ +│ ───────────────────────────────── │ +│ /etc/hosts.custom-entries.state tracks blocked domains │ +│ Prevents removal of domains from install.sh │ +│ │ +│ Layer 6: nsswitch.conf Protection (NEW) │ +│ ─────────────────────────────────────── │ +│ Prevents bypass via /etc/nsswitch.conf manipulation │ +│ Ensures "files" always appears in hosts: line before "dns" │ +│ nsswitch-guard.path watches for changes │ +│ Canonical copy at /usr/local/share/locked-nsswitch.conf │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## File Locations + +| File | Purpose | Protection | +|------|---------|------------| +| `/etc/hosts` | Active hosts file | chattr +i, bind mount | +| `/usr/local/share/locked-hosts` | Canonical source of truth | chattr +i | +| `/etc/hosts.custom-entries.state` | Tracks custom blocked domains | chattr +i | +| `/etc/hosts.stevenblack` | Cached upstream hosts file | None | +| `/etc/nsswitch.conf` | Name service switch config | chattr +i, path watcher | +| `/usr/local/share/locked-nsswitch.conf` | Canonical nsswitch copy | chattr +i | +| `/usr/local/sbin/enforce-hosts.sh` | Restoration script | File permissions | +| `/usr/local/sbin/enforce-nsswitch.sh` | nsswitch enforcement | File permissions | +| `/usr/local/sbin/unlock-hosts` | Psychological unlock script | File permissions | +| `/etc/systemd/system/hosts-guard.path` | Path watcher unit | systemd | +| `/etc/systemd/system/hosts-guard.service` | Enforcement service | systemd | +| `/etc/systemd/system/hosts-bind-mount.service` | RO bind mount | systemd | +| `/etc/systemd/system/nsswitch-guard.path` | nsswitch watcher | systemd | +| `/etc/systemd/system/nsswitch-guard.service` | nsswitch enforce | systemd | + +## Key Scripts + +### hosts/install.sh +- Downloads StevenBlack hosts list (cached at `/etc/hosts.stevenblack`) +- Adds custom blocking entries (YouTube, etc.) +- Comments out allowed sites (4chan, Facebook) +- Runs protection check for custom entries +- Sets up initial immutable attribute + +### hosts/guard/setup_hosts_guard.sh +Installs all protection layers: +- Creates canonical snapshot +- Installs enforce-hosts.sh and unlock-hosts scripts +- Enables systemd path watcher +- Enables bind mount service +- Installs shell history suppression hooks + +### hosts/guard/enforce-hosts.sh +Called when tampering detected: +```bash +# Compares /etc/hosts to canonical +# If different: restores from canonical, logs event +# Re-applies chattr +i +``` + +### hosts/guard/psychological/unlock-hosts.sh +Legitimate edit workflow: +1. Prompts for reason (logged) +2. Stops protection services +3. Waits 45 seconds (cooling off) +4. Opens editor +5. Updates canonical if changes made +6. Re-enables all protections + +## Pacman Integration + +The pacman wrapper calls these hooks during package transactions: +- `/usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh` - Before transaction +- `/usr/local/share/hosts-guard/pacman-post-relock-hosts.sh` - After transaction + +These temporarily unlock hosts for package manager operations. + +## Common Tasks + +### Adding a New Blocked Domain + +1. Edit `hosts/install.sh` +2. Find the heredoc section after `# Custom blocking entries` +3. Add line: `0.0.0.0 newdomain.com` +4. Run: `sudo ~/linux-configuration/hosts/install.sh` + +```bash +# Example: Block example.com +# In hosts/install.sh, add to heredoc: +0.0.0.0 example.com +0.0.0.0 www.example.com +``` + +### Allowing a Previously Blocked Domain + +**This is intentionally difficult.** You must: +1. Remove entry from install.sh heredoc +2. Remove protection: `sudo chattr -i /etc/hosts.custom-entries.state` +3. Edit state file to remove domain +4. Re-run install.sh + +### Checking Protection Status + +```bash +# Check immutable attribute +lsattr /etc/hosts +# Should show: ----i--------e-- /etc/hosts + +# Check services +systemctl status hosts-guard.path hosts-guard.service hosts-bind-mount.service + +# Check canonical exists +ls -la /usr/local/share/locked-hosts +``` + +### Legitimate Editing + +```bash +sudo /usr/local/sbin/unlock-hosts +# Enter reason when prompted +# Wait 45 seconds +# Edit in your $EDITOR +# Changes auto-saved to canonical +``` + +## nsswitch.conf Protection (Layer 6) + +**Why this matters:** A user could bypass ALL /etc/hosts protections by simply editing `/etc/nsswitch.conf` and removing `files` from the `hosts:` line. This protection layer prevents that. + +### How it works: +- `nsswitch-guard.path` watches `/etc/nsswitch.conf` for changes +- `nsswitch-guard.service` runs `enforce-nsswitch.sh` when triggered +- Canonical copy stored at `/usr/local/share/locked-nsswitch.conf` +- Validates that `hosts:` line contains `files` before `dns` +- Auto-restores from canonical if tampered + +### Check nsswitch protection status: +```bash +lsattr /etc/nsswitch.conf +systemctl status nsswitch-guard.path +``` + +## Troubleshooting + +### "Cannot modify /etc/hosts" +This is expected! Use the unlock script: +```bash +sudo /usr/local/sbin/unlock-hosts +``` + +### Path watcher not running +```bash +sudo systemctl start hosts-guard.path +sudo systemctl enable hosts-guard.path +``` + +### Bind mount preventing access +```bash +# Temporarily disable (not recommended) +sudo systemctl stop hosts-bind-mount.service +``` + +### Custom entries protection blocking install +The protection mechanism detected you're trying to remove previously blocked domains. This is intentional. To proceed, manually edit the state file (see "Allowing a Previously Blocked Domain"). + +## DO NOT + +1. ❌ Edit `/etc/nsswitch.conf` to bypass hosts (this defeats the purpose) +2. ❌ Stop `hosts-guard.path` without understanding consequences +3. ❌ Delete `/usr/local/share/locked-hosts` (breaks restoration) +4. ❌ Remove entries from install.sh without updating state file +5. ❌ Use `chattr -i` without going through unlock-hosts diff --git a/hosts/guard/enforce-nsswitch.sh b/hosts/guard/enforce-nsswitch.sh new file mode 100644 index 0000000..ec01fd0 --- /dev/null +++ b/hosts/guard/enforce-nsswitch.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# Template guard script to enforce canonical /etc/nsswitch.conf +# Ensures "hosts:" line always contains "files" before "dns" +# This prevents bypassing /etc/hosts by removing "files" from nsswitch.conf +# Installed to /usr/local/sbin/enforce-nsswitch.sh by setup_hosts_guard.sh + +set -euo pipefail + +CANONICAL_SOURCE="/usr/local/share/locked-nsswitch.conf" +TARGET="/etc/nsswitch.conf" +LOG_FILE="/var/log/nsswitch-guard.log" + +log() { + printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2 +} + +# Function to validate that "hosts:" line has correct format +# Must contain "files" before "dns" (or "dns" not present) +validate_hosts_line() { + local line="$1" + + # Check if "files" is present + if ! echo "$line" | grep -qw "files"; then + return 1 + fi + + # If dns is present, files must come before it + 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 +} + +# Check current nsswitch.conf hosts line +current_hosts_line=$(grep '^hosts:' "$TARGET" 2>/dev/null || echo "") + +if [[ -z "$current_hosts_line" ]]; then + log "CRITICAL: No 'hosts:' line found in $TARGET - restoring from canonical" + if [[ -f "$CANONICAL_SOURCE" ]]; then + chattr -i "$TARGET" 2>/dev/null || true + cp "$CANONICAL_SOURCE" "$TARGET" + chmod 644 "$TARGET" + chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET" + log "Restored $TARGET from canonical copy" + else + log "ERROR: Canonical source not found at $CANONICAL_SOURCE" + exit 1 + fi + exit 0 +fi + +if ! validate_hosts_line "$current_hosts_line"; then + log "TAMPERING DETECTED: 'hosts:' line is invalid or missing 'files' before 'dns'" + log "Current line: $current_hosts_line" + + if [[ -f "$CANONICAL_SOURCE" ]]; then + chattr -i "$TARGET" 2>/dev/null || true + cp "$CANONICAL_SOURCE" "$TARGET" + chmod 644 "$TARGET" + chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET" + log "Restored $TARGET from canonical copy" + else + log "ERROR: Canonical source not found at $CANONICAL_SOURCE" + # Emergency fix: add "files" back to hosts line + chattr -i "$TARGET" 2>/dev/null || true + sed -i 's/^hosts:\(.*\)dns/hosts:\1files dns/' "$TARGET" + chattr +i "$TARGET" 2>/dev/null || true + log "Emergency fix applied: added 'files' before 'dns'" + fi + exit 0 +fi + +# If canonical exists, check for any drift +if [[ -f "$CANONICAL_SOURCE" ]]; then + if ! cmp -s "$CANONICAL_SOURCE" "$TARGET"; then + log "Drift detected in $TARGET (but hosts line valid) - restoring canonical" + chattr -i "$TARGET" 2>/dev/null || true + cp "$CANONICAL_SOURCE" "$TARGET" + chmod 644 "$TARGET" + chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute" + log "Restored $TARGET from canonical copy" + else + log "No drift detected in $TARGET" + fi +else + log "Creating initial canonical snapshot" + mkdir -p "$(dirname "$CANONICAL_SOURCE")" + cp "$TARGET" "$CANONICAL_SOURCE" + chmod 644 "$CANONICAL_SOURCE" + chattr +i "$CANONICAL_SOURCE" 2>/dev/null || log "Failed to protect canonical copy" +fi + +# Ensure immutable attribute is set +chattr -i "$TARGET" 2>/dev/null || true +chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET" + +log "nsswitch.conf enforcement complete" diff --git a/hosts/guard/nsswitch-guard.path b/hosts/guard/nsswitch-guard.path new file mode 100644 index 0000000..769d698 --- /dev/null +++ b/hosts/guard/nsswitch-guard.path @@ -0,0 +1,9 @@ +[Unit] +Description=Watch /etc/nsswitch.conf for tampering (hosts bypass protection) + +[Path] +PathChanged=/etc/nsswitch.conf +Unit=nsswitch-guard.service + +[Install] +WantedBy=multi-user.target diff --git a/hosts/guard/nsswitch-guard.service b/hosts/guard/nsswitch-guard.service new file mode 100644 index 0000000..448b5e3 --- /dev/null +++ b/hosts/guard/nsswitch-guard.service @@ -0,0 +1,12 @@ +[Unit] +Description=Enforce canonical /etc/nsswitch.conf (prevents hosts bypass) +After=local-fs.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/enforce-nsswitch.sh +Nice=10 +IOSchedulingClass=idle + +[Install] +WantedBy=multi-user.target diff --git a/hosts/guard/pacman-hooks/hosts-guard-common.sh b/hosts/guard/pacman-hooks/hosts-guard-common.sh old mode 100644 new mode 100755 index 404d618..9872cc3 --- a/hosts/guard/pacman-hooks/hosts-guard-common.sh +++ b/hosts/guard/pacman-hooks/hosts-guard-common.sh @@ -7,85 +7,85 @@ LOGTAG=hosts-guard-hook # Check if target has a read-only mount is_ro_mount() { - findmnt -no OPTIONS -T "$TARGET" 2> /dev/null | grep -qw ro + findmnt -no OPTIONS -T "$TARGET" 2>/dev/null | grep -qw ro } # Count mount layers for the target mount_layers_count() { - awk '$5=="/etc/hosts"{c++} END{print c+0}' /proc/self/mountinfo 2> /dev/null || echo 0 + awk '$5=="/etc/hosts"{c++} END{print c+0}' /proc/self/mountinfo 2>/dev/null || echo 0 } # Collapse all bind mount layers collapse_mounts() { - local i=0 - if command -v mountpoint > /dev/null 2>&1; then - while mountpoint -q "$TARGET"; do - umount -l "$TARGET" > /dev/null 2>&1 || break - i=$((i + 1)) - ((i > 20)) && break - done - else - local cnt - cnt=$(mount_layers_count) - while ((cnt > 1)); do - umount -l "$TARGET" > /dev/null 2>&1 || break - i=$((i + 1)) - ((i > 20)) && break - cnt=$(mount_layers_count) - done - fi + local i=0 + if command -v mountpoint >/dev/null 2>&1; then + while mountpoint -q "$TARGET"; do + umount -l "$TARGET" >/dev/null 2>&1 || break + i=$((i + 1)) + ((i > 20)) && break + done + else + local cnt + cnt=$(mount_layers_count) + while ((cnt > 1)); do + umount -l "$TARGET" >/dev/null 2>&1 || break + i=$((i + 1)) + ((i > 20)) && break + cnt=$(mount_layers_count) + done + fi } # Stop systemd units related to hosts guard stop_units_if_present() { - local units=(hosts-bind-mount.service hosts-guard.path) - for u in "${units[@]}"; do - if command -v systemctl > /dev/null 2>&1; then - if systemctl list-unit-files 2> /dev/null | grep -q "^$u"; then - systemctl stop "$u" > /dev/null 2>&1 || true - fi - fi - done + local units=(hosts-bind-mount.service hosts-guard.path) + for u in "${units[@]}"; do + if command -v systemctl >/dev/null 2>&1; then + if systemctl list-unit-files 2>/dev/null | grep -q "^$u"; then + systemctl stop "$u" >/dev/null 2>&1 || true + fi + fi + done } # Remove immutable/append-only attributes remove_host_attrs() { - if command -v lsattr > /dev/null 2>&1; then - local attrs - attrs=$(lsattr -d "$TARGET" 2> /dev/null || true) - if echo "$attrs" | grep -q " i "; then - chattr -i "$TARGET" > /dev/null 2>&1 || true - fi - if echo "$attrs" | grep -q " a "; then - chattr -a "$TARGET" > /dev/null 2>&1 || true - fi - fi + if command -v lsattr >/dev/null 2>&1; then + local attrs + attrs=$(lsattr -d "$TARGET" 2>/dev/null || true) + if echo "$attrs" | grep -q " i "; then + chattr -i "$TARGET" >/dev/null 2>&1 || true + fi + if echo "$attrs" | grep -q " a "; then + chattr -a "$TARGET" >/dev/null 2>&1 || true + fi + fi } # Apply immutable attribute apply_immutable() { - if command -v chattr > /dev/null 2>&1; then - chattr +i "$TARGET" > /dev/null 2>&1 || true - fi + if command -v chattr >/dev/null 2>&1; then + chattr +i "$TARGET" >/dev/null 2>&1 || true + fi } # Apply a single read-only bind mount layer apply_ro_bind_mount() { - mount --bind "$TARGET" "$TARGET" > /dev/null 2>&1 || true - mount -o remount,ro,bind "$TARGET" > /dev/null 2>&1 || true + mount --bind "$TARGET" "$TARGET" >/dev/null 2>&1 || true + mount -o remount,ro,bind "$TARGET" >/dev/null 2>&1 || true } # Start the path watcher service start_path_watcher() { - if command -v systemctl > /dev/null 2>&1; then - systemctl start hosts-guard.path > /dev/null 2>&1 || true - fi + if command -v systemctl >/dev/null 2>&1; then + systemctl start hosts-guard.path >/dev/null 2>&1 || true + fi } # Log to system logger and run log file log_hook() { - local phase="$1" - local state="$2" - logger -t "$LOGTAG" "$phase: $state" - echo "$(date -Is) $phase-$state" >> /run/hosts-guard-hook.log 2> /dev/null || true + local phase="$1" + local state="$2" + logger -t "$LOGTAG" "$phase: $state" + echo "$(date -Is) $phase-$state" >>/run/hosts-guard-hook.log 2>/dev/null || true } diff --git a/hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh b/hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh old mode 100644 new mode 100755 index 3817803..9f6cee2 --- a/hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh +++ b/hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh @@ -16,7 +16,7 @@ collapse_mounts # Run enforcement script if available if [[ -x $ENFORCE ]]; then - "$ENFORCE" > /dev/null 2>&1 || true + "$ENFORCE" >/dev/null 2>&1 || true fi # Apply protections diff --git a/hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh b/hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh old mode 100644 new mode 100755 index 1530ef1..0cb3331 --- a/hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh +++ b/hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh @@ -9,6 +9,7 @@ source "$SCRIPT_DIR/hosts-guard-common.sh" # Remove protective attributes remove_host_attrs +sudo rm /etc/hosts # Stop guard services stop_units_if_present @@ -20,7 +21,7 @@ collapse_mounts # Ensure writable by remounting if still read-only if is_ro_mount; then - mount -o remount,rw "$TARGET" > /dev/null 2>&1 || collapse_mounts + mount -o remount,rw "$TARGET" >/dev/null 2>&1 || collapse_mounts fi log_hook "pre" "unlocking(done)" diff --git a/hosts/guard/psychological/unlock-hosts.sh b/hosts/guard/psychological/unlock-hosts.sh old mode 100644 new mode 100755 index 959ae0e..62a781c --- a/hosts/guard/psychological/unlock-hosts.sh +++ b/hosts/guard/psychological/unlock-hosts.sh @@ -17,30 +17,30 @@ require_root "$@" echo "Reason for editing /etc/hosts (will be logged):" >&2 read -r -p "Enter reason: " REASON if [[ -z ${REASON// /} ]]; then - echo "Empty reason not allowed. Aborting." >&2 - exit 1 + echo "Empty reason not allowed. Aborting." >&2 + exit 1 fi log "Requested intentional /etc/hosts modification session. Reason: $REASON" logger -t "$SYSLOG_TAG" "session_start user=${SUDO_USER:-$USER} reason='$REASON'" echo "This action is logged. A cooling-off delay of $DELAY_SECONDS seconds applies." >&2 for s in hosts-bind-mount.service hosts-guard.path; do - if systemctl is-active --quiet "$s"; then - log "Stopping $s" - systemctl stop "$s" || true - fi - if systemctl is-enabled --quiet "$s"; then - log "(Will re-enable later)" - fi + if systemctl is-active --quiet "$s"; then + log "Stopping $s" + systemctl stop "$s" || true + fi + if systemctl is-enabled --quiet "$s"; then + log "(Will re-enable later)" + fi done # Remove attributes to allow edit -chattr -i -a "$TARGET" 2> /dev/null || true +chattr -i -a "$TARGET" 2>/dev/null || true echo "Countdown:" >&2 for ((i = DELAY_SECONDS; i > 0; i--)); do - printf '\rEdit window opens in %2d seconds... Press Ctrl+C to abort.' "$i" >&2 - sleep 1 + printf '\rEdit window opens in %2d seconds... Press Ctrl+C to abort.' "$i" >&2 + sleep 1 done echo >&2 @@ -50,12 +50,12 @@ sha_before=$(sha256sum "$TARGET" | awk '{print $1}') sha_after=$(sha256sum "$TARGET" | awk '{print $1}') if [[ $sha_before == "$sha_after" ]]; then - log "No changes made to $TARGET. Reason: $REASON" - logger -t "$SYSLOG_TAG" "no_change user=${SUDO_USER:-$USER} reason='$REASON'" + log "No changes made to $TARGET. Reason: $REASON" + logger -t "$SYSLOG_TAG" "no_change user=${SUDO_USER:-$USER} reason='$REASON'" else - log "Changes detected. Updating canonical copy and re-enforcing. Reason: $REASON" - logger -t "$SYSLOG_TAG" "modified user=${SUDO_USER:-$USER} reason='$REASON'" - cp "$TARGET" "$CANON" + log "Changes detected. Updating canonical copy and re-enforcing. Reason: $REASON" + logger -t "$SYSLOG_TAG" "modified user=${SUDO_USER:-$USER} reason='$REASON'" + cp "$TARGET" "$CANON" fi # Re-run enforcement diff --git a/hosts/guard/setup_hosts_guard.sh b/hosts/guard/setup_hosts_guard.sh index 19da91c..e31dbde 100755 --- a/hosts/guard/setup_hosts_guard.sh +++ b/hosts/guard/setup_hosts_guard.sh @@ -33,6 +33,7 @@ FORCE_SNAPSHOT=0 DO_SNAPSHOT=1 ENABLE_BIND=1 ENABLE_PATH=1 +ENABLE_NSSWITCH=1 UNINSTALL=0 DELAY=45 DRY_RUN=0 @@ -48,15 +49,15 @@ note() { printf '\e[1;34m[i]\e[0m %s\n' "$*"; } warn() { printf '\e[1;33m[!]\e[0m %s\n' "$*"; } err() { printf '\e[1;31m[x]\e[0m %s\n' "$*" >&2; } run() { - if [[ $DRY_RUN -eq 1 ]]; then - printf 'DRY-RUN:' - if [ "$#" -gt 0 ]; then - printf ' %q' "$@" - fi - printf '\n' - else - "$@" - fi + if [[ $DRY_RUN -eq 1 ]]; then + printf 'DRY-RUN:' + if [ "$#" -gt 0 ]; then + printf ' %q' "$@" + fi + printf '\n' + else + "$@" + fi } require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi; } @@ -67,73 +68,77 @@ usage() { sed -n '1,/^set -euo pipefail/p' "$0" | sed 's/^# \{0,1\}//'; } # Parse args ###################################################################### while [[ $# -gt 0 ]]; do - case "$1" in - --force-snapshot) - FORCE_SNAPSHOT=1 - shift - ;; - --no-snapshot) - DO_SNAPSHOT=0 - shift - ;; - --skip-bind) - ENABLE_BIND=0 - shift - ;; - --skip-path-watch) - ENABLE_PATH=0 - shift - ;; - --delay) - DELAY=${2:-} - [[ -z ${DELAY} ]] && { - err '--delay requires value' - exit 2 - } - shift 2 - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --no-shell-hooks) - INSTALL_SHELL_HOOKS=0 - shift - ;; - --shell-hooks) - INSTALL_SHELL_HOOKS=1 - shift - ;; - --no-audit) - INSTALL_AUDIT_RULE=0 - shift - ;; - --audit) - INSTALL_AUDIT_RULE=1 - shift - ;; - --no-alias-stub) - ADD_ALIAS_STUB=0 - shift - ;; - --alias-stub) - ADD_ALIAS_STUB=1 - shift - ;; - --uninstall) - UNINSTALL=1 - shift - ;; - -h | --help) - usage - exit 0 - ;; - *) - err "Unknown argument: $1" - usage - exit 2 - ;; - esac + case "$1" in + --force-snapshot) + FORCE_SNAPSHOT=1 + shift + ;; + --no-snapshot) + DO_SNAPSHOT=0 + shift + ;; + --skip-bind) + ENABLE_BIND=0 + shift + ;; + --skip-path-watch) + ENABLE_PATH=0 + shift + ;; + --skip-nsswitch) + ENABLE_NSSWITCH=0 + shift + ;; + --delay) + DELAY=${2:-} + [[ -z ${DELAY} ]] && { + err '--delay requires value' + exit 2 + } + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --no-shell-hooks) + INSTALL_SHELL_HOOKS=0 + shift + ;; + --shell-hooks) + INSTALL_SHELL_HOOKS=1 + shift + ;; + --no-audit) + INSTALL_AUDIT_RULE=0 + shift + ;; + --audit) + INSTALL_AUDIT_RULE=1 + shift + ;; + --no-alias-stub) + ADD_ALIAS_STUB=0 + shift + ;; + --alias-stub) + ADD_ALIAS_STUB=1 + shift + ;; + --uninstall) + UNINSTALL=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + err "Unknown argument: $1" + usage + exit 2 + ;; + esac done require_root "$@" @@ -149,11 +154,17 @@ TEMPLATE_UNLOCK="$SCRIPT_DIR/psychological/unlock-hosts.sh" UNIT_GUARD_SERVICE="$SCRIPT_DIR/hosts-guard.service" UNIT_GUARD_PATH="$SCRIPT_DIR/hosts-guard.path" UNIT_BIND_SERVICE="$SCRIPT_DIR/hosts-bind-mount.service" +TEMPLATE_ENFORCE_NSSWITCH="$SCRIPT_DIR/enforce-nsswitch.sh" +UNIT_NSSWITCH_SERVICE="$SCRIPT_DIR/nsswitch-guard.service" +UNIT_NSSWITCH_PATH="$SCRIPT_DIR/nsswitch-guard.path" INSTALL_ENFORCE="/usr/local/sbin/enforce-hosts.sh" INSTALL_UNLOCK="/usr/local/sbin/unlock-hosts" +INSTALL_ENFORCE_NSSWITCH="/usr/local/sbin/enforce-nsswitch.sh" CANON="/usr/local/share/locked-hosts" +CANON_NSSWITCH="/usr/local/share/locked-nsswitch.conf" HOSTS="/etc/hosts" +NSSWITCH="/etc/nsswitch.conf" # Shell hook destinations (user agnostic system-wide skeleton + etc profile.d) ZSH_FILTER_SNIPPET="/etc/zsh/hosts_guard_history_filter.zsh" @@ -165,26 +176,29 @@ SYSTEMD_DIR="/etc/systemd/system" # Uninstall flow ###################################################################### if [[ $UNINSTALL -eq 1 ]]; then - note "Uninstalling hosts guard components ( protections removed )" - for u in hosts-guard.path hosts-guard.service hosts-bind-mount.service; do - if systemctl list-unit-files | grep -q "^$u"; then - run systemctl disable --now "$u" || true - fi - done - for f in \ - "$INSTALL_ENFORCE" \ - "$INSTALL_UNLOCK" \ - "$SYSTEMD_DIR/hosts-guard.service" \ - "$SYSTEMD_DIR/hosts-guard.path" \ - "$SYSTEMD_DIR/hosts-bind-mount.service" \ - "$ZSH_FILTER_SNIPPET" \ - "$BASH_FILTER_SNIPPET"; do - if [[ -e $f ]]; then run rm -f "$f"; fi - done - note "Leaving canonical snapshot at $CANON (remove manually if undesired)." - if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi - msg "Uninstall complete" - exit 0 + note "Uninstalling hosts guard components ( protections removed )" + for u in hosts-guard.path hosts-guard.service hosts-bind-mount.service nsswitch-guard.path nsswitch-guard.service; do + if systemctl list-unit-files | grep -q "^$u"; then + run systemctl disable --now "$u" || true + fi + done + for f in \ + "$INSTALL_ENFORCE" \ + "$INSTALL_UNLOCK" \ + "$INSTALL_ENFORCE_NSSWITCH" \ + "$SYSTEMD_DIR/hosts-guard.service" \ + "$SYSTEMD_DIR/hosts-guard.path" \ + "$SYSTEMD_DIR/hosts-bind-mount.service" \ + "$SYSTEMD_DIR/nsswitch-guard.service" \ + "$SYSTEMD_DIR/nsswitch-guard.path" \ + "$ZSH_FILTER_SNIPPET" \ + "$BASH_FILTER_SNIPPET"; do + if [[ -e $f ]]; then run rm -f "$f"; fi + done + note "Leaving canonical snapshots at $CANON and $CANON_NSSWITCH (remove manually if undesired)." + if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi + msg "Uninstall complete" + exit 0 fi ###################################################################### @@ -194,29 +208,29 @@ note "Script directory: $SCRIPT_DIR" note "Repository root: $REPO_ROOT" for req in "$TEMPLATE_ENFORCE" "$TEMPLATE_UNLOCK" "$UNIT_GUARD_SERVICE"; do - [[ -f $req ]] || { - err "Missing template: $req" - exit 1 - } + [[ -f $req ]] || { + err "Missing template: $req" + exit 1 + } done if [[ ! -f $HOSTS ]]; then - err "$HOSTS does not exist. Run your hosts/install.sh first." - exit 1 + err "$HOSTS does not exist. Run your hosts/install.sh first." + exit 1 fi ###################################################################### # Snapshot ###################################################################### if [[ $DO_SNAPSHOT -eq 1 ]]; then - if [[ -f $CANON && $FORCE_SNAPSHOT -eq 0 ]]; then - note "Canonical snapshot exists (use --force-snapshot to overwrite)" - else - msg "Creating canonical snapshot at $CANON" - run install -m 644 -D "$HOSTS" "$CANON" - fi + if [[ -f $CANON && $FORCE_SNAPSHOT -eq 0 ]]; then + note "Canonical snapshot exists (use --force-snapshot to overwrite)" + else + msg "Creating canonical snapshot at $CANON" + run install -m 644 -D "$HOSTS" "$CANON" + fi else - note "Skipping snapshot creation (--no-snapshot)" + note "Skipping snapshot creation (--no-snapshot)" fi ###################################################################### @@ -230,27 +244,27 @@ run install -m 755 "$TEMPLATE_UNLOCK" "$INSTALL_UNLOCK" # Adjust delay in unlock script if different from default if [[ $DELAY -ne 45 ]]; then - msg "Adjusting unlock delay to $DELAY seconds" - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would patch $INSTALL_UNLOCK" - else - # Replace DELAY_SECONDS=... line - sed -i -E "s/^(DELAY_SECONDS=).*/\\1$DELAY/" "$INSTALL_UNLOCK" || warn "Failed to adjust delay" - fi + msg "Adjusting unlock delay to $DELAY seconds" + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would patch $INSTALL_UNLOCK" + else + # Replace DELAY_SECONDS=... line + sed -i -E "s/^(DELAY_SECONDS=).*/\\1$DELAY/" "$INSTALL_UNLOCK" || warn "Failed to adjust delay" + fi fi ###################################################################### # Install shell history filters (optional) ###################################################################### if [[ $INSTALL_SHELL_HOOKS -eq 1 ]]; then - msg "Installing shell history suppression hooks for unlock command" - # Pattern matches commands invoking unlock-hosts (with or without sudo) & setup script force snapshot - # Zsh: use zshaddhistory function - if command -v zsh > /dev/null 2>&1; then - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would create $ZSH_FILTER_SNIPPET" - else - cat > "$ZSH_FILTER_SNIPPET" << 'ZEOF' + msg "Installing shell history suppression hooks for unlock command" + # Pattern matches commands invoking unlock-hosts (with or without sudo) & setup script force snapshot + # Zsh: use zshaddhistory function + if command -v zsh >/dev/null 2>&1; then + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would create $ZSH_FILTER_SNIPPET" + else + cat >"$ZSH_FILTER_SNIPPET" <<'ZEOF' # Added by hosts guard setup – suppress unlock-hosts commands from Zsh history autoload -Uz add-zsh-hook 2>/dev/null || true _hosts_guard_history_filter() { @@ -269,16 +283,16 @@ else zshaddhistory() { _hosts_guard_history_filter "$1"; } fi ZEOF - chmod 644 "$ZSH_FILTER_SNIPPET" - fi - fi + chmod 644 "$ZSH_FILTER_SNIPPET" + fi + fi - # Bash: rely on HISTCONTROL and PROMPT_COMMAND filter - if command -v bash > /dev/null 2>&1; then - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would create $BASH_FILTER_SNIPPET" - else - cat > "$BASH_FILTER_SNIPPET" << 'BEOF' + # Bash: rely on HISTCONTROL and PROMPT_COMMAND filter + if command -v bash >/dev/null 2>&1; then + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would create $BASH_FILTER_SNIPPET" + else + cat >"$BASH_FILTER_SNIPPET" <<'BEOF' # Added by hosts guard setup – suppress unlock-hosts commands from Bash history export HISTCONTROL=ignoredups:erasedups _hosts_guard_hist_filter() { @@ -299,51 +313,51 @@ case :${PROMPT_COMMAND-}: in * ) PROMPT_COMMAND="_hosts_guard_hist_filter${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;; esac BEOF - chmod 644 "$BASH_FILTER_SNIPPET" - fi - fi + chmod 644 "$BASH_FILTER_SNIPPET" + fi + fi else - note "Skipping shell history hooks (--no-shell-hooks)" + note "Skipping shell history hooks (--no-shell-hooks)" fi ###################################################################### # Add alias stub to discourage raw invocation (shell-level friction) ###################################################################### if [[ $ADD_ALIAS_STUB -eq 1 ]]; then - PROFILE_STUB="/etc/profile.d/hosts_guard_alias_stub.sh" - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would create $PROFILE_STUB" - else - cat > "$PROFILE_STUB" << 'ASTUB' + PROFILE_STUB="/etc/profile.d/hosts_guard_alias_stub.sh" + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would create $PROFILE_STUB" + else + cat >"$PROFILE_STUB" <<'ASTUB' # Added by hosts guard setup – discourages casual use of unlock-hosts name if command -v unlock-hosts >/dev/null 2>&1; then alias unlock-hosts='command_not_found_handle 2>/dev/null || echo "Use: sudo /usr/local/sbin/unlock-hosts (logged & delayed)"' fi ASTUB - chmod 644 "$PROFILE_STUB" - fi + chmod 644 "$PROFILE_STUB" + fi fi ###################################################################### # Audit rule to record executions (requires auditd) ###################################################################### if [[ $INSTALL_AUDIT_RULE -eq 1 ]]; then - if command -v auditctl > /dev/null 2>&1; then - audit_rule_str="-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock" - audit_rule_args=(-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock) - if auditctl -l 2> /dev/null | grep -Fq "/usr/local/sbin/unlock-hosts"; then - note "Audit rule already present" - else - run auditctl "${audit_rule_args[@]}" || warn "Failed to add audit rule (runtime)" - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would create /etc/audit/rules.d/hosts_unlock.rules" - else - echo "$audit_rule_str" > /etc/audit/rules.d/hosts_unlock.rules - fi - fi - else - warn "auditctl not found; skipping audit rule (install auditd to enable)" - fi + if command -v auditctl >/dev/null 2>&1; then + audit_rule_str="-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock" + audit_rule_args=(-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock) + if auditctl -l 2>/dev/null | grep -Fq "/usr/local/sbin/unlock-hosts"; then + note "Audit rule already present" + else + run auditctl "${audit_rule_args[@]}" || warn "Failed to add audit rule (runtime)" + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would create /etc/audit/rules.d/hosts_unlock.rules" + else + echo "$audit_rule_str" >/etc/audit/rules.d/hosts_unlock.rules + fi + fi + else + warn "auditctl not found; skipping audit rule (install auditd to enable)" + fi fi ###################################################################### @@ -353,6 +367,8 @@ msg "Deploying systemd units" run install -m 644 "$UNIT_GUARD_SERVICE" "$SYSTEMD_DIR/hosts-guard.service" run install -m 644 "$UNIT_GUARD_PATH" "$SYSTEMD_DIR/hosts-guard.path" run install -m 644 "$UNIT_BIND_SERVICE" "$SYSTEMD_DIR/hosts-bind-mount.service" +run install -m 644 "$UNIT_NSSWITCH_SERVICE" "$SYSTEMD_DIR/nsswitch-guard.service" +run install -m 644 "$UNIT_NSSWITCH_PATH" "$SYSTEMD_DIR/nsswitch-guard.path" if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi @@ -360,24 +376,51 @@ if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi # Enable / Start ###################################################################### if [[ $ENABLE_PATH -eq 1 ]]; then - msg "Enabling path watch (auto-revert)" - run systemctl enable --now hosts-guard.path + msg "Enabling path watch (auto-revert)" + run systemctl enable --now hosts-guard.path else - note "Skipping path watch (--skip-path-watch)" + note "Skipping path watch (--skip-path-watch)" fi if [[ $ENABLE_BIND -eq 1 ]]; then - msg "Enabling read-only bind mount" - run systemctl enable --now hosts-bind-mount.service + msg "Enabling read-only bind mount" + run systemctl enable --now hosts-bind-mount.service else - note "Skipping bind mount (--skip-bind)" + note "Skipping bind mount (--skip-bind)" fi -msg "Performing initial enforcement" -if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would run $INSTALL_ENFORCE" +if [[ $ENABLE_NSSWITCH -eq 1 ]]; then + msg "Enabling nsswitch.conf protection (hosts bypass prevention)" + msg "Installing nsswitch enforcement script -> $INSTALL_ENFORCE_NSSWITCH" + run install -m 755 "$TEMPLATE_ENFORCE_NSSWITCH" "$INSTALL_ENFORCE_NSSWITCH" + + # Create nsswitch canonical snapshot if needed + if [[ -f "$NSSWITCH" ]]; then + if [[ ! -f "$CANON_NSSWITCH" ]]; then + msg "Creating canonical nsswitch.conf snapshot at $CANON_NSSWITCH" + run cp "$NSSWITCH" "$CANON_NSSWITCH" + run chmod 644 "$CANON_NSSWITCH" + chattr +i "$CANON_NSSWITCH" 2>/dev/null || warn "Failed to protect canonical nsswitch copy" + fi + fi + + run systemctl enable --now nsswitch-guard.path + + # Perform initial nsswitch enforcement + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would run $INSTALL_ENFORCE_NSSWITCH" + else + "$INSTALL_ENFORCE_NSSWITCH" || warn "nsswitch enforcement returned non-zero" + fi else - "$INSTALL_ENFORCE" || warn "Enforcement returned non-zero" + note "Skipping nsswitch protection (--skip-nsswitch)" +fi + +msg "Performing initial hosts enforcement" +if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would run $INSTALL_ENFORCE" +else + "$INSTALL_ENFORCE" || warn "Enforcement returned non-zero" fi ###################################################################### @@ -385,12 +428,15 @@ fi ###################################################################### echo msg "Hosts guard setup complete" -echo "Canonical copy: $CANON" +echo "Canonical hosts copy: $CANON" +echo "Canonical nsswitch copy: $CANON_NSSWITCH" echo "Enforce script: $INSTALL_ENFORCE" +echo "nsswitch enforce: $INSTALL_ENFORCE_NSSWITCH" echo "Unlock command: sudo $INSTALL_UNLOCK" echo "Delay (seconds): $DELAY" echo "Auto-revert path watch: $([[ $ENABLE_PATH -eq 1 ]] && echo enabled || echo disabled)" echo "Read-only bind mount: $([[ $ENABLE_BIND -eq 1 ]] && echo enabled || echo disabled)" +echo "nsswitch protection: $([[ $ENABLE_NSSWITCH -eq 1 ]] && echo enabled || echo disabled)" echo "Shell history suppression: $([[ $INSTALL_SHELL_HOOKS -eq 1 ]] && echo enabled || echo disabled)" echo "Audit rule: $([[ $INSTALL_AUDIT_RULE -eq 1 ]] && echo enabled || echo disabled)" echo "Alias stub: $([[ $ADD_ALIAS_STUB -eq 1 ]] && echo enabled || echo disabled)" diff --git a/hosts/install.sh b/hosts/install.sh index 0d2d902..98ba020 100755 --- a/hosts/install.sh +++ b/hosts/install.sh @@ -2,7 +2,7 @@ # Re-run with sudo if not root if [[ $EUID -ne 0 ]]; then - exec sudo -E bash "$0" "$@" + exec sudo -E bash "$0" "$@" fi # Options @@ -11,18 +11,18 @@ FLUSH_DNS=0 # Parse CLI flags for arg in "$@"; do - case "$arg" in - --flush-dns) - FLUSH_DNS=1 - ;; - --no-flush-dns) - FLUSH_DNS=0 - ;; - -h | --help) - echo "Usage: $0 [--flush-dns|--no-flush-dns]" - exit 0 - ;; - esac + case "$arg" in + --flush-dns) + FLUSH_DNS=1 + ;; + --no-flush-dns) + FLUSH_DNS=0 + ;; + -h | --help) + echo "Usage: $0 [--flush-dns|--no-flush-dns]" + exit 0 + ;; + esac done # ============================================================================ @@ -39,136 +39,136 @@ CUSTOM_ENTRIES_STATE_FILE="/etc/hosts.custom-entries.state" # Extract custom blocked entries from a hosts file or heredoc section # Returns only the "0.0.0.0 domain.com" lines (normalized, sorted, unique) extract_custom_entries_from_script() { - # Extract entries from the heredoc in this script (between EOF markers after "Custom blocking entries") - local script_path="$1" - sed -n '/^# Custom blocking entries$/,/^EOF$/p' "$script_path" | - grep -E '^0\.0\.0\.0[[:space:]]+' | - awk '{print $2}' | - sort -u + # Extract entries from the heredoc in this script (between EOF markers after "Custom blocking entries") + local script_path="$1" + sed -n '/^# Custom blocking entries$/,/^EOF$/p' "$script_path" | + grep -E '^0\.0\.0\.0[[:space:]]+' | + awk '{print $2}' | + sort -u } # Extract custom entries from the current /etc/hosts (entries after "# Custom blocking entries" marker) extract_custom_entries_from_hosts() { - local hosts_file="$1" - if [[ ! -f $hosts_file ]]; then - return - fi - sed -n '/^# Custom blocking entries$/,$p' "$hosts_file" | - grep -E '^0\.0\.0\.0[[:space:]]+' | - awk '{print $2}' | - sort -u + local hosts_file="$1" + if [[ ! -f $hosts_file ]]; then + return + fi + sed -n '/^# Custom blocking entries$/,$p' "$hosts_file" | + grep -E '^0\.0\.0\.0[[:space:]]+' | + awk '{print $2}' | + sort -u } # Load previously saved custom entries state load_saved_custom_entries() { - if [[ -f $CUSTOM_ENTRIES_STATE_FILE ]]; then - sort -u "$CUSTOM_ENTRIES_STATE_FILE" - fi + if [[ -f $CUSTOM_ENTRIES_STATE_FILE ]]; then + sort -u "$CUSTOM_ENTRIES_STATE_FILE" + fi } # Save current custom entries to state file save_custom_entries_state() { - local entries="$1" - echo "$entries" | sort -u > "$CUSTOM_ENTRIES_STATE_FILE" - chmod 644 "$CUSTOM_ENTRIES_STATE_FILE" - chattr +i "$CUSTOM_ENTRIES_STATE_FILE" 2> /dev/null || true + local entries="$1" + echo "$entries" | sort -u >"$CUSTOM_ENTRIES_STATE_FILE" + chmod 644 "$CUSTOM_ENTRIES_STATE_FILE" + chattr +i "$CUSTOM_ENTRIES_STATE_FILE" 2>/dev/null || true } # Helper function to count non-empty lines count_lines() { - local input="$1" - if [[ -z $input ]]; then - echo 0 - else - echo "$input" | grep -c . 2> /dev/null || echo 0 - fi + local input="$1" + if [[ -z $input ]]; then + echo 0 + else + echo "$input" | grep -c . 2>/dev/null || echo 0 + fi } # Main protection check check_custom_entries_protection() { - local script_path - script_path="$(readlink -f "$0")" + local script_path + script_path="$(readlink -f "$0")" - # Get new entries from the script's heredoc - local new_entries - new_entries=$(extract_custom_entries_from_script "$script_path") - local new_count - new_count=$(count_lines "$new_entries") + # Get new entries from the script's heredoc + local new_entries + new_entries=$(extract_custom_entries_from_script "$script_path") + local new_count + new_count=$(count_lines "$new_entries") - # Get saved/existing entries (prefer state file, fall back to current /etc/hosts) - local saved_entries - saved_entries=$(load_saved_custom_entries) - if [[ -z $saved_entries ]]; then - # First run or state file missing - extract from current /etc/hosts if it has our marker - saved_entries=$(extract_custom_entries_from_hosts "/etc/hosts") - fi - local saved_count - saved_count=$(count_lines "$saved_entries") + # Get saved/existing entries (prefer state file, fall back to current /etc/hosts) + local saved_entries + saved_entries=$(load_saved_custom_entries) + if [[ -z $saved_entries ]]; then + # First run or state file missing - extract from current /etc/hosts if it has our marker + saved_entries=$(extract_custom_entries_from_hosts "/etc/hosts") + fi + local saved_count + saved_count=$(count_lines "$saved_entries") - # If no saved state exists, this is first installation - allow it - if [[ $saved_count -eq 0 ]]; then - echo "ℹ️ First installation detected - no protection check needed." - return 0 - fi + # If no saved state exists, this is first installation - allow it + if [[ $saved_count -eq 0 ]]; then + echo "ℹ️ First installation detected - no protection check needed." + return 0 + fi - # Find entries that were removed - local removed_entries - removed_entries=$(comm -23 <(echo "$saved_entries") <(echo "$new_entries")) - local removed_count - removed_count=$(count_lines "$removed_entries") + # Find entries that were removed + local removed_entries + removed_entries=$(comm -23 <(echo "$saved_entries") <(echo "$new_entries")) + local removed_count + removed_count=$(count_lines "$removed_entries") - # Find entries that are new - local added_entries - added_entries=$(comm -13 <(echo "$saved_entries") <(echo "$new_entries")) - local added_count - added_count=$(count_lines "$added_entries") + # Find entries that are new + local added_entries + added_entries=$(comm -13 <(echo "$saved_entries") <(echo "$new_entries")) + local added_count + added_count=$(count_lines "$added_entries") - echo "" - echo "📊 Custom Entries Protection Check:" - echo " Previously blocked: $saved_count entries" - echo " Currently in script: $new_count entries" - echo " Removed: $removed_count | Added: $added_count" + echo "" + echo "📊 Custom Entries Protection Check:" + echo " Previously blocked: $saved_count entries" + echo " Currently in script: $new_count entries" + echo " Removed: $removed_count | Added: $added_count" - # RULE 1: No entries removed - always OK - if [[ $removed_count -eq 0 ]]; then - echo " ✅ No entries removed - protection check passed." - return 0 - fi + # RULE 1: No entries removed - always OK + if [[ $removed_count -eq 0 ]]; then + echo " ✅ No entries removed - protection check passed." + return 0 + fi - # RULE 2: Entries were removed - BLOCK INSTALLATION - echo "" - echo "============================================================" - echo " ❌ INSTALLATION BLOCKED - CUSTOM ENTRIES REMOVED" - echo "============================================================" - echo "" - echo "You are attempting to REMOVE the following blocked entries:" - while IFS= read -r entry; do - echo " - $entry" - done <<< "$removed_entries" - echo "" - echo "This is NOT allowed. The only way to unblock sites is to:" - echo "" - echo " 1. Manually edit /etc/hosts (requires removing chattr protection)" - echo " 2. Delete the state file /etc/hosts.custom-entries.state" - echo " (also protected with chattr)" - echo "" - echo "These manual steps are intentionally difficult to prevent" - echo "impulsive unblocking. If you really need to unblock something," - echo "you'll have to work for it." - echo "" - return 1 + # RULE 2: Entries were removed - BLOCK INSTALLATION + echo "" + echo "============================================================" + echo " ❌ INSTALLATION BLOCKED - CUSTOM ENTRIES REMOVED" + echo "============================================================" + echo "" + echo "You are attempting to REMOVE the following blocked entries:" + while IFS= read -r entry; do + echo " - $entry" + done <<<"$removed_entries" + echo "" + echo "This is NOT allowed. The only way to unblock sites is to:" + echo "" + echo " 1. Manually edit /etc/hosts (requires removing chattr protection)" + echo " 2. Delete the state file /etc/hosts.custom-entries.state" + echo " (also protected with chattr)" + echo "" + echo "These manual steps are intentionally difficult to prevent" + echo "impulsive unblocking. If you really need to unblock something," + echo "you'll have to work for it." + echo "" + return 1 } # Run the protection check if ! check_custom_entries_protection; then - exit 1 + exit 1 fi # Enable systemd-resolved sudo systemctl enable systemd-resolved # Remove all attributes from /etc/hosts to allow modifications -sudo chattr -i -a /etc/hosts 2> /dev/null || true +sudo chattr -i -a /etc/hosts 2>/dev/null || true # Source and local cache configuration URL="https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-gambling-porn-social/hosts" @@ -177,33 +177,33 @@ LOCAL_CACHE="/etc/hosts.stevenblack" # Helpers extract_date_epoch_from_file() { - # Grep "# Date:" line and convert to epoch seconds (UTC) - local f="$1" - local line - line=$(grep -m1 '^# Date:' "$f" 2> /dev/null | sed -E 's/^# Date:[[:space:]]*(.*)[[:space:]]*\(UTC\).*/\1 UTC/') - if [[ -n $line ]]; then - date -u -d "$line" +%s 2> /dev/null || echo "" - else - echo "" - fi + # Grep "# Date:" line and convert to epoch seconds (UTC) + local f="$1" + local line + line=$(grep -m1 '^# Date:' "$f" 2>/dev/null | sed -E 's/^# Date:[[:space:]]*(.*)[[:space:]]*\(UTC\).*/\1 UTC/') + if [[ -n $line ]]; then + date -u -d "$line" +%s 2>/dev/null || echo "" + else + echo "" + fi } fetch_remote_header() { - # Try to fetch only the first ~4KB using HTTP Range; fallback to piping to head - local out="$1" - if curl -LfsS --max-time 10 -H 'Range: bytes=0-4095' "$URL" -o "$out"; then - return 0 - fi - # Fallback – may download more, but we only keep first lines - if curl -LfsS --max-time 10 "$URL" | head -n 20 > "$out"; then - return 0 - fi - return 1 + # Try to fetch only the first ~4KB using HTTP Range; fallback to piping to head + local out="$1" + if curl -LfsS --max-time 10 -H 'Range: bytes=0-4095' "$URL" -o "$out"; then + return 0 + fi + # Fallback – may download more, but we only keep first lines + if curl -LfsS --max-time 10 "$URL" | head -n 20 >"$out"; then + return 0 + fi + return 1 } download_remote_full_to() { - local out="$1" - curl -LfsS "$URL" -o "$out" + local out="$1" + curl -LfsS "$URL" -o "$out" } # Decide whether to use cache or update @@ -212,47 +212,47 @@ trap 'rm -f "$TMP_REMOTE_HEAD"' EXIT REMOTE_AVAILABLE=0 if fetch_remote_header "$TMP_REMOTE_HEAD"; then - REMOTE_AVAILABLE=1 + REMOTE_AVAILABLE=1 fi NEED_UPDATE=0 if [[ -f $LOCAL_CACHE ]]; then - local_epoch=$(extract_date_epoch_from_file "$LOCAL_CACHE") + local_epoch=$(extract_date_epoch_from_file "$LOCAL_CACHE") else - local_epoch="" + local_epoch="" fi if [[ $REMOTE_AVAILABLE -eq 1 ]]; then - remote_epoch=$(extract_date_epoch_from_file "$TMP_REMOTE_HEAD") - if [[ -n $local_epoch && -n $remote_epoch && $local_epoch -ge $remote_epoch ]]; then - echo "Using cached StevenBlack hosts (up-to-date)." - else - echo "Cached version is missing or outdated; downloading latest StevenBlack hosts..." - NEED_UPDATE=1 - fi + remote_epoch=$(extract_date_epoch_from_file "$TMP_REMOTE_HEAD") + if [[ -n $local_epoch && -n $remote_epoch && $local_epoch -ge $remote_epoch ]]; then + echo "Using cached StevenBlack hosts (up-to-date)." + else + echo "Cached version is missing or outdated; downloading latest StevenBlack hosts..." + NEED_UPDATE=1 + fi else - if [[ -f $LOCAL_CACHE ]]; then - echo "No internet; using cached StevenBlack hosts." - else - echo "Error: No internet and no cached StevenBlack hosts found." >&2 - exit 1 - fi + if [[ -f $LOCAL_CACHE ]]; then + echo "No internet; using cached StevenBlack hosts." + else + echo "Error: No internet and no cached StevenBlack hosts found." >&2 + exit 1 + fi fi # Ensure we have a fresh cache if needed if [[ $NEED_UPDATE -eq 1 ]]; then - TMP_DL=$(mktemp) - if download_remote_full_to "$TMP_DL"; then - # Save raw upstream to cache - sudo mv "$TMP_DL" "$LOCAL_CACHE" - sudo chmod 644 "$LOCAL_CACHE" - echo "Saved latest StevenBlack hosts to cache: $LOCAL_CACHE" - else - rm -f "$TMP_DL" - echo "Error: Failed to download latest StevenBlack hosts." >&2 - exit 1 - fi + TMP_DL=$(mktemp) + if download_remote_full_to "$TMP_DL"; then + # Save raw upstream to cache + sudo mv "$TMP_DL" "$LOCAL_CACHE" + sudo chmod 644 "$LOCAL_CACHE" + echo "Saved latest StevenBlack hosts to cache: $LOCAL_CACHE" + else + rm -f "$TMP_DL" + echo "Error: Failed to download latest StevenBlack hosts." >&2 + exit 1 + fi fi # Install the base hosts from cache into /etc/hosts @@ -272,7 +272,7 @@ sudo sed -i 's/^0\.0\.0\.0 messenger\.com/#0.0.0.0 messenger.com/' /etc/hosts # Add custom entries for YouTube and Discord echo "Adding custom entries for YouTube and Discord..." -tee -a /etc/hosts > /dev/null << 'EOF' +tee -a /etc/hosts >/dev/null <<'EOF' # Custom blocking entries # YouTube @@ -295,15 +295,15 @@ tee -a /etc/hosts > /dev/null << 'EOF' # Steam Store -# Discord (selective blocking - media only, voice chat allowed) -0.0.0.0 cdn.discordapp.com -0.0.0.0 media.discordapp.net -0.0.0.0 images-ext-1.discordapp.net -0.0.0.0 images-ext-2.discordapp.net -0.0.0.0 attachments-1.discordapp.net -0.0.0.0 attachments-2.discordapp.net -0.0.0.0 tenor.com -0.0.0.0 giphy.com +# Discord - media allowed +# 0.0.0.0 cdn.discordapp.com +# 0.0.0.0 media.discordapp.net +# 0.0.0.0 images-ext-1.discordapp.net +# 0.0.0.0 images-ext-2.discordapp.net +# 0.0.0.0 attachments-1.discordapp.net +# 0.0.0.0 attachments-2.discordapp.net +# 0.0.0.0 tenor.com +# 0.0.0.0 giphy.com # Food Delivery Services # Polish services @@ -407,20 +407,111 @@ echo "Saving custom entries state for protection mechanism..." script_path="$(readlink -f "$0")" current_custom_entries=$(extract_custom_entries_from_script "$script_path") # Remove immutable from state file if it exists -chattr -i "$CUSTOM_ENTRIES_STATE_FILE" 2> /dev/null || true +chattr -i "$CUSTOM_ENTRIES_STATE_FILE" 2>/dev/null || true save_custom_entries_state "$current_custom_entries" echo "✅ Custom entries state saved to $CUSTOM_ENTRIES_STATE_FILE" # Optionally flush DNS caches if [[ $FLUSH_DNS -eq 1 ]]; then - echo "Flushing DNS caches..." - sudo systemd-resolve --flush-caches - sudo systemctl restart NetworkManager.service + echo "Flushing DNS caches..." + sudo systemd-resolve --flush-caches + sudo systemctl restart NetworkManager.service else - echo "DNS cache flush skipped (use --flush-dns to enable)." + echo "DNS cache flush skipped (use --flush-dns to enable)." +fi + +# ============================================================================ +# DISABLE DNS OVER HTTPS (DoH) IN BROWSERS +# ============================================================================ +# DoH bypasses /etc/hosts entirely, defeating all our blocking! +# We disable it in Firefox profiles for all users. +echo "" +echo "Disabling DNS over HTTPS (DoH) in browsers..." + +# Get the actual user (not root) who invoked this script +REAL_USER="${SUDO_USER:-$USER}" +REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6) + +# Firefox: disable DoH via user.js +if [[ -d "$REAL_HOME/.mozilla/firefox" ]]; then + for profile in "$REAL_HOME/.mozilla/firefox"/*.default*; do + if [[ -d "$profile" ]]; then + cat >>"$profile/user.js" <<'FIREFOXEOF' +// Disable DNS over HTTPS (DoH) to ensure /etc/hosts blocking works +// Added by linux-configuration hosts installer +user_pref("network.trr.mode", 5); // 5 = Off by user choice +user_pref("doh-rollout.enabled", false); +user_pref("doh-rollout.disable-heuristics", true); +FIREFOXEOF + chown "$REAL_USER:$REAL_USER" "$profile/user.js" + echo " Firefox DoH disabled in: $(basename "$profile")" + fi + done +else + echo " No Firefox profiles found" +fi + +# Chromium-based browsers: use policy file +CHROME_POLICY_DIR="/etc/chromium/policies/managed" +if [[ -d "/etc/chromium" ]] || command -v chromium &>/dev/null; then + mkdir -p "$CHROME_POLICY_DIR" + cat >"$CHROME_POLICY_DIR/disable-doh.json" <<'CHROMEEOF' +{ + "DnsOverHttpsMode": "off", + "BuiltInDnsClientEnabled": false +} +CHROMEEOF + echo " Chromium DoH disabled via policy" +fi + +# Google Chrome policy +GCHROME_POLICY_DIR="/etc/opt/chrome/policies/managed" +if [[ -d "/etc/opt/chrome" ]] || command -v google-chrome &>/dev/null; then + mkdir -p "$GCHROME_POLICY_DIR" + cat >"$GCHROME_POLICY_DIR/disable-doh.json" <<'GCHROMEEOF' +{ + "DnsOverHttpsMode": "off", + "BuiltInDnsClientEnabled": false +} +GCHROMEEOF + echo " Google Chrome DoH disabled via policy" fi echo "" echo "✅ Installation complete!" echo " Custom entries protection is now active." echo " Removing blocked entries from the script will be blocked." +echo " DNS over HTTPS (DoH) has been disabled in browsers." + +# ============================================================================ +# FORCE BROWSER RESTART TO APPLY DOH CHANGES +# ============================================================================ +# Kill all browser processes so DoH changes take effect immediately +echo "" +echo "Killing browsers to apply DoH policy changes..." +BROWSERS_KILLED=0 + +for browser in chrome chromium chromium-browser brave brave-browser firefox firefox-esr thorium vivaldi opera; do + if pgrep -x "$browser" &>/dev/null || pgrep -f "/opt/.*/$browser" &>/dev/null; then + echo " Killing $browser..." + pkill -9 -f "$browser" 2>/dev/null || true + BROWSERS_KILLED=1 + fi +done + +# Also kill by common binary paths +for pattern in "/opt/google/chrome" "/opt/brave" "/opt/thorium" "/usr/lib/firefox" "/usr/lib/chromium"; do + if pgrep -f "$pattern" &>/dev/null; then + echo " Killing processes matching $pattern..." + pkill -9 -f "$pattern" 2>/dev/null || true + BROWSERS_KILLED=1 + fi +done + +if [[ $BROWSERS_KILLED -eq 1 ]]; then + echo "" + echo "⚠️ Browsers were killed to apply DNS settings." + echo " Reopen your browser - hosts blocking is now enforced." +else + echo " No browsers were running." +fi diff --git a/scripts/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md b/scripts/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md new file mode 100644 index 0000000..33b6265 --- /dev/null +++ b/scripts/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md @@ -0,0 +1,234 @@ +# Block Compulsive Opening - LLM Reference Guide + +> **For AI assistants**: This document explains the compulsive opening blocker so you can make correct modifications. + +## System Purpose + +Limit messaging apps (Beeper, Signal, Discord) to **one launch per hour** to reduce compulsive checking behavior. + +## How It Works + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ LAUNCH INTERCEPTION │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ User clicks "Discord" in app launcher │ +│ ↓ │ +│ /usr/bin/discord (wrapper script) │ +│ ↓ │ +│ exec /usr/local/bin/block-compulsive-opening.sh wrapper discord │ +│ ↓ │ +│ Check: ~/.local/state/compulsive-block/discord.lastopen │ +│ ↓ │ +│ ┌─────────────────┴─────────────────┐ │ +│ │ │ │ +│ ▼ Not opened this hour ▼ Already opened │ +│ Record opening time Show notification │ +│ Launch real binary Exit with error │ +│ /opt/discord/Discord │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## File Locations + +| File | Purpose | +|------|---------| +| `/usr/local/bin/block-compulsive-opening.sh` | Installed main script | +| `/usr/bin/beeper` | Wrapper (replaces original) | +| `/usr/bin/signal-desktop` | Wrapper (replaces original) | +| `/usr/bin/discord` | Wrapper (replaces original) | +| `/usr/bin/*.orig` or `SYMLINK:*` | Original binaries/links | +| `~/.local/state/compulsive-block/*.lastopen` | Per-app hour tracking | +| `~/.local/state/compulsive-block/compulsive-block.log` | Activity log | +| `/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook` | Auto-rewrap hook | + +## Managed Applications + +```bash +declare -A APPS=( + ["beeper"]="/usr/bin/beeper" + ["signal-desktop"]="/usr/bin/signal-desktop" + ["discord"]="/usr/bin/discord" +) + +declare -A REAL_BINARIES=( + ["beeper"]="/opt/beeper/beepertexts" + ["signal-desktop"]="/usr/lib/signal-desktop/signal-desktop" + ["discord"]="/opt/discord/Discord" +) +``` + +## State Tracking + +Hour key format: `YYYY-MM-DD-HH` (e.g., `2026-02-02-14`) + +State file content: Just the hour key string + +```bash +# Check if opened this hour +cat ~/.local/state/compulsive-block/discord.lastopen +# Output: 2026-02-02-14 + +# Current hour +date '+%Y-%m-%d-%H' +# Output: 2026-02-02-15 (different = can open again) +``` + +## Wrapper Installation Process + +When `install_all()` runs: + +1. Copies script to `/usr/local/bin/block-compulsive-opening.sh` +2. For each app: + - If original is a symlink: Save `SYMLINK:/target/path` to `.orig` + - If original is a file: Move to `.orig` + - Create wrapper script at original location: + ```bash + #!/bin/bash + exec /usr/local/bin/block-compulsive-opening.sh wrapper "discord" "$@" + ``` +3. Install pacman hook for auto-rewrap + +## Pacman Hook + +After beeper/signal/discord package updates, the hook re-wraps them: + +```ini +[Trigger] +Operation = Upgrade +Operation = Install +Type = Package +Target = beeper +Target = signal-desktop +Target = discord + +[Action] +When = PostTransaction +Exec = /usr/local/bin/block-compulsive-opening.sh rewrap-quiet +``` + +The `rewrap-quiet` command: +- Checks if wrapper was overwritten (doesn't contain "block-compulsive-opening") +- If overwritten: removes stale `.orig`, re-installs wrapper +- Logs to activity log + +## Commands + +```bash +# Install all wrappers (requires root) +sudo ./block_compulsive_opening.sh install + +# Uninstall all wrappers (requires root) +sudo ./block_compulsive_opening.sh uninstall + +# Check status of all apps +./block_compulsive_opening.sh status + +# Reset a specific app (allow opening again this hour) +./block_compulsive_opening.sh reset discord + +# Reset all apps +./block_compulsive_opening.sh reset-all +``` + +## Log Format + +``` +2026-02-02 14:30:15 - ALLOWED: discord opened (first time this hour: 2026-02-02-14) +2026-02-02 14:30:15 - LAUNCHED: discord with PID 12345 (auto-close in 10m) +2026-02-02 14:38:15 - (notification: "Session will end in 2 minutes") +2026-02-02 14:40:15 - AUTO-CLOSED: discord (PID 12345) after 10m +2026-02-02 14:45:22 - BLOCKED: discord launch prevented (already opened this hour: 2026-02-02-14) +2026-02-02 15:01:03 - ALLOWED: discord opened (first time this hour: 2026-02-02-15) +2026-02-02 15:30:00 - RESET: discord state cleared by user +``` + +## Auto-Close Timer (Session Limit) + +Apps are automatically closed after **10 minutes** to prevent indefinite usage: + +1. When app launches, a background daemon is spawned +2. At **8 minutes**: Warning notification "Session will end in 2 minutes" +3. At **10 minutes**: App is closed with SIGTERM, then SIGKILL if needed +4. State file `~/.local/state/compulsive-block/.running` tracks PID and start time + +**Configuration variables** (in script): +```bash +AUTO_CLOSE_TIMEOUT_MINUTES=10 # Total session length +AUTO_CLOSE_WARNING_MINUTES=2 # Warning before close +``` + +## Adding a New App + +1. Add to `APPS` associative array: +```bash +declare -A APPS=( + # ... existing apps ... + ["newapp"]="/usr/bin/newapp" +) +``` + +2. Add to `REAL_BINARIES`: +```bash +declare -A REAL_BINARIES=( + # ... existing apps ... + ["newapp"]="/opt/newapp/actual-binary" +) +``` + +3. Add to pacman hook targets (if installed via pacman): +```ini +Target = newapp +``` + +4. Reinstall: +```bash +sudo ./block_compulsive_opening.sh install +``` + +## Debugging + +### Check if wrapper is installed +```bash +cat /usr/bin/discord +# Should show wrapper script, not binary + +ls -la /usr/bin/discord.orig +# Should exist (or check for SYMLINK: content) +``` + +### Check current state +```bash +./block_compulsive_opening.sh status +# Shows: which apps are wrapped, last open times, current hour +``` + +### Test manually +```bash +# Simulate wrapper call +/usr/local/bin/block-compulsive-opening.sh wrapper discord +``` + +### View logs +```bash +tail -f ~/.local/state/compulsive-block/compulsive-block.log +``` + +## Notification Behavior + +When blocked, shows desktop notification: +- Title: "🚫 discord Blocked" +- Message: "Already opened this hour. Wait until the next hour." +- Urgency: critical +- Timeout: 5000ms + +Uses `notify-send` (falls back silently if not available). + +## DO NOT + +1. ❌ Delete `.orig` files (cannot restore original binaries) +2. ❌ Manually edit wrapper scripts at `/usr/bin/` (will be overwritten) +3. ❌ Assume app is "blocked" once notification shows (it ran, just not again) +4. ❌ Remove pacman hook without understanding auto-rewrap won't work diff --git a/scripts/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md b/scripts/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md new file mode 100644 index 0000000..170bb78 --- /dev/null +++ b/scripts/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md @@ -0,0 +1,277 @@ +# Midnight Shutdown System - LLM Reference Guide + +> **For AI assistants**: This document explains the automatic shutdown system so you can make correct modifications. + +## System Purpose + +Automatically shut down the PC during configured time windows to enforce healthy sleep schedules: +- **Monday-Wednesday**: Shutdown at 24:00 (midnight) +- **Thursday-Sunday**: Shutdown at 24:00 (midnight) +- **Morning**: Safe time starts at 00:00 (effectively no morning block) + +The times above are defaults; actual values in `/etc/shutdown-schedule.conf`. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ SHUTDOWN SYSTEM LAYERS │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Layer 1: Systemd Timer │ +│ ───────────────────── │ +│ day-specific-shutdown.timer fires every minute │ +│ day-specific-shutdown.service runs the check script │ +│ │ +│ Layer 2: Check Script │ +│ ──────────────────── │ +│ /usr/local/bin/day-specific-shutdown-check.sh │ +│ Reads config, checks current time, initiates shutdown if in window │ +│ │ +│ Layer 3: Config Protection │ +│ ──────────────────────── │ +│ /etc/shutdown-schedule.conf has chattr +i │ +│ Canonical copy at /usr/local/share/locked-shutdown-schedule.conf │ +│ Path watcher auto-restores if tampered │ +│ │ +│ Layer 4: Timer Monitor │ +│ ───────────────────── │ +│ shutdown-timer-monitor.service watches timer status │ +│ Re-enables timer if user tries to disable it │ +│ │ +│ Layer 5: Script Protection │ +│ ──────────────────────── │ +│ Setup script blocks making schedule MORE LENIENT │ +│ Can only make it STRICTER without the unlock script │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## File Locations + +| File | Purpose | Protection | +|------|---------|------------| +| `/etc/shutdown-schedule.conf` | Runtime config | chattr +i, path watcher | +| `/usr/local/share/locked-shutdown-schedule.conf` | Canonical copy | chattr +i | +| `/usr/local/bin/day-specific-shutdown-check.sh` | Shutdown logic | None | +| `/usr/local/bin/day-specific-shutdown-manager.sh` | Status/management | None | +| `/usr/local/bin/shutdown-timer-monitor.sh` | Timer re-enabler | None | +| `/usr/local/sbin/enforce-shutdown-schedule.sh` | Config restoration | None | +| `/usr/local/sbin/unlock-shutdown-schedule` | Delayed config edit | None | +| `/etc/systemd/system/day-specific-shutdown.timer` | Timer unit | systemd | +| `/etc/systemd/system/day-specific-shutdown.service` | Service unit | systemd | +| `/etc/systemd/system/shutdown-schedule-guard.path` | Config watcher | systemd | +| `/etc/systemd/system/shutdown-schedule-guard.service` | Enforcement | systemd | +| `/etc/systemd/system/shutdown-timer-monitor.service` | Timer guardian | systemd | +| `/var/log/shutdown-schedule-guard.log` | Tampering log | None | + +## Config File Format + +```bash +# /etc/shutdown-schedule.conf + +# Shutdown hour for Monday-Wednesday (24-hour format) +MON_WED_HOUR=21 + +# Shutdown hour for Thursday-Sunday (24-hour format) +THU_SUN_HOUR=22 + +# Morning end hour (shutdown window ends at this hour) +MORNING_END_HOUR=5 +``` + +**Interpretation**: +- Mon-Wed: Shutdown if current hour >= 21 OR current hour < 5 +- Thu-Sun: Shutdown if current hour >= 22 OR current hour < 5 + +## Schedule Protection Logic + +The setup script (`setup_midnight_shutdown.sh`) has constants at the top: +```bash +SCHEDULE_MON_WED_HOUR=24 +SCHEDULE_THU_SUN_HOUR=24 +SCHEDULE_MORNING_END_HOUR=0 +``` + +When re-run, it compares these to the canonical config: + +| Change Type | Action | +|-------------|--------| +| Making shutdown EARLIER | ✅ Allowed without unlock | +| Making shutdown LATER | ❌ Blocked, requires unlock | +| Making morning end EARLIER | ❌ Always blocked | +| Making morning end LATER | ✅ Allowed (extends shutdown window) | + +Example blocked attempt: +``` +╔══════════════════════════════════════════════════════════════════╗ +║ ❌ SCHEDULE MODIFICATION BLOCKED - CHEATING DETECTED! ❌ ║ +╚══════════════════════════════════════════════════════════════════╝ + +You modified the script to make the shutdown schedule MORE LENIENT: + • Mon-Wed shutdown: 21:00 → 23:00 (later) + +Nice try! But this is exactly the kind of late-night bargaining +that this protection is designed to prevent. 😉 +``` + +## Unlock Script Behavior + +`/usr/local/sbin/unlock-shutdown-schedule`: + +1. Stops `shutdown-schedule-guard.path` +2. Removes chattr from both config files +3. Opens editor on temp copy +4. Checks what changed: + - **Stricter (earlier)**: No delay, applies immediately + - **Lenient (later)**: 45-second countdown, then applies + - **Lower morning end**: **ALWAYS BLOCKED** (cannot shorten window) +5. Updates both config and canonical +6. Re-applies chattr +i +7. Restarts path watcher + +## Integration Points + +### i3blocks Countdown +`i3blocks/shutdown_countdown.sh` reads the config to show time remaining: +```bash +source /etc/shutdown-schedule.conf +# Calculates and displays "Shutdown in X:XX" +``` + +### Screen Locker +`screen_lock.py` can adjust shutdown time: +- **Sick day**: Moves shutdown 1.5 hours EARLIER (penalty) +- **Workout completed**: Moves shutdown 1.5 hours LATER (reward) + +Uses `adjust_shutdown_schedule.sh` helper script. + +## Systemd Units + +### Timer (fires every minute) +```ini +[Timer] +OnCalendar=*:*:00 +Persistent=false +AccuracySec=1s +``` + +### Check Service +```ini +[Service] +Type=oneshot +ExecStart=/usr/local/bin/day-specific-shutdown-check.sh +``` + +### Path Watcher +```ini +[Path] +PathChanged=/etc/shutdown-schedule.conf +Unit=shutdown-schedule-guard.service +``` + +## Check Script Logic + +```bash +# Pseudocode for day-specific-shutdown-check.sh + +source /etc/shutdown-schedule.conf +day=$(date +%u) # 1=Monday, 7=Sunday +hour=$(date +%H) + +if [[ $day -le 3 ]]; then + shutdown_hour=$MON_WED_HOUR +else + shutdown_hour=$THU_SUN_HOUR +fi + +# Check if in shutdown window +if [[ $hour -ge $shutdown_hour ]] || [[ $hour -lt $MORNING_END_HOUR ]]; then + systemctl poweroff +fi +``` + +## Common Tasks + +### Check Current Status +```bash +/usr/local/bin/day-specific-shutdown-manager.sh status +# Or run setup script with 'status' argument +``` + +### Make Schedule Stricter +Edit the constants in `setup_midnight_shutdown.sh`: +```bash +SCHEDULE_MON_WED_HOUR=20 # Changed from 21 to 20 (earlier) +``` +Then re-run: +```bash +sudo ./setup_midnight_shutdown.sh +``` + +### Make Schedule More Lenient (Requires Unlock) +```bash +sudo /usr/local/sbin/unlock-shutdown-schedule +# Wait for delay, edit config, save +``` + +### Disable Timer (Will Be Re-Enabled!) +```bash +sudo systemctl disable --now day-specific-shutdown.timer +# Monitor service will re-enable it automatically +``` + +### Check Protection Status +```bash +lsattr /etc/shutdown-schedule.conf +# Should show: ----i--------e-- + +systemctl status shutdown-schedule-guard.path +systemctl status shutdown-timer-monitor.service +``` + +## KNOWN VULNERABILITIES + +1. **Information Disclosure**: Error messages tell user exactly how to bypass +2. **Unlock Script Discoverable**: Path mentioned in error messages +3. **Timer Monitor Killable**: User can stop the monitor then the timer +4. **Check Script Unprotected**: `/usr/local/bin/day-specific-shutdown-check.sh` can be edited + +**TODO**: +- Remove helpful bypass instructions from error messages +- Rename unlock script to obscure name +- Protect check script with integrity verification + +## Troubleshooting + +### Timer not firing +```bash +systemctl status day-specific-shutdown.timer +systemctl list-timers | grep shutdown +``` + +### Config not being enforced +```bash +# Check path watcher +systemctl status shutdown-schedule-guard.path + +# Manually trigger enforcement +sudo /usr/local/sbin/enforce-shutdown-schedule.sh +``` + +### Wrong time shown in i3blocks +```bash +# Verify config +cat /etc/shutdown-schedule.conf + +# Check i3blocks config +cat ~/.config/i3blocks/config | grep shutdown +``` + +## DO NOT + +1. ❌ Edit setup script constants to make schedule later (will be blocked) +2. ❌ Delete canonical config (breaks restoration) +3. ❌ Stop `shutdown-timer-monitor.service` (timer will be re-enabled anyway) +4. ❌ Modify check script to skip shutdown (defeats purpose) +5. ❌ Lower `MORNING_END_HOUR` (always blocked, shortens shutdown window) diff --git a/scripts/digital_wellbeing/block_compulsive_opening.sh b/scripts/digital_wellbeing/block_compulsive_opening.sh index 426658b..cd3c6ba 100755 --- a/scripts/digital_wellbeing/block_compulsive_opening.sh +++ b/scripts/digital_wellbeing/block_compulsive_opening.sh @@ -12,182 +12,298 @@ set -euo pipefail # Send desktop notification (inlined from common.sh to avoid dependency issues # when script is installed to /usr/local/bin) notify() { - local title="$1" - local message="$2" - local urgency="${3:-normal}" - local timeout="${4:-5000}" + local title="$1" + local message="$2" + local urgency="${3:-normal}" + local timeout="${4:-5000}" - if command -v notify-send &> /dev/null; then - notify-send -u "$urgency" -t "$timeout" "$title" "$message" 2> /dev/null || true - fi + if command -v notify-send &>/dev/null; then + notify-send -u "$urgency" -t "$timeout" "$title" "$message" 2>/dev/null || true + fi } # Configuration STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/compulsive-block" LOG_FILE="$STATE_DIR/compulsive-block.log" +# Auto-close timeout in minutes (apps forcefully closed after this) +AUTO_CLOSE_TIMEOUT_MINUTES=10 +# Warning before auto-close (in minutes before timeout) +AUTO_CLOSE_WARNING_MINUTES=2 + # Apps to limit (name -> binary path) # These are the primary wrapper locations (what the user calls) declare -A APPS=( - ["beeper"]="/usr/bin/beeper" - ["signal-desktop"]="/usr/bin/signal-desktop" - ["discord"]="/usr/bin/discord" + ["beeper"]="/usr/bin/beeper" + ["signal-desktop"]="/usr/bin/signal-desktop" + ["discord"]="/usr/bin/discord" ) # Actual executable paths (the real binaries to exec after wrapper check) # These are where the real code lives declare -A REAL_BINARIES=( - ["beeper"]="/opt/beeper/beepertexts" - ["signal-desktop"]="/usr/lib/signal-desktop/signal-desktop" - ["discord"]="/opt/discord/Discord" + ["beeper"]="/opt/beeper/beepertexts" + ["signal-desktop"]="/usr/lib/signal-desktop/signal-desktop" + ["discord"]="/opt/discord/Discord" ) # Ensure state directory exists ensure_state_dir() { - mkdir -p "$STATE_DIR" 2> /dev/null || true + mkdir -p "$STATE_DIR" 2>/dev/null || true } # Log message with timestamp log_message() { - local msg - msg="$(date '+%Y-%m-%d %H:%M:%S') - $1" - echo "$msg" >&2 - echo "$msg" >> "$LOG_FILE" 2> /dev/null || true + local msg + msg="$(date '+%Y-%m-%d %H:%M:%S') - $1" + echo "$msg" >&2 + echo "$msg" >>"$LOG_FILE" 2>/dev/null || true } # Get current hour key (YYYY-MM-DD-HH format) get_hour_key() { - date '+%Y-%m-%d-%H' + date '+%Y-%m-%d-%H' } # Get state file path for an app get_state_file() { - local app="$1" - echo "$STATE_DIR/${app}.lastopen" + local app="$1" + echo "$STATE_DIR/${app}.lastopen" } # Check if app was already opened this hour was_opened_this_hour() { - local app="$1" - local state_file - state_file=$(get_state_file "$app") - local current_hour - current_hour=$(get_hour_key) + local app="$1" + local state_file + state_file=$(get_state_file "$app") + local current_hour + current_hour=$(get_hour_key) - if [[ -f $state_file ]]; then - local last_hour - last_hour=$(cat "$state_file" 2> /dev/null || echo "") - if [[ $last_hour == "$current_hour" ]]; then - return 0 # Was opened this hour - fi - fi - return 1 # Not opened this hour + if [[ -f $state_file ]]; then + local last_hour + last_hour=$(cat "$state_file" 2>/dev/null || echo "") + if [[ $last_hour == "$current_hour" ]]; then + return 0 # Was opened this hour + fi + fi + return 1 # Not opened this hour } # Record app opening record_opening() { - local app="$1" - local state_file - state_file=$(get_state_file "$app") - local current_hour - current_hour=$(get_hour_key) + local app="$1" + local state_file + state_file=$(get_state_file "$app") + local current_hour + current_hour=$(get_hour_key) - echo "$current_hour" > "$state_file" - log_message "ALLOWED: $app opened (first time this hour: $current_hour)" + echo "$current_hour" >"$state_file" + log_message "ALLOWED: $app opened (first time this hour: $current_hour)" } # Block app and notify block_app() { - local app="$1" - local current_hour - current_hour=$(get_hour_key) + local app="$1" + local current_hour + current_hour=$(get_hour_key) - log_message "BLOCKED: $app launch prevented (already opened this hour: $current_hour)" + log_message "BLOCKED: $app launch prevented (already opened this hour: $current_hour)" - # Send notification using common library - notify "🚫 $app Blocked" "Already opened this hour. Wait until the next hour." critical 5000 + # Send notification using common library + notify "🚫 $app Blocked" "Already opened this hour. Wait until the next hour." critical 5000 } # Get real binary path for an app get_real_binary() { - local app="$1" - local wrapper_path="${APPS[$app]}" - local real_binary="${REAL_BINARIES[$app]}" + local app="$1" + local wrapper_path="${APPS[$app]}" + local real_binary="${REAL_BINARIES[$app]}" - # Check if wrapper is installed (original moved to .orig) - if [[ -f "${wrapper_path}.orig" ]]; then - # Wrapper installed, return the actual executable - echo "$real_binary" - return 0 - fi + # Check if wrapper is installed (original moved to .orig) + if [[ -f "${wrapper_path}.orig" ]]; then + # Wrapper installed, return the actual executable + echo "$real_binary" + return 0 + fi - return 1 + return 1 +} + +# Get running state file path for an app (tracks PID and start time) +get_running_file() { + local app="$1" + echo "$STATE_DIR/${app}.running" +} + +# Clean up stale running state (process no longer running) +cleanup_stale_running_state() { + local app="$1" + local running_file + running_file=$(get_running_file "$app") + + if [[ ! -f $running_file ]]; then + return 0 + fi + + local pid + pid=$(awk '{print $1}' "$running_file" 2>/dev/null || echo "") + + if [[ -z $pid ]]; then + rm -f "$running_file" + return 0 + fi + + # Check if process is still running + if ! kill -0 "$pid" 2>/dev/null; then + log_message "CLEANUP: Stale running state for $app (PID $pid no longer exists)" + rm -f "$running_file" + fi +} + +# Launch app with auto-close timer +launch_with_timer() { + local app="$1" + local real_binary="$2" + shift 2 + + local warning_seconds=$(((AUTO_CLOSE_TIMEOUT_MINUTES - AUTO_CLOSE_WARNING_MINUTES) * 60)) + local running_file + running_file=$(get_running_file "$app") + + # Launch the app in background + "$real_binary" "$@" & + local app_pid=$! + + # Record state + echo "$app_pid $(date +%s)" >"$running_file" + log_message "LAUNCHED: $app with PID $app_pid (auto-close in ${AUTO_CLOSE_TIMEOUT_MINUTES}m)" + + # Spawn the auto-close daemon in a completely detached subshell + ( + # Detach from terminal + exec /dev/null 2>&1 + + # Wait for warning time + sleep "$warning_seconds" + + # Check if still running before warning + if kill -0 "$app_pid" 2>/dev/null; then + # Send warning notification + notify-send -u critical -t 30000 "⏰ $app Closing Soon" \ + "Session will end in ${AUTO_CLOSE_WARNING_MINUTES} minutes. Save your work!" 2>/dev/null || true + else + # Process already exited + rm -f "$running_file" 2>/dev/null || true + exit 0 + fi + + # Wait remaining time + sleep $((AUTO_CLOSE_WARNING_MINUTES * 60)) + + # Check if still running + if kill -0 "$app_pid" 2>/dev/null; then + # Send final notification + notify-send -u critical -t 5000 "🚫 $app Session Ended" \ + "Time's up! Closing $app now." 2>/dev/null || true + + # Graceful kill first + kill "$app_pid" 2>/dev/null || true + + # Wait a moment for graceful shutdown + sleep 2 + + # Force kill if still running + if kill -0 "$app_pid" 2>/dev/null; then + kill -9 "$app_pid" 2>/dev/null || true + fi + + echo "$(date '+%Y-%m-%d %H:%M:%S') - AUTO-CLOSED: $app (PID $app_pid) after ${AUTO_CLOSE_TIMEOUT_MINUTES}m" >>"$LOG_FILE" 2>/dev/null || true + fi + + rm -f "$running_file" 2>/dev/null || true + ) & + disown + + # Wait for the app to exit (keeps wrapper process alive while app is running) + wait "$app_pid" 2>/dev/null || true + local exit_code=$? + + # Clean up running state + rm -f "$running_file" 2>/dev/null || true + + log_message "EXITED: $app (PID $app_pid) with code $exit_code" + return $exit_code } # Main wrapper function - called when wrapping app launches wrapper_main() { - local app="$1" - shift + local app="$1" + shift - ensure_state_dir + ensure_state_dir - local real_binary - if ! real_binary=$(get_real_binary "$app"); then - log_message "ERROR: Real binary not found for $app" - echo "Error: Real binary for $app not found. Was the installer run?" >&2 - exit 1 - fi + local real_binary + if ! real_binary=$(get_real_binary "$app"); then + log_message "ERROR: Real binary not found for $app" + echo "Error: Real binary for $app not found. Was the installer run?" >&2 + exit 1 + fi - if was_opened_this_hour "$app"; then - block_app "$app" - exit 1 - fi + # Clean up stale running state from previous crashes + cleanup_stale_running_state "$app" - record_opening "$app" - exec "$real_binary" "$@" + if was_opened_this_hour "$app"; then + block_app "$app" + exit 1 + fi + + record_opening "$app" + + # Launch with auto-close timer (replaces direct exec) + launch_with_timer "$app" "$real_binary" "$@" } # Install wrapper for a specific app install_wrapper() { - local app="$1" - local wrapper_path="${APPS[$app]}" - local real_binary="${REAL_BINARIES[$app]}" + local app="$1" + local wrapper_path="${APPS[$app]}" + local real_binary="${REAL_BINARIES[$app]}" - # Check if already wrapped - if [[ -f "${wrapper_path}.orig" ]]; then - echo " ✓ $app already wrapped" - return 0 - fi + # Check if already wrapped + if [[ -f "${wrapper_path}.orig" ]]; then + echo " ✓ $app already wrapped" + return 0 + fi - # Check if wrapper location exists (file or symlink) - if [[ ! -e $wrapper_path && ! -L $wrapper_path ]]; then - echo " ⚠ $app not installed ($wrapper_path not found)" - return 1 - fi + # Check if wrapper location exists (file or symlink) + if [[ ! -e $wrapper_path && ! -L $wrapper_path ]]; then + echo " ⚠ $app not installed ($wrapper_path not found)" + return 1 + fi - # Check if real binary exists - if [[ ! -x $real_binary ]]; then - echo " ⚠ $app real binary not found ($real_binary)" - return 1 - fi + # Check if real binary exists + if [[ ! -x $real_binary ]]; then + echo " ⚠ $app real binary not found ($real_binary)" + return 1 + fi - echo " Installing wrapper for $app..." + echo " Installing wrapper for $app..." - # Handle symlinks: save the symlink itself, not the target - if [[ -L $wrapper_path ]]; then - local link_target - link_target=$(readlink "$wrapper_path") - echo " Saving symlink $wrapper_path -> $link_target as ${wrapper_path}.orig" - # Remove symlink and create .orig that stores the link target info - echo "SYMLINK:$link_target" > "${wrapper_path}.orig" - rm "$wrapper_path" - else - echo " Backing up $wrapper_path -> ${wrapper_path}.orig" - mv "$wrapper_path" "${wrapper_path}.orig" - fi + # Handle symlinks: save the symlink itself, not the target + if [[ -L $wrapper_path ]]; then + local link_target + link_target=$(readlink "$wrapper_path") + echo " Saving symlink $wrapper_path -> $link_target as ${wrapper_path}.orig" + # Remove symlink and create .orig that stores the link target info + echo "SYMLINK:$link_target" >"${wrapper_path}.orig" + rm "$wrapper_path" + else + echo " Backing up $wrapper_path -> ${wrapper_path}.orig" + mv "$wrapper_path" "${wrapper_path}.orig" + fi - echo " Creating wrapper at $wrapper_path" - cat > "$wrapper_path" << WRAPPER_EOF + echo " Creating wrapper at $wrapper_path" + cat >"$wrapper_path" < /dev/null || echo "") - if [[ $orig_content == SYMLINK:* ]]; then - local link_target="${orig_content#SYMLINK:}" - echo " Restoring symlink $wrapper_path -> $link_target" - ln -s "$link_target" "$wrapper_path" - rm "${wrapper_path}.orig" - else - echo " Restoring original file" - mv "${wrapper_path}.orig" "$wrapper_path" - fi - echo " ✓ $app restored" + # Check if it was a symlink (stored as SYMLINK:target in .orig) + local orig_content + orig_content=$(cat "${wrapper_path}.orig" 2>/dev/null || echo "") + if [[ $orig_content == SYMLINK:* ]]; then + local link_target="${orig_content#SYMLINK:}" + echo " Restoring symlink $wrapper_path -> $link_target" + ln -s "$link_target" "$wrapper_path" + rm "${wrapper_path}.orig" + else + echo " Restoring original file" + mv "${wrapper_path}.orig" "$wrapper_path" + fi + echo " ✓ $app restored" } # Install all wrappers install_all() { - echo "Installing compulsive opening blockers..." - echo "" + echo "Installing compulsive opening blockers..." + echo "" - # Install main script to /usr/local/bin - local script_path - script_path="$(readlink -f "$0")" - local install_path="/usr/local/bin/block-compulsive-opening.sh" + # Install main script to /usr/local/bin + local script_path + script_path="$(readlink -f "$0")" + local install_path="/usr/local/bin/block-compulsive-opening.sh" - if [[ $script_path != "$install_path" ]]; then - echo "Installing main script to $install_path..." - cp "$script_path" "$install_path" - chmod +x "$install_path" - echo "✓ Main script installed" - else - echo "Main script already at $install_path" - fi - echo "" + if [[ $script_path != "$install_path" ]]; then + echo "Installing main script to $install_path..." + cp "$script_path" "$install_path" + chmod +x "$install_path" + echo "✓ Main script installed" + else + echo "Main script already at $install_path" + fi + echo "" - # Install wrappers for each app - local installed=0 - for app in "${!APPS[@]}"; do - if install_wrapper "$app"; then - ((installed++)) || true - fi - done + # Install wrappers for each app + local installed=0 + for app in "${!APPS[@]}"; do + if install_wrapper "$app"; then + ((installed++)) || true + fi + done - echo "" - echo "Installation complete. $installed app(s) wrapped." - echo "" - echo "Each app can now only be opened once per hour." - echo "State files stored in: $STATE_DIR" - echo "Logs stored in: $LOG_FILE" + echo "" + echo "Installation complete. $installed app(s) wrapped." + echo "" + echo "Each app can now only be opened once per hour." + echo "State files stored in: $STATE_DIR" + echo "Logs stored in: $LOG_FILE" - # Install pacman hook to re-wrap after package updates - install_pacman_hook + # Install pacman hook to re-wrap after package updates + install_pacman_hook } # Install pacman hook to re-install wrappers after package updates install_pacman_hook() { - local hook_dir="/etc/pacman.d/hooks" - local hook_file="$hook_dir/95-compulsive-block-rewrap.hook" + local hook_dir="/etc/pacman.d/hooks" + local hook_file="$hook_dir/95-compulsive-block-rewrap.hook" - echo "" - echo "Installing pacman hook..." + echo "" + echo "Installing pacman hook..." - mkdir -p "$hook_dir" + mkdir -p "$hook_dir" - cat > "$hook_file" << 'HOOK_EOF' + cat >"$hook_file" <<'HOOK_EOF' [Trigger] Operation = Upgrade Operation = Install @@ -291,131 +407,131 @@ When = PostTransaction Exec = /usr/local/bin/block-compulsive-opening.sh rewrap-quiet HOOK_EOF - chmod 644 "$hook_file" - echo "✓ Pacman hook installed: $hook_file" - echo " Wrappers will be automatically re-installed after beeper/signal/discord updates" + chmod 644 "$hook_file" + echo "✓ Pacman hook installed: $hook_file" + echo " Wrappers will be automatically re-installed after beeper/signal/discord updates" } # Uninstall pacman hook uninstall_pacman_hook() { - local hook_file="/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook" - if [[ -f $hook_file ]]; then - rm -f "$hook_file" - echo "✓ Pacman hook removed" - fi + local hook_file="/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook" + if [[ -f $hook_file ]]; then + rm -f "$hook_file" + echo "✓ Pacman hook removed" + fi } # Quietly re-wrap apps (for pacman hook - no interactive output) rewrap_quiet() { - log_message "REWRAP: Pacman hook triggered, re-installing wrappers" + log_message "REWRAP: Pacman hook triggered, re-installing wrappers" - for app in "${!APPS[@]}"; do - local wrapper_path="${APPS[$app]}" + for app in "${!APPS[@]}"; do + local wrapper_path="${APPS[$app]}" - # Check if wrapper was overwritten (no longer our wrapper script) - if [[ -f $wrapper_path ]] && ! grep -q "block-compulsive-opening" "$wrapper_path" 2> /dev/null; then - # Wrapper was overwritten by package update - log_message "REWRAP: $app wrapper was overwritten, re-installing" + # Check if wrapper was overwritten (no longer our wrapper script) + if [[ -f $wrapper_path ]] && ! grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then + # Wrapper was overwritten by package update + log_message "REWRAP: $app wrapper was overwritten, re-installing" - # Remove old .orig if exists (it's now stale) - rm -f "${wrapper_path}.orig" + # Remove old .orig if exists (it's now stale) + rm -f "${wrapper_path}.orig" - # Re-install wrapper - install_wrapper "$app" >> "$LOG_FILE" 2>&1 || true - fi - done + # Re-install wrapper + install_wrapper "$app" >>"$LOG_FILE" 2>&1 || true + fi + done - log_message "REWRAP: Complete" + log_message "REWRAP: Complete" } # Uninstall all wrappers uninstall_all() { - echo "Removing compulsive opening blockers..." - echo "" + echo "Removing compulsive opening blockers..." + echo "" - for app in "${!APPS[@]}"; do - uninstall_wrapper "$app" || true - done + for app in "${!APPS[@]}"; do + uninstall_wrapper "$app" || true + done - rm -f "/usr/local/bin/block-compulsive-opening.sh" + rm -f "/usr/local/bin/block-compulsive-opening.sh" - # Remove pacman hook - uninstall_pacman_hook + # Remove pacman hook + uninstall_pacman_hook - echo "" - echo "Uninstallation complete." + echo "" + echo "Uninstallation complete." } # Show status of all apps show_status() { - ensure_state_dir - local current_hour - current_hour=$(get_hour_key) + ensure_state_dir + local current_hour + current_hour=$(get_hour_key) - echo "Compulsive Opening Blocker Status" - echo "==================================" - echo "Current hour: $current_hour" - echo "" + echo "Compulsive Opening Blocker Status" + echo "==================================" + echo "Current hour: $current_hour" + echo "" - for app in "${!APPS[@]}"; do - local state_file - state_file=$(get_state_file "$app") - local status="not opened this hour" - local icon="○" + for app in "${!APPS[@]}"; do + local state_file + state_file=$(get_state_file "$app") + local status="not opened this hour" + local icon="○" - if [[ -f $state_file ]]; then - local last_hour - last_hour=$(cat "$state_file" 2> /dev/null || echo "") - if [[ $last_hour == "$current_hour" ]]; then - status="already opened (blocked until next hour)" - icon="●" - else - status="last opened: $last_hour" - fi - fi + if [[ -f $state_file ]]; then + local last_hour + last_hour=$(cat "$state_file" 2>/dev/null || echo "") + if [[ $last_hour == "$current_hour" ]]; then + status="already opened (blocked until next hour)" + icon="●" + else + status="last opened: $last_hour" + fi + fi - # Check if wrapped - local wrapped="not installed" - local wrapper_path="${APPS[$app]}" - if [[ -f "${wrapper_path}.orig" ]]; then - wrapped="wrapped" - elif [[ -f $wrapper_path ]]; then - wrapped="installed (not wrapped)" - fi + # Check if wrapped + local wrapped="not installed" + local wrapper_path="${APPS[$app]}" + if [[ -f "${wrapper_path}.orig" ]]; then + wrapped="wrapped" + elif [[ -f $wrapper_path ]]; then + wrapped="installed (not wrapped)" + fi - printf " %s %-15s [%s] - %s\n" "$icon" "$app" "$wrapped" "$status" - done + printf " %s %-15s [%s] - %s\n" "$icon" "$app" "$wrapped" "$status" + done - echo "" - echo "State directory: $STATE_DIR" + echo "" + echo "State directory: $STATE_DIR" } # Reset state for an app (allow opening again) reset_app() { - local app="$1" - local state_file - state_file=$(get_state_file "$app") + local app="$1" + local state_file + state_file=$(get_state_file "$app") - if [[ -f $state_file ]]; then - rm -f "$state_file" - echo "Reset $app - can be opened again this hour" - log_message "RESET: $app state cleared by user" - else - echo "$app was not marked as opened" - fi + if [[ -f $state_file ]]; then + rm -f "$state_file" + echo "Reset $app - can be opened again this hour" + log_message "RESET: $app state cleared by user" + else + echo "$app was not marked as opened" + fi } # Clear all state reset_all() { - ensure_state_dir - rm -f "$STATE_DIR"/*.lastopen - echo "All apps reset - can be opened again this hour" - log_message "RESET: All app states cleared by user" + ensure_state_dir + rm -f "$STATE_DIR"/*.lastopen + echo "All apps reset - can be opened again this hour" + log_message "RESET: All app states cleared by user" } # Show usage show_usage() { - cat << EOF + cat < None: + """Log message with timestamp.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_line = f"{timestamp} - {message}" + print(log_line) + try: + STATE_DIR.mkdir(parents=True, exist_ok=True) + with open(LOG_FILE, "a") as f: + f.write(log_line + "\n") + except Exception: + pass + + +def notify(title: str, message: str, urgency: str = "normal") -> None: + """Send desktop notification.""" + try: + subprocess.run( + ["notify-send", "-u", urgency, title, message], + capture_output=True, + timeout=5, + ) + except Exception: + pass + + +def get_running_processes() -> Set[str]: + """Get set of currently running process names.""" + processes = set() + try: + result = subprocess.run( + ["ps", "-eo", "comm="], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + for line in result.stdout.strip().split("\n"): + proc_name = line.strip().lower() + if proc_name: + processes.add(proc_name) + except Exception as e: + log(f"Error getting processes: {e}") + return processes + + +def is_steam_running(processes: Set[str]) -> bool: + """Check if Steam or any Steam game is running.""" + for proc in processes: + # Check for Steam main processes + if proc in STEAM_PATTERNS: + return True + # Check for Steam games (have steam_app_ prefix) + if proc.startswith(STEAM_GAME_PREFIX): + return True + return False + + +def is_browser_running(processes: Set[str]) -> bool: + """Check if any browser is running.""" + for proc in processes: + # Skip ignored patterns + if any(ign in proc for ign in IGNORE_PATTERNS): + continue + # Check browser patterns + for pattern in BROWSER_PATTERNS: + if pattern in proc: + return True + return False + + +def kill_steam() -> None: + """Kill all Steam-related processes.""" + log("Killing Steam processes...") + notify("🎮 Gaming Blocked", "Browser is active. Closing Steam.", "critical") + + try: + # First try graceful shutdown + subprocess.run(["pkill", "-f", "steam"], capture_output=True, timeout=5) + time.sleep(2) + + # Force kill if still running + subprocess.run(["pkill", "-9", "-f", "steam"], capture_output=True, timeout=5) + except Exception as e: + log(f"Error killing Steam: {e}") + + +def kill_browsers() -> None: + """Kill all browser processes.""" + log("Killing browser processes...") + notify("🌐 Browsers Blocked", "Steam is active. Closing browsers.", "critical") + + for browser in BROWSER_PATTERNS: + try: + subprocess.run(["pkill", "-f", browser], capture_output=True, timeout=5) + except Exception: + pass + + time.sleep(2) + + # Force kill if still running + for browser in BROWSER_PATTERNS: + try: + subprocess.run(["pkill", "-9", "-f", browser], capture_output=True, timeout=5) + except Exception: + pass + + +class FocusMode: + """Tracks current focus mode and enforces mutual exclusion.""" + + def __init__(self): + self.current_mode: Optional[str] = None # "gaming" or "browsing" or None + self.mode_start_time: Optional[datetime] = None + + def update(self, processes: Set[str]) -> None: + """Update focus mode based on running processes.""" + steam_running = is_steam_running(processes) + browser_running = is_browser_running(processes) + + if self.current_mode is None: + # No mode set yet - first to start wins + if steam_running and browser_running: + # Both running at startup - prefer gaming mode (close browsers) + log("Both Steam and browsers detected at startup - entering GAMING mode") + self.current_mode = "gaming" + self.mode_start_time = datetime.now() + kill_browsers() + elif steam_running: + log("Steam detected - entering GAMING mode") + self.current_mode = "gaming" + self.mode_start_time = datetime.now() + notify("🎮 Gaming Mode", "Steam detected. Browsers are now blocked.", "normal") + elif browser_running: + log("Browser detected - entering BROWSING mode") + self.current_mode = "browsing" + self.mode_start_time = datetime.now() + notify("🌐 Browsing Mode", "Browser detected. Steam is now blocked.", "normal") + + elif self.current_mode == "gaming": + if not steam_running: + # Steam closed - exit gaming mode + log("Steam closed - exiting GAMING mode") + self.current_mode = None + self.mode_start_time = None + notify("🎮 Gaming Mode Ended", "You can now use browsers.", "normal") + elif browser_running: + # Browser started while in gaming mode - kill it + log("Browser detected during GAMING mode - killing browsers") + kill_browsers() + + elif self.current_mode == "browsing": + if not browser_running: + # Browsers closed - exit browsing mode + log("Browsers closed - exiting BROWSING mode") + self.current_mode = None + self.mode_start_time = None + notify("🌐 Browsing Mode Ended", "You can now use Steam.", "normal") + elif steam_running: + # Steam started while in browsing mode - kill it + log("Steam detected during BROWSING mode - killing Steam") + kill_steam() + + def get_status(self) -> str: + """Get current status string.""" + if self.current_mode is None: + return "No active focus mode" + + duration = "" + if self.mode_start_time: + elapsed = datetime.now() - self.mode_start_time + minutes = int(elapsed.total_seconds() // 60) + duration = f" (active for {minutes}m)" + + if self.current_mode == "gaming": + return f"🎮 GAMING mode{duration} - browsers blocked" + else: + return f"🌐 BROWSING mode{duration} - Steam blocked" + + +def write_status(focus: FocusMode) -> None: + """Write current status to state file for external queries.""" + try: + STATE_DIR.mkdir(parents=True, exist_ok=True) + status_file = STATE_DIR / "status" + with open(status_file, "w") as f: + f.write(focus.get_status() + "\n") + f.write(f"mode={focus.current_mode or 'none'}\n") + except Exception: + pass + + +def main(): + """Main daemon loop.""" + log("Focus Mode Daemon starting...") + + # Setup signal handlers + def handle_signal(signum, frame): + log(f"Received signal {signum} - shutting down") + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + focus = FocusMode() + + while True: + try: + processes = get_running_processes() + focus.update(processes) + write_status(focus) + except Exception as e: + log(f"Error in main loop: {e}") + + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + main() diff --git a/scripts/digital_wellbeing/install_focus_mode_daemon.sh b/scripts/digital_wellbeing/install_focus_mode_daemon.sh new file mode 100755 index 0000000..6c898c3 --- /dev/null +++ b/scripts/digital_wellbeing/install_focus_mode_daemon.sh @@ -0,0 +1,211 @@ +#!/bin/bash +# Install Focus Mode Daemon +# Sets up Steam/Browser mutual exclusion as a systemd user service + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DAEMON_SCRIPT="$SCRIPT_DIR/focus_mode_daemon.py" +INSTALL_PATH="/usr/local/bin/focus-mode-daemon" +SERVICE_DIR="$HOME/.config/systemd/user" +SERVICE_FILE="$SERVICE_DIR/focus-mode.service" + +msg() { printf '\e[1;32m[+]\e[0m %s\n' "$*"; } +note() { printf '\e[1;34m[i]\e[0m %s\n' "$*"; } +warn() { printf '\e[1;33m[!]\e[0m %s\n' "$*"; } +err() { printf '\e[1;31m[x]\e[0m %s\n' "$*" >&2; } + +usage() { + cat </dev/null; then + err "python3 is required but not installed" + missing=1 + fi + + if ! command -v systemctl &>/dev/null; then + err "systemd is required but systemctl not found" + missing=1 + fi + + if [[ $missing -eq 1 ]]; then + exit 1 + fi +} + +install_daemon() { + msg "Installing Focus Mode Daemon..." + + check_deps + + if [[ ! -f "$DAEMON_SCRIPT" ]]; then + err "Daemon script not found: $DAEMON_SCRIPT" + exit 1 + fi + + # Install the daemon script + msg "Installing daemon script to $INSTALL_PATH" + if [[ $EUID -eq 0 ]]; then + install -m 755 "$DAEMON_SCRIPT" "$INSTALL_PATH" + else + sudo install -m 755 "$DAEMON_SCRIPT" "$INSTALL_PATH" + fi + + # Create systemd user directory + mkdir -p "$SERVICE_DIR" + + # Create the systemd user service + msg "Creating systemd user service: $SERVICE_FILE" + cat >"$SERVICE_FILE" <<'EOF' +[Unit] +Description=Focus Mode Daemon (Steam/Browser mutual exclusion) +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/focus-mode-daemon +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +# Don't allow easy stopping (psychological friction) +RefuseManualStop=false + +[Install] +WantedBy=default.target +EOF + + # Reload systemd user daemon + msg "Reloading systemd user daemon..." + systemctl --user daemon-reload + + # Enable and start the service + msg "Enabling and starting focus-mode.service..." + systemctl --user enable focus-mode.service + systemctl --user start focus-mode.service + + msg "Focus Mode Daemon installed successfully!" + echo "" + echo "The daemon is now running and will:" + echo " 🎮 Block browsers when Steam is running" + echo " 🌐 Block Steam when a browser is running" + echo "" + echo "Status: $(systemctl --user is-active focus-mode.service 2>/dev/null || echo 'unknown')" + echo "" + echo "Commands:" + echo " systemctl --user status focus-mode - Check daemon status" + echo " journalctl --user -u focus-mode -f - View daemon logs" + echo " cat ~/.local/state/focus-mode/status - View current mode" + echo "" +} + +uninstall_daemon() { + msg "Uninstalling Focus Mode Daemon..." + + # Stop and disable service + if systemctl --user is-active focus-mode.service &>/dev/null; then + msg "Stopping focus-mode.service..." + systemctl --user stop focus-mode.service || true + fi + + if systemctl --user is-enabled focus-mode.service &>/dev/null; then + msg "Disabling focus-mode.service..." + systemctl --user disable focus-mode.service || true + fi + + # Remove service file + if [[ -f "$SERVICE_FILE" ]]; then + msg "Removing service file..." + rm -f "$SERVICE_FILE" + fi + + # Reload daemon + systemctl --user daemon-reload 2>/dev/null || true + + # Remove installed script + if [[ -f "$INSTALL_PATH" ]]; then + msg "Removing daemon script..." + if [[ $EUID -eq 0 ]]; then + rm -f "$INSTALL_PATH" + else + sudo rm -f "$INSTALL_PATH" + fi + fi + + msg "Focus Mode Daemon uninstalled" + note "State files in ~/.local/state/focus-mode/ were NOT removed" +} + +show_status() { + echo "Focus Mode Daemon Status" + echo "========================" + echo "" + + # Service status + if systemctl --user is-active focus-mode.service &>/dev/null; then + echo "Service: ✓ Running" + else + echo "Service: ✗ Not running" + fi + + if systemctl --user is-enabled focus-mode.service &>/dev/null; then + echo "Enabled: ✓ Yes" + else + echo "Enabled: ✗ No" + fi + + echo "" + + # Current mode + local status_file="$HOME/.local/state/focus-mode/status" + if [[ -f "$status_file" ]]; then + echo "Current Mode:" + cat "$status_file" + else + echo "Current Mode: Unknown (status file not found)" + fi + + echo "" + echo "Recent Logs:" + journalctl --user -u focus-mode --no-pager -n 10 2>/dev/null || echo " (no logs available)" +} + +# Main +case "${1:-install}" in +install) + install_daemon + ;; +uninstall) + uninstall_daemon + ;; +status) + show_status + ;; +-h | --help | help) + usage + ;; +*) + err "Unknown command: $1" + usage + exit 1 + ;; +esac diff --git a/scripts/digital_wellbeing/pacman/README_FOR_LLM.md b/scripts/digital_wellbeing/pacman/README_FOR_LLM.md new file mode 100644 index 0000000..fc6bbb2 --- /dev/null +++ b/scripts/digital_wellbeing/pacman/README_FOR_LLM.md @@ -0,0 +1,282 @@ +# Pacman Wrapper Security System - LLM Reference Guide + +> **For AI assistants**: This document explains the pacman wrapper architecture so you can make correct modifications. + +## System Purpose + +Intercept all `pacman` commands to: +1. Block installation of restricted packages (browsers, games, etc.) +2. Require challenges for greylisted packages +3. Enforce hosts file sharing on VirtualBox VMs +4. Auto-setup maintenance services if missing +5. Handle stale database locks gracefully + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ PACMAN WRAPPER │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ User runs: pacman -S firefox │ +│ ↓ │ +│ /usr/bin/pacman (symlink) → pacman_wrapper.sh │ +│ ↓ │ +│ 1. Verify policy file integrity (SHA256) │ +│ 2. Check if package matches blocked keywords │ +│ 3. Check if package requires challenge (greylist) │ +│ 4. Run hosts-guard pre-unlock hook │ +│ 5. Execute real pacman: /usr/bin/pacman.orig │ +│ 6. Run hosts-guard post-relock hook │ +│ 7. Remove any blocked packages that slipped through │ +│ 8. Enforce VirtualBox hosts if vbox detected │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## File Locations + +| File | Purpose | +|------|---------| +| `/usr/bin/pacman` | Symlink to wrapper | +| `/usr/bin/pacman.orig` | Real pacman binary | +| `pacman_wrapper.sh` | Main wrapper script (823 lines) | +| `install_pacman_wrapper.sh` | Installer script | +| `pacman_blocked_keywords.txt` | Substrings that cause blocking | +| `pacman_whitelist.txt` | Exact names that bypass blocking | +| `pacman_greylist.txt` | Packages requiring challenge | +| `words.txt` | Word scramble challenge dictionary | +| `/var/lib/pacman-wrapper/policy.sha256` | Integrity checksums | + +## Policy Files Explained + +### pacman_blocked_keywords.txt +``` +# Lines starting with # are comments +# Any package containing these substrings is BLOCKED +firefox +brave +chromium +youtube +stremio +``` + +If user tries `pacman -S firefox-developer-edition`, it's blocked because it contains "firefox". + +### pacman_whitelist.txt +``` +# Exact package names that bypass keyword blocking +minizip # Contains nothing bad but might match a pattern +python-requests # Safe despite containing blocked substrings +``` + +### pacman_greylist.txt +``` +# Packages requiring word scramble challenge +# Currently empty - add packages here for challenge requirement +``` + +## Hardcoded Security Checks + +These checks are in the script itself and **cannot be bypassed by editing policy files**: + +### VirtualBox Check +```bash +function is_virtualbox_package() { + local pkg_lower="${1,,}" + [[ $pkg_lower == *"virtualbox"* || $pkg_lower == *"vbox"* ]] +} +``` +- Detects any package with "virtualbox" or "vbox" in name +- Requires word scramble challenge (7-letter words, 120s timeout) +- Auto-enforces hosts file sharing on all VMs after install + +### Steam Check +```bash +function is_steam_package() { + [[ $1 == "steam" ]] +} +``` +- Only exact match "steam" (not steam-native-runtime etc.) +- **Weekend only** - blocked Monday through Friday 4PM +- Requires word scramble challenge (5-letter words, 60s timeout) + +## Word Scramble Challenge + +Used for Steam, VirtualBox, and greylisted packages: + +``` +Challenge: Words with 5 letters +Here are 160 random words. Remember them: +APPLE BRAVE CHAIR DANCE ... + +One of those words has been scrambled to: ELPPA +Unscramble the word to proceed (you have 60 seconds): +``` + +Parameters vary by package type: +| Package Type | Word Length | Words Shown | Timeout | Initial Delay | +|--------------|-------------|-------------|---------|---------------| +| Steam | 5 | 160 | 60s | 0-20s | +| VirtualBox | 7 | 150 | 120s | 0-45s | +| Greylist | 6 | 120 | 90s | 0-30s | + +## Integrity Verification + +On every invocation, the wrapper verifies policy files haven't been tampered with: + +```bash +verify_policy_integrity() { + # Reads /var/lib/pacman-wrapper/policy.sha256 + # Compares SHA256 of each policy file + # If mismatch: BLOCKS all operations +} +``` + +If tampering detected: +``` +SECURITY WARNING: Policy file integrity check failed! +CRITICAL: Policy files have been tampered with! +Wrapper operation DENIED. Please reinstall using: sudo install_pacman_wrapper.sh +``` + +## Hosts Integration + +The wrapper integrates with the hosts guard system: + +```bash +pre_unlock_hosts() { + # Called before any transaction (-S, -U, -R) + /usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh +} + +post_relock_hosts() { + # Called after transaction completes + /usr/local/share/hosts-guard/pacman-post-relock-hosts.sh +} +``` + +This allows package installations to modify `/etc/hosts` temporarily (e.g., for network setup) while maintaining protection. + +## Common Tasks + +### Adding a Blocked Package + +1. Edit `pacman_blocked_keywords.txt`: +```bash +echo "newkeyword" >> pacman_blocked_keywords.txt +``` + +2. Reinstall wrapper to update checksums: +```bash +sudo ./install_pacman_wrapper.sh +``` + +### Whitelisting a Package + +If a legitimate package is being blocked (e.g., `python-firefox-sync` blocked by "firefox" keyword): + +1. Edit `pacman_whitelist.txt`: +```bash +echo "python-firefox-sync" >> pacman_whitelist.txt +``` + +2. Reinstall wrapper: +```bash +sudo ./install_pacman_wrapper.sh +``` + +### Adding a Challenge Requirement + +1. Edit `pacman_greylist.txt`: +```bash +echo "suspicious-package" >> pacman_greylist.txt +``` + +2. Reinstall wrapper. + +### Bypassing the Wrapper (Emergency) + +If wrapper is broken and you need real pacman: +```bash +sudo /usr/bin/pacman.orig -S package +``` + +**Warning**: This bypasses all security checks. + +## Post-Transaction Cleanup + +After every transaction, the wrapper: + +1. Scans installed packages for blocked keywords +2. Removes any that match (shouldn't happen normally) +3. Scans for greylisted packages and removes them +4. Checks if VirtualBox is installed and enforces hosts + +```bash +remove_installed_blocked_packages() { + mapfile -t installed_names < <("$PACMAN_BIN" -Qq) + for name in "${installed_names[@]}"; do + if is_blocked_package_name "$name"; then + pacman -Rns --noconfirm "$name" + fi + done +} +``` + +## Stale Lock Handling + +If `/var/lib/pacman/db.lck` exists but no pacman is running: +- Interactive: Prompts user to remove (15s timeout) +- Non-interactive (`--noconfirm`): Auto-removes if lock is >10 minutes old +- If another pacman is actually running: Blocks with error + +## Maintenance Auto-Setup + +On first run, wrapper checks if periodic maintenance services exist: +```bash +ensure_periodic_maintenance() { + # Checks: periodic-system-maintenance.timer + # periodic-system-startup.service + # hosts-file-monitor.service + # If missing: runs setup_periodic_system.sh +} +``` + +## Known Gaps (TODO) + +1. ❌ `google-chrome` and `google-chrome-stable` not in blocked list +2. ❌ No automatic LeechBlock installation when browsers detected +3. ❌ User can download and install `.deb`/`.tar.gz` manually +4. ❌ AUR packages bypass wrapper (yay/paru call pacman internally) + +## Debugging + +### Check if wrapper is installed +```bash +ls -la /usr/bin/pacman +# Should show: /usr/bin/pacman -> /path/to/pacman_wrapper.sh + +ls -la /usr/bin/pacman.orig +# Should exist and be the real binary +``` + +### Test policy integrity +```bash +cat /var/lib/pacman-wrapper/policy.sha256 +sha256sum /path/to/pacman_blocked_keywords.txt +# Hashes should match +``` + +### Verbose mode +The wrapper outputs colored status messages to stderr. To see them: +```bash +pacman -S package 2>&1 | cat +``` + +## DO NOT + +1. ❌ Edit policy files without reinstalling wrapper (breaks integrity check) +2. ❌ Remove `/usr/bin/pacman.orig` (breaks all pacman operations) +3. ❌ Symlink pacman to something other than the wrapper +4. ❌ Clear `/var/lib/pacman-wrapper/` without understanding consequences diff --git a/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt b/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt index 472cf6b..8c54a5e 100644 --- a/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt +++ b/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt @@ -53,4 +53,8 @@ netsurf amfora tartube youtube -virtualbox \ No newline at end of file +# Chrome/Chromium variants +google-chrome +chromium +ungoogled-chromium +thorium diff --git a/scripts/digital_wellbeing/pacman/pacman_greylist.txt b/scripts/digital_wellbeing/pacman/pacman_greylist.txt index b9d41d8..0c25ee2 100644 --- a/scripts/digital_wellbeing/pacman/pacman_greylist.txt +++ b/scripts/digital_wellbeing/pacman/pacman_greylist.txt @@ -1,4 +1,3 @@ # Packages matching any of these substrings require a challenge to install. # They will also be uninstalled if found already installed. # Lines starting with # are comments. -virtualbox diff --git a/scripts/digital_wellbeing/pacman/pacman_wrapper.sh b/scripts/digital_wellbeing/pacman/pacman_wrapper.sh index 7d1118d..3b3a155 100755 --- a/scripts/digital_wellbeing/pacman/pacman_wrapper.sh +++ b/scripts/digital_wellbeing/pacman/pacman_wrapper.sh @@ -22,683 +22,689 @@ INTEGRITY_FILE="${INTEGRITY_DIR}/policy.sha256" # Verify integrity of policy files verify_policy_integrity() { - if [[ ! -f $INTEGRITY_FILE ]]; then - echo -e "${RED}SECURITY WARNING: Policy integrity file missing!${NC}" >&2 - echo -e "${RED}The pacman wrapper may have been tampered with.${NC}" >&2 - echo -e "${RED}Please reinstall the wrapper using: sudo install_pacman_wrapper.sh${NC}" >&2 - return 1 - fi + if [[ ! -f $INTEGRITY_FILE ]]; then + echo -e "${RED}SECURITY WARNING: Policy integrity file missing!${NC}" >&2 + echo -e "${RED}The pacman wrapper may have been tampered with.${NC}" >&2 + echo -e "${RED}Please reinstall the wrapper using: sudo install_pacman_wrapper.sh${NC}" >&2 + return 1 + fi - local script_dir - script_dir="$(dirname "$(readlink -f "$0")")" - local blocked_file="$script_dir/pacman_blocked_keywords.txt" - local greylist_file="$script_dir/pacman_greylist.txt" - local whitelist_file="$script_dir/pacman_whitelist.txt" + local script_dir + script_dir="$(dirname "$(readlink -f "$0")")" + local blocked_file="$script_dir/pacman_blocked_keywords.txt" + local greylist_file="$script_dir/pacman_greylist.txt" + local whitelist_file="$script_dir/pacman_whitelist.txt" - # Verify checksums - local failed=0 - while IFS= read -r line; do - local expected_hash expected_file - expected_hash=$(echo "$line" | awk '{print $1}') - expected_file=$(echo "$line" | awk '{print $2}') - - if [[ -f $expected_file ]]; then - local actual_hash - actual_hash=$(sha256sum "$expected_file" 2>/dev/null | awk '{print $1}') - if [[ $actual_hash != "$expected_hash" ]]; then - echo -e "${RED}SECURITY WARNING: Policy file integrity check failed for $expected_file${NC}" >&2 - failed=1 - fi - fi - done < "$INTEGRITY_FILE" + # Verify checksums + local failed=0 + while IFS= read -r line; do + local expected_hash expected_file + expected_hash=$(echo "$line" | awk '{print $1}') + expected_file=$(echo "$line" | awk '{print $2}') - if [[ $failed -eq 1 ]]; then - echo -e "${RED}CRITICAL: Policy files have been tampered with!${NC}" >&2 - echo -e "${RED}This could be an attempt to bypass security restrictions.${NC}" >&2 - echo -e "${RED}Wrapper operation DENIED. Please reinstall using: sudo install_pacman_wrapper.sh${NC}" >&2 - return 1 - fi + if [[ -f $expected_file ]]; then + local actual_hash + actual_hash=$(sha256sum "$expected_file" 2>/dev/null | awk '{print $1}') + if [[ $actual_hash != "$expected_hash" ]]; then + echo -e "${RED}SECURITY WARNING: Policy file integrity check failed for $expected_file${NC}" >&2 + failed=1 + fi + fi + done <"$INTEGRITY_FILE" - return 0 + if [[ $failed -eq 1 ]]; then + echo -e "${RED}CRITICAL: Policy files have been tampered with!${NC}" >&2 + echo -e "${RED}This could be an attempt to bypass security restrictions.${NC}" >&2 + echo -e "${RED}Wrapper operation DENIED. Please reinstall using: sudo install_pacman_wrapper.sh${NC}" >&2 + return 1 + fi + + return 0 } load_policy_lists() { - if [[ $POLICY_LISTS_LOADED -eq 1 ]]; then - return - fi + if [[ $POLICY_LISTS_LOADED -eq 1 ]]; then + return + fi - local script_dir - script_dir="$(dirname "$(readlink -f "$0")")" - local blocked_file="$script_dir/pacman_blocked_keywords.txt" - local whitelist_file="$script_dir/pacman_whitelist.txt" - local greylist_file="$script_dir/pacman_greylist.txt" + local script_dir + script_dir="$(dirname "$(readlink -f "$0")")" + local blocked_file="$script_dir/pacman_blocked_keywords.txt" + local whitelist_file="$script_dir/pacman_whitelist.txt" + local greylist_file="$script_dir/pacman_greylist.txt" - if [[ -f $blocked_file ]]; then - mapfile -t BLOCKED_KEYWORDS_LIST < <(sed 's/\r$//' "$blocked_file" | grep -Ev '^[[:space:]]*(#|$)' || true) - else - BLOCKED_KEYWORDS_LIST=() - echo -e "${YELLOW}Warning:${NC} Missing blocked keywords file at $blocked_file" >&2 - fi + if [[ -f $blocked_file ]]; then + mapfile -t BLOCKED_KEYWORDS_LIST < <(sed 's/\r$//' "$blocked_file" | grep -Ev '^[[:space:]]*(#|$)' || true) + else + BLOCKED_KEYWORDS_LIST=() + echo -e "${YELLOW}Warning:${NC} Missing blocked keywords file at $blocked_file" >&2 + fi - if [[ -f $whitelist_file ]]; then - mapfile -t WHITELISTED_NAMES_LIST < <(sed 's/\r$//' "$whitelist_file" | grep -Ev '^[[:space:]]*(#|$)' || true) - else - WHITELISTED_NAMES_LIST=() - fi + if [[ -f $whitelist_file ]]; then + mapfile -t WHITELISTED_NAMES_LIST < <(sed 's/\r$//' "$whitelist_file" | grep -Ev '^[[:space:]]*(#|$)' || true) + else + WHITELISTED_NAMES_LIST=() + fi - if [[ -f $greylist_file ]]; then - mapfile -t GREYLISTED_KEYWORDS_LIST < <(sed 's/\r$//' "$greylist_file" | grep -Ev '^[[:space:]]*(#|$)' || true) - else - GREYLISTED_KEYWORDS_LIST=() - fi + if [[ -f $greylist_file ]]; then + mapfile -t GREYLISTED_KEYWORDS_LIST < <(sed 's/\r$//' "$greylist_file" | grep -Ev '^[[:space:]]*(#|$)' || true) + else + GREYLISTED_KEYWORDS_LIST=() + fi - for i in "${!BLOCKED_KEYWORDS_LIST[@]}"; do - BLOCKED_KEYWORDS_LIST[i]="${BLOCKED_KEYWORDS_LIST[i],,}" - done + for i in "${!BLOCKED_KEYWORDS_LIST[@]}"; do + BLOCKED_KEYWORDS_LIST[i]="${BLOCKED_KEYWORDS_LIST[i],,}" + done - for i in "${!WHITELISTED_NAMES_LIST[@]}"; do - WHITELISTED_NAMES_LIST[i]="${WHITELISTED_NAMES_LIST[i],,}" - done + for i in "${!WHITELISTED_NAMES_LIST[@]}"; do + WHITELISTED_NAMES_LIST[i]="${WHITELISTED_NAMES_LIST[i],,}" + done - for i in "${!GREYLISTED_KEYWORDS_LIST[@]}"; do - GREYLISTED_KEYWORDS_LIST[i]="${GREYLISTED_KEYWORDS_LIST[i],,}" - done + for i in "${!GREYLISTED_KEYWORDS_LIST[@]}"; do + GREYLISTED_KEYWORDS_LIST[i]="${GREYLISTED_KEYWORDS_LIST[i],,}" + done - POLICY_LISTS_LOADED=1 + POLICY_LISTS_LOADED=1 } # Determine if this invocation may perform a transaction (upgrade/install/remove) needs_unlock() { - # If args include -S (install/upgrade), -U (local install), or -R (remove), we unlock - # Also include -Su/-Syu/-Syuu when -S is part of the combined flag - for arg in "$@"; do - case "$arg" in - -S* | -U | -R | --sync | --upgrade | --remove) - return 0 - ;; - esac - done - return 1 + # If args include -S (install/upgrade), -U (local install), or -R (remove), we unlock + # Also include -Su/-Syu/-Syuu when -S is part of the combined flag + for arg in "$@"; do + case "$arg" in + -S* | -U | -R | --sync | --upgrade | --remove) + return 0 + ;; + esac + done + return 1 } # 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" - if [[ -x $pre ]]; then - echo -e "${CYAN}[hosts-guard] Preparing /etc/hosts for transaction...${NC}" >&2 - /bin/bash "$pre" || true - fi + local pre="/usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh" + if [[ -x $pre ]]; then + echo -e "${CYAN}[hosts-guard] Preparing /etc/hosts for transaction...${NC}" >&2 + /bin/bash "$pre" || true + fi } post_relock_hosts() { - local post="/usr/local/share/hosts-guard/pacman-post-relock-hosts.sh" - if [[ -x $post ]]; then - /bin/bash "$post" || true - echo -e "${CYAN}[hosts-guard] Protections re-applied to /etc/hosts.${NC}" >&2 - fi + local post="/usr/local/share/hosts-guard/pacman-post-relock-hosts.sh" + if [[ -x $post ]]; then + /bin/bash "$post" || true + echo -e "${CYAN}[hosts-guard] Protections re-applied to /etc/hosts.${NC}" >&2 + fi } # Ensure periodic system services (timer/monitor) are set up; if not, trigger setup ensure_periodic_maintenance() { - # Only proceed if systemd/systemctl is available - if ! command -v systemctl > /dev/null 2>&1; then - return 0 - fi + # Only proceed if systemd/systemctl is available + if ! command -v systemctl >/dev/null 2>&1; then + return 0 + fi - local timer_unit="periodic-system-maintenance.timer" - local startup_unit="periodic-system-startup.service" - local monitor_unit="hosts-file-monitor.service" - local needs_setup=0 + local timer_unit="periodic-system-maintenance.timer" + local startup_unit="periodic-system-startup.service" + local monitor_unit="hosts-file-monitor.service" + local needs_setup=0 - # Timer should be enabled and active - systemctl --quiet is-enabled "$timer_unit" || needs_setup=1 - systemctl --quiet is-active "$timer_unit" || needs_setup=1 + # Timer should be enabled and active + systemctl --quiet is-enabled "$timer_unit" || needs_setup=1 + systemctl --quiet is-active "$timer_unit" || needs_setup=1 - # Monitor should be enabled and active - systemctl --quiet is-enabled "$monitor_unit" || needs_setup=1 - systemctl --quiet is-active "$monitor_unit" || needs_setup=1 + # Monitor should be enabled and active + systemctl --quiet is-enabled "$monitor_unit" || needs_setup=1 + systemctl --quiet is-active "$monitor_unit" || needs_setup=1 - # Startup service should be enabled (it’s oneshot and may not be active except at boot) - systemctl --quiet is-enabled "$startup_unit" || needs_setup=1 + # Startup service should be enabled (it’s oneshot and may not be active except at boot) + systemctl --quiet is-enabled "$startup_unit" || needs_setup=1 - if [[ $needs_setup -eq 0 ]]; then - return 0 - fi + if [[ $needs_setup -eq 0 ]]; then + return 0 + fi - echo -e "${YELLOW}Periodic maintenance services missing or inactive. Running setup...${NC}" >&2 + echo -e "${YELLOW}Periodic maintenance services missing or inactive. Running setup...${NC}" >&2 - # Try to locate setup_periodic_system.sh - local setup_script="" - local self_dir - self_dir="$(dirname "$(readlink -f "$0")")" - if [[ -f "$self_dir/setup_periodic_system.sh" ]]; then - setup_script="$self_dir/setup_periodic_system.sh" - elif [[ -f "$HOME/linux-configuration/scripts/setup_periodic_system.sh" ]]; then - setup_script="$HOME/linux-configuration/scripts/setup_periodic_system.sh" - fi + # Try to locate setup_periodic_system.sh + local setup_script="" + local self_dir + self_dir="$(dirname "$(readlink -f "$0")")" + if [[ -f "$self_dir/setup_periodic_system.sh" ]]; then + setup_script="$self_dir/setup_periodic_system.sh" + elif [[ -f "$HOME/linux-configuration/scripts/setup_periodic_system.sh" ]]; then + setup_script="$HOME/linux-configuration/scripts/setup_periodic_system.sh" + fi - if [[ -n $setup_script ]]; then - if [[ $EUID -ne 0 ]]; then - sudo bash "$setup_script" - else - bash "$setup_script" - fi - echo -e "${CYAN}Tip:${NC} To disable these later:" >&2 - echo " sudo systemctl disable periodic-system-maintenance.timer" >&2 - echo " sudo systemctl disable periodic-system-startup.service" >&2 - echo " sudo systemctl disable hosts-file-monitor.service" >&2 - else - echo -e "${RED}Could not locate setup_periodic_system.sh to configure services automatically.${NC}" >&2 - fi + if [[ -n $setup_script ]]; then + if [[ $EUID -ne 0 ]]; then + sudo bash "$setup_script" + else + bash "$setup_script" + fi + echo -e "${CYAN}Tip:${NC} To disable these later:" >&2 + echo " sudo systemctl disable periodic-system-maintenance.timer" >&2 + echo " sudo systemctl disable periodic-system-startup.service" >&2 + echo " sudo systemctl disable hosts-file-monitor.service" >&2 + else + echo -e "${RED}Could not locate setup_periodic_system.sh to configure services automatically.${NC}" >&2 + fi } # Function to display help function show_help() { - echo -e "${BOLD}Pacman Wrapper Help${NC}" - echo "This wrapper adds helpful features while preserving all pacman functionality." - echo "" - echo "Additional commands:" - echo " --help-wrapper Show this help message" + echo -e "${BOLD}Pacman Wrapper Help${NC}" + echo "This wrapper adds helpful features while preserving all pacman functionality." + echo "" + echo "Additional commands:" + echo " --help-wrapper Show this help message" } # Function to display a message before executing function display_operation() { - case "$1" in - -S | -Sy | -S\ *) - echo -e "${BLUE}Installing packages...${NC}" >&2 - ;; - -Syu | -Syyu) - echo -e "${BLUE}Updating system...${NC}" >&2 - ;; - -R | -Rs | -Rns | -R\ *) - echo -e "${YELLOW}Removing packages...${NC}" >&2 - ;; - -Ss | -Ss\ *) - echo -e "${CYAN}Searching for packages...${NC}" >&2 - ;; - -Q | -Qs | -Qi | -Ql | -Q\ *) - echo -e "${CYAN}Querying package database...${NC}" >&2 - ;; - -U | -U\ *) - echo -e "${BLUE}Installing local packages...${NC}" >&2 - ;; - -Scc) - echo -e "${YELLOW}Cleaning package cache...${NC}" >&2 - ;; - *) - echo -e "${CYAN}Executing pacman command...${NC}" >&2 - ;; - esac + case "$1" in + -S | -Sy | -S\ *) + echo -e "${BLUE}Installing packages...${NC}" >&2 + ;; + -Syu | -Syyu) + echo -e "${BLUE}Updating system...${NC}" >&2 + ;; + -R | -Rs | -Rns | -R\ *) + echo -e "${YELLOW}Removing packages...${NC}" >&2 + ;; + -Ss | -Ss\ *) + echo -e "${CYAN}Searching for packages...${NC}" >&2 + ;; + -Q | -Qs | -Qi | -Ql | -Q\ *) + echo -e "${CYAN}Querying package database...${NC}" >&2 + ;; + -U | -U\ *) + echo -e "${BLUE}Installing local packages...${NC}" >&2 + ;; + -Scc) + echo -e "${YELLOW}Cleaning package cache...${NC}" >&2 + ;; + *) + echo -e "${CYAN}Executing pacman command...${NC}" >&2 + ;; + esac } # Helper: return 0 if the given package name is blocked by policy function is_blocked_package_name() { - load_policy_lists - local normalized="${1,,}" + load_policy_lists + local normalized="${1,,}" - for allowed in "${WHITELISTED_NAMES_LIST[@]}"; do - if [[ $normalized == "$allowed" ]]; then - return 1 - fi - done + for allowed in "${WHITELISTED_NAMES_LIST[@]}"; do + if [[ $normalized == "$allowed" ]]; then + return 1 + fi + done - for keyword in "${BLOCKED_KEYWORDS_LIST[@]}"; do - if [[ $normalized == *"$keyword"* ]]; then - return 0 - fi - done + for keyword in "${BLOCKED_KEYWORDS_LIST[@]}"; do + if [[ $normalized == *"$keyword"* ]]; then + return 0 + fi + done - return 1 + return 1 } # Helper: return 0 if the given package name is greylisted (challenge required) function is_greylisted_package_name() { - load_policy_lists - local normalized="${1,,}" + load_policy_lists + local normalized="${1,,}" - for keyword in "${GREYLISTED_KEYWORDS_LIST[@]}"; do - if [[ $normalized == *"$keyword"* ]]; then - return 0 - fi - done + for keyword in "${GREYLISTED_KEYWORDS_LIST[@]}"; do + if [[ $normalized == *"$keyword"* ]]; then + return 0 + fi + done - return 1 + return 1 } # Helper: detect if current invocation includes --noconfirm function has_noconfirm_flag() { - for arg in "$@"; do - if [[ $arg == "--noconfirm" ]]; then - return 0 - fi - done - return 1 + for arg in "$@"; do + if [[ $arg == "--noconfirm" ]]; then + return 0 + fi + done + return 1 } # Helper: get list of PIDs holding a lock file (excluding our own PID) # Populates the $holders array get_lock_holders() { - local lock_file="$1" - holders=() - if command -v fuser > /dev/null 2>&1; then - mapfile -t holders < <(fuser "$lock_file" 2> /dev/null | tr ' ' '\n' | grep -E '^[0-9]+$' || true) - elif command -v lsof > /dev/null 2>&1; then - mapfile -t holders < <(lsof -t "$lock_file" 2> /dev/null | grep -E '^[0-9]+$' || true) - fi - # Filter out our own PID - if [[ ${#holders[@]} -gt 0 ]]; then - local -a filtered=() - for pid in "${holders[@]}"; do - [[ $pid -eq $$ ]] && continue - filtered+=("$pid") - done - holders=("${filtered[@]}") - fi + local lock_file="$1" + holders=() + if command -v fuser >/dev/null 2>&1; then + mapfile -t holders < <(fuser "$lock_file" 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+$' || true) + elif command -v lsof >/dev/null 2>&1; then + mapfile -t holders < <(lsof -t "$lock_file" 2>/dev/null | grep -E '^[0-9]+$' || true) + fi + # Filter out our own PID + if [[ ${#holders[@]} -gt 0 ]]; then + local -a filtered=() + for pid in "${holders[@]}"; do + [[ $pid -eq $$ ]] && continue + filtered+=("$pid") + done + holders=("${filtered[@]}") + fi } # Handle stale pacman database lock if present and no package managers are running check_and_handle_db_lock() { - local lock_file="/var/lib/pacman/db.lck" - # Quick exit if no lock - if [[ ! -e $lock_file ]]; then - return 0 - fi + local lock_file="/var/lib/pacman/db.lck" + # Quick exit if no lock + if [[ ! -e $lock_file ]]; then + return 0 + fi - # Determine which processes actually have the lock open - local -a holders=() - get_lock_holders "$lock_file" + # Determine which processes actually have the lock open + local -a holders=() + get_lock_holders "$lock_file" - if [[ ${#holders[@]} -gt 0 ]]; then - local pac_holder=0 - local gui_holder=0 - for pid in "${holders[@]}"; do - local comm args lower - comm=$(ps -p "$pid" -o comm= 2> /dev/null || true) - args=$(ps -p "$pid" -o args= 2> /dev/null || true) - lower="${comm,,} ${args,,}" - if [[ $lower == *" pacman"* || $lower == pacman* || $lower == *"/pacman "* || $lower == *" pamac"* ]]; then - pac_holder=1 - elif [[ $lower == *packagekit* || $lower == *gnome-software* || $lower == *discover* ]]; then - gui_holder=1 - fi - done + if [[ ${#holders[@]} -gt 0 ]]; then + local pac_holder=0 + local gui_holder=0 + for pid in "${holders[@]}"; do + local comm args lower + comm=$(ps -p "$pid" -o comm= 2>/dev/null || true) + args=$(ps -p "$pid" -o args= 2>/dev/null || true) + lower="${comm,,} ${args,,}" + if [[ $lower == *" pacman"* || $lower == pacman* || $lower == *"/pacman "* || $lower == *" pamac"* ]]; then + pac_holder=1 + elif [[ $lower == *packagekit* || $lower == *gnome-software* || $lower == *discover* ]]; then + gui_holder=1 + fi + done - if [[ $pac_holder -eq 1 ]]; then - echo -e "${RED}Another pacman/pamac transaction is holding the database lock. Try again later.${NC}" >&2 - return 1 - fi + if [[ $pac_holder -eq 1 ]]; then + echo -e "${RED}Another pacman/pamac transaction is holding the database lock. Try again later.${NC}" >&2 + return 1 + fi - if [[ $gui_holder -eq 1 ]]; then - echo -e "${YELLOW}A background software updater is holding the pacman lock. Attempting to stop it...${NC}" >&2 - if command -v systemctl > /dev/null 2>&1; then - systemctl --quiet stop packagekit.service 2> /dev/null || true - systemctl --quiet stop packagekit 2> /dev/null || true - fi - pkill -x packagekitd 2> /dev/null || true - pkill -f gnome-software 2> /dev/null || true - pkill -f discover 2> /dev/null || true - sleep 1 + if [[ $gui_holder -eq 1 ]]; then + echo -e "${YELLOW}A background software updater is holding the pacman lock. Attempting to stop it...${NC}" >&2 + if command -v systemctl >/dev/null 2>&1; then + systemctl --quiet stop packagekit.service 2>/dev/null || true + systemctl --quiet stop packagekit 2>/dev/null || true + fi + pkill -x packagekitd 2>/dev/null || true + pkill -f gnome-software 2>/dev/null || true + pkill -f discover 2>/dev/null || true + sleep 1 - # Re-check holders - get_lock_holders "$lock_file" - if [[ ${#holders[@]} -gt 0 ]]; then - echo -e "${RED}Cannot free the pacman lock; another process still holds it. Try again later.${NC}" >&2 - return 1 - fi - fi - fi + # Re-check holders + get_lock_holders "$lock_file" + if [[ ${#holders[@]} -gt 0 ]]; then + echo -e "${RED}Cannot free the pacman lock; another process still holds it. Try again later.${NC}" >&2 + return 1 + fi + fi + fi - # Helper to remove a file with sudo if needed - remove_file_as_root() { - local f="$1" - if [[ $EUID -ne 0 ]]; then - sudo rm -f "$f" - else - rm -f "$f" - fi - } + # Helper to remove a file with sudo if needed + remove_file_as_root() { + local f="$1" + if [[ $EUID -ne 0 ]]; then + sudo rm -f "$f" + else + rm -f "$f" + fi + } - # Decide whether to remove the lock - local now epoch age - if epoch=$(stat -c %Y "$lock_file" 2> /dev/null); then - now=$(date +%s) - age=$((now - epoch)) - else - age=999999 - fi + # Decide whether to remove the lock + local now epoch age + if epoch=$(stat -c %Y "$lock_file" 2>/dev/null); then + now=$(date +%s) + age=$((now - epoch)) + else + age=999999 + fi - # Auto-remove in non-interactive mode (--noconfirm) or if the lock is older than 10 minutes - if has_noconfirm_flag "$@" || [[ $age -ge 600 ]]; then - echo -e "${YELLOW}Stale pacman lock detected (age: ${age}s). Removing it automatically...${NC}" >&2 - remove_file_as_root "$lock_file" || return 1 - return 0 - fi + # Auto-remove in non-interactive mode (--noconfirm) or if the lock is older than 10 minutes + if has_noconfirm_flag "$@" || [[ $age -ge 600 ]]; then + echo -e "${YELLOW}Stale pacman lock detected (age: ${age}s). Removing it automatically...${NC}" >&2 + remove_file_as_root "$lock_file" || return 1 + return 0 + fi - # Interactive prompt (15s timeout) - echo -e "${YELLOW}A pacman lock exists but no active pacman is running.${NC}" >&2 - echo -e "${CYAN}Lock path:${NC} $lock_file (age: ${age}s)" >&2 - read -r -t 15 -p $'Remove stale lock and continue? [y/N]: ' reply || reply="n" - if [[ ${reply,,} == "y" || ${reply,,} == "yes" ]]; then - remove_file_as_root "$lock_file" || return 1 - return 0 - fi - echo -e "${RED}Aborting due to existing pacman lock. Close other updaters and retry, or run with --noconfirm to auto-clear stale locks.${NC}" >&2 - return 1 + # Interactive prompt (15s timeout) + echo -e "${YELLOW}A pacman lock exists but no active pacman is running.${NC}" >&2 + echo -e "${CYAN}Lock path:${NC} $lock_file (age: ${age}s)" >&2 + read -r -t 15 -p $'Remove stale lock and continue? [y/N]: ' reply || reply="n" + if [[ ${reply,,} == "y" || ${reply,,} == "yes" ]]; then + remove_file_as_root "$lock_file" || return 1 + return 0 + fi + echo -e "${RED}Aborting due to existing pacman lock. Close other updaters and retry, or run with --noconfirm to auto-clear stale locks.${NC}" >&2 + return 1 } # Generic function to remove installed packages matching a filter # Args: check_function label_prefix function remove_installed_packages_matching() { - local check_function="$1" - local label="$2" + local check_function="$1" + local label="$2" - mapfile -t installed_names < <("$PACMAN_BIN" -Qq 2> /dev/null) - local to_remove=() - for name in "${installed_names[@]}"; do - if "$check_function" "$name"; then - to_remove+=("$name") - fi - done + mapfile -t installed_names < <("$PACMAN_BIN" -Qq 2>/dev/null) + local to_remove=() + for name in "${installed_names[@]}"; do + if "$check_function" "$name"; then + to_remove+=("$name") + fi + done - if [[ ${#to_remove[@]} -eq 0 ]]; then - return 0 - fi + if [[ ${#to_remove[@]} -eq 0 ]]; then + return 0 + fi - echo -e "${YELLOW}${label} cleanup:${NC} Removing packages: ${BOLD}${to_remove[*]}${NC}" >&2 - "$PACMAN_BIN" -Rns --noconfirm "${to_remove[@]}" - local rc=$? - if [[ $rc -ne 0 ]]; then - echo -e "${RED}${label} cleanup removal failed with exit code ${rc}.${NC}" >&2 - else - echo -e "${GREEN}${label} cleanup removal completed for: ${to_remove[*]}${NC}" >&2 - fi - return $rc + echo -e "${YELLOW}${label} cleanup:${NC} Removing packages: ${BOLD}${to_remove[*]}${NC}" >&2 + "$PACMAN_BIN" -Rns --noconfirm "${to_remove[@]}" + local rc=$? + if [[ $rc -ne 0 ]]; then + echo -e "${RED}${label} cleanup removal failed with exit code ${rc}.${NC}" >&2 + else + echo -e "${GREEN}${label} cleanup removal completed for: ${to_remove[*]}${NC}" >&2 + fi + return $rc } # Cleanup: remove any installed blocked packages function remove_installed_blocked_packages() { - remove_installed_packages_matching is_blocked_package_name "Policy" + remove_installed_packages_matching is_blocked_package_name "Policy" } # Cleanup: remove any installed greylisted packages function remove_installed_greylisted_packages() { - remove_installed_packages_matching is_greylisted_package_name "Greylist" + remove_installed_packages_matching is_greylisted_package_name "Greylist" } # Helper: Check if this is an install command and run a filter on each package name # Usage: check_install_for filter_func "$@" # Returns 0 if filter_func matches any package function check_install_for() { - local filter_func="$1" - shift - # Check if the command is an installation command - if [[ ${1:-} == "-S" || ${1:-} == "-Sy" || ${1:-} == "-Syu" || ${1:-} == "-Syyu" || ${1:-} == "-U" ]]; then - for arg in "$@"; do - # Strip repository prefix if present (like extra/ or community/) - local package_name="${arg##*/}" - if "$filter_func" "$package_name"; then - return 0 - fi - done - fi - return 1 + local filter_func="$1" + shift + # Check if the command is an installation command + if [[ ${1:-} == "-S" || ${1:-} == "-Sy" || ${1:-} == "-Syu" || ${1:-} == "-Syyu" || ${1:-} == "-U" ]]; then + for arg in "$@"; do + # Strip repository prefix if present (like extra/ or community/) + local package_name="${arg##*/}" + if "$filter_func" "$package_name"; then + return 0 + fi + done + fi + return 1 } # Function to check if user is trying to install packages that are always blocked function check_for_always_blocked() { - check_install_for is_blocked_package_name "$@" + check_install_for is_blocked_package_name "$@" } # Helper to check if a package name is steam function is_steam_package() { - [[ $1 == "steam" ]] + [[ $1 == "steam" ]] } # Helper to check if a package name is VirtualBox (hardcoded, cannot be bypassed by editing policy files) function is_virtualbox_package() { - local pkg_lower="${1,,}" - [[ $pkg_lower == *"virtualbox"* || $pkg_lower == *"vbox"* ]] + local pkg_lower="${1,,}" + [[ $pkg_lower == *"virtualbox"* || $pkg_lower == *"vbox"* ]] } # Function to check if user is trying to install steam (challenge-eligible package) function check_for_steam() { - check_install_for is_steam_package "$@" + check_install_for is_steam_package "$@" } # Function to check if user is trying to install VirtualBox (hardcoded enforcement) function check_for_virtualbox() { - check_install_for is_virtualbox_package "$@" + check_install_for is_virtualbox_package "$@" } # Function to check if current day is a weekday (after 4PM Friday until midnight Sunday) function is_weekday() { - local day_of_week - day_of_week=$(date +%u) # %u gives 1-7 (Monday is 1, Sunday is 7) - local hour - hour=$(date +%H) # %H gives hour in 24-hour format (00-23) + local day_of_week + day_of_week=$(date +%u) # %u gives 1-7 (Monday is 1, Sunday is 7) + local hour + hour=$(date +%H) # %H gives hour in 24-hour format (00-23) - # Monday through Thursday are always weekdays - if [[ $day_of_week -ge 1 && $day_of_week -le 4 ]]; then - return 0 # Is weekday - # Friday before 4PM is weekday, after 4PM is weekend - elif [[ $day_of_week -eq 5 ]]; then - if [[ $hour -lt 14 ]]; then - return 0 # Is weekday (Friday before 4PM) - else - return 1 # Is weekend (Friday after 4PM) - fi - # Saturday and Sunday are weekend - else - return 1 # Is weekend - fi + # Monday through Thursday are always weekdays + if [[ $day_of_week -ge 1 && $day_of_week -le 4 ]]; then + return 0 # Is weekday + # Friday before 4PM is weekday, after 4PM is weekend + elif [[ $day_of_week -eq 5 ]]; then + if [[ $hour -lt 14 ]]; then + return 0 # Is weekday (Friday before 4PM) + else + return 1 # Is weekend (Friday after 4PM) + fi + # Saturday and Sunday are weekend + else + return 1 # Is weekend + fi } # Unified word unscrambling challenge function # Args: challenge_name word_length words_count timeout_seconds initial_delay_max post_delay_min post_delay_range function run_word_challenge() { - local challenge_name="$1" - local word_length="$2" - local words_count="$3" - local timeout_seconds="$4" - local initial_delay_max="${5:-20}" - local post_delay_min="${6:-0}" - local post_delay_range="${7:-20}" + local challenge_name="$1" + local word_length="$2" + local words_count="$3" + local timeout_seconds="$4" + local initial_delay_max="${5:-20}" + local post_delay_min="${6:-0}" + local post_delay_range="${7:-20}" - echo -e "${YELLOW}${challenge_name} challenge will begin shortly...${NC}" + echo -e "${YELLOW}${challenge_name} challenge will begin shortly...${NC}" - # Initial delay - local sleep_duration=$((RANDOM % initial_delay_max)) - sleep "$sleep_duration" + # Initial delay + local sleep_duration=$((RANDOM % initial_delay_max)) + sleep "$sleep_duration" - # Load words file - local script_dir words_file - script_dir="$(dirname "$(readlink -f "$0")")" - words_file="$script_dir/words.txt" + # Load words file + local script_dir words_file + script_dir="$(dirname "$(readlink -f "$0")")" + words_file="$script_dir/words.txt" - if [[ ! -f $words_file ]]; then - echo -e "${RED}Error: words.txt file not found at $words_file${NC}" - return 1 - fi + if [[ ! -f $words_file ]]; then + echo -e "${RED}Error: words.txt file not found at $words_file${NC}" + return 1 + fi - echo -e "${CYAN}Challenge: Words with ${word_length} letters${NC}" + echo -e "${CYAN}Challenge: Words with ${word_length} letters${NC}" - # Load random words of specified length - local -a selected_words - mapfile -t selected_words < <(grep -E "^[a-zA-Z]{$word_length}$" "$words_file" | shuf -n "$words_count") + # Load random words of specified length + local -a selected_words + mapfile -t selected_words < <(grep -E "^[a-zA-Z]{$word_length}$" "$words_file" | shuf -n "$words_count") - if [[ ${#selected_words[@]} -lt $words_count ]]; then - echo -e "${RED}Warning: Could only find ${#selected_words[@]} words of length $word_length.${NC}" - words_count=${#selected_words[@]} - if [[ $words_count -eq 0 ]]; then - echo -e "${RED}Error: No words of length $word_length found in $words_file${NC}" - return 1 - fi - fi + if [[ ${#selected_words[@]} -lt $words_count ]]; then + echo -e "${RED}Warning: Could only find ${#selected_words[@]} words of length $word_length.${NC}" + words_count=${#selected_words[@]} + if [[ $words_count -eq 0 ]]; then + echo -e "${RED}Error: No words of length $word_length found in $words_file${NC}" + return 1 + fi + fi - # Convert to uppercase - for i in "${!selected_words[@]}"; do - selected_words[i]=$(echo "${selected_words[i]}" | tr '[:lower:]' '[:upper:]') - done + # Convert to uppercase + for i in "${!selected_words[@]}"; do + selected_words[i]=$(echo "${selected_words[i]}" | tr '[:lower:]' '[:upper:]') + done - echo -e "${CYAN}Here are ${words_count} random words. Remember them:${NC}" + echo -e "${CYAN}Here are ${words_count} random words. Remember them:${NC}" - # Display words in grid - for ((i = 0; i < words_count; i++)); do - printf "${BLUE}%-15s${NC}" "${selected_words[i]}" - if (((i + 1) % 4 == 0)); then - echo "" - fi - done + # Display words in grid + for ((i = 0; i < words_count; i++)); do + printf "${BLUE}%-15s${NC}" "${selected_words[i]}" + if (((i + 1) % 4 == 0)); then + echo "" + fi + done - # Select and scramble a word - local target_index target_word scrambled_word - target_index=$((RANDOM % words_count)) - target_word="${selected_words[target_index]}" - scrambled_word=$(echo "$target_word" | fold -w1 | shuf | tr -d '\n') + # Select and scramble a word + local target_index target_word scrambled_word + target_index=$((RANDOM % words_count)) + target_word="${selected_words[target_index]}" + scrambled_word=$(echo "$target_word" | fold -w1 | shuf | tr -d '\n') - if [[ $scrambled_word == "$target_word" ]]; then - scrambled_word=$(echo "$target_word" | rev) - fi + if [[ $scrambled_word == "$target_word" ]]; then + scrambled_word=$(echo "$target_word" | rev) + fi - echo -e "\n${YELLOW}One of those words has been scrambled to:${NC} ${CYAN}$scrambled_word${NC}" - echo -e "${YELLOW}Unscramble the word to proceed (you have $timeout_seconds seconds):${NC}" + echo -e "\n${YELLOW}One of those words has been scrambled to:${NC} ${CYAN}$scrambled_word${NC}" + echo -e "${YELLOW}Unscramble the word to proceed (you have $timeout_seconds seconds):${NC}" - # Timer display background process - ( - local start_time current_time elapsed remaining - start_time=$(date +%s) - while true; do - current_time=$(date +%s) - elapsed=$((current_time - start_time)) - remaining=$((timeout_seconds - elapsed)) - if [[ $remaining -le 0 ]]; then - echo -ne "\r${YELLOW}Time remaining: 0 seconds${NC} " - break - fi - echo -ne "\r${YELLOW}Time remaining: ${remaining} seconds${NC} " - sleep 1 - done - ) & - local display_pid=$! + # Timer display background process + ( + local start_time current_time elapsed remaining + start_time=$(date +%s) + while true; do + current_time=$(date +%s) + elapsed=$((current_time - start_time)) + remaining=$((timeout_seconds - elapsed)) + if [[ $remaining -le 0 ]]; then + echo -ne "\r${YELLOW}Time remaining: 0 seconds${NC} " + break + fi + echo -ne "\r${YELLOW}Time remaining: ${remaining} seconds${NC} " + sleep 1 + done + ) & + local display_pid=$! - # Read input with timeout - local user_input read_status - read -t "$timeout_seconds" -r user_input - read_status=$? + # Read input with timeout + local user_input read_status + read -t "$timeout_seconds" -r user_input + read_status=$? - kill "$display_pid" 2> /dev/null - wait "$display_pid" 2> /dev/null - echo + kill "$display_pid" 2>/dev/null + wait "$display_pid" 2>/dev/null + echo - if [[ $read_status -ne 0 ]]; then - echo -e "${RED}Time's up! Challenge failed. The correct word was '$target_word'.${NC}" - return 1 - fi + if [[ $read_status -ne 0 ]]; then + echo -e "${RED}Time's up! Challenge failed. The correct word was '$target_word'.${NC}" + return 1 + fi - user_input=$(echo "$user_input" | tr '[:lower:]' '[:upper:]' | xargs) + user_input=$(echo "$user_input" | tr '[:lower:]' '[:upper:]' | xargs) - if [[ $user_input == "$target_word" ]]; then - echo -e "${GREEN}Correct! Proceeding with installation...${NC}" - local post_challenge_sleep=$((RANDOM % post_delay_range + post_delay_min)) - [[ $post_challenge_sleep -gt 0 ]] && sleep "$post_challenge_sleep" - return 0 - else - echo -e "${RED}Incorrect answer. Installation aborted. The correct word was '$target_word'.${NC}" - return 1 - fi + if [[ $user_input == "$target_word" ]]; then + echo -e "${GREEN}Correct! Proceeding with installation...${NC}" + local post_challenge_sleep=$((RANDOM % post_delay_range + post_delay_min)) + [[ $post_challenge_sleep -gt 0 ]] && sleep "$post_challenge_sleep" + return 0 + else + echo -e "${RED}Incorrect answer. Installation aborted. The correct word was '$target_word'.${NC}" + return 1 + fi } # Function to prompt for solving a word unscrambling challenge (only for steam) function prompt_for_steam_challenge() { - echo -e "${YELLOW}WARNING: You are trying to install Steam.${NC}" + echo -e "${YELLOW}WARNING: You are trying to install Steam.${NC}" - # Check if it's a weekday and block completely - if is_weekday; then - local day_name - day_name=$(date +%A) - echo -e "${RED}Steam installation BLOCKED: Steam cannot be installed on weekdays.${NC}" - echo -e "${RED}Today is $day_name. Please try again on the weekend (Saturday or Sunday).${NC}" - return 1 - fi + # Check if it's a weekday and block completely + if is_weekday; then + local day_name + day_name=$(date +%A) + echo -e "${RED}Steam installation BLOCKED: Steam cannot be installed on weekdays.${NC}" + echo -e "${RED}Today is $day_name. Please try again on the weekend (Saturday or Sunday).${NC}" + return 1 + fi - # word_length=5, words_count=160, timeout=60s, initial_delay=20, post_delay=0-20 - run_word_challenge "Weekend Steam" 5 160 60 20 0 20 + # word_length=5, words_count=160, timeout=60s, initial_delay=20, post_delay=0-20 + run_word_challenge "Weekend Steam" 5 160 60 20 0 20 } function check_for_greylisted() { - check_install_for is_greylisted_package_name "$@" + check_install_for is_greylisted_package_name "$@" } # Function to prompt for solving a word unscrambling challenge (for greylisted packages - always active) function prompt_for_greylist_challenge() { - echo -e "${YELLOW}WARNING: You are trying to install a greylisted package.${NC}" + echo -e "${YELLOW}WARNING: You are trying to install a greylisted package.${NC}" - # word_length=6, words_count=120, timeout=90s, initial_delay=30, post_delay=15-35 - run_word_challenge "Greylist" 6 120 90 30 15 20 + # word_length=6, words_count=120, timeout=90s, initial_delay=30, post_delay=15-35 + run_word_challenge "Greylist" 6 120 90 30 15 20 } # Function to prompt for VirtualBox installation (enhanced security, hardcoded) function prompt_for_virtualbox_challenge() { - echo -e "${RED}═══════════════════════════════════════════════════════${NC}" - echo -e "${RED} VIRTUALBOX INSTALLATION ATTEMPT DETECTED ${NC}" - echo -e "${RED}═══════════════════════════════════════════════════════${NC}" - echo -e "${YELLOW}WARNING: You are trying to install VirtualBox.${NC}" - echo -e "${YELLOW}This package can be used to bypass /etc/hosts restrictions.${NC}" - echo -e "" - echo -e "${CYAN}Security measures will be automatically applied:${NC}" - echo -e " 1. VMs will use host's DNS resolution" - echo -e " 2. Host's /etc/hosts will be shared with VMs (read-only)" - echo -e " 3. Policy enforcement cannot be disabled via file editing" - echo -e "" - echo -e "${YELLOW}This is a HARDCODED restriction that cannot be bypassed by${NC}" - echo -e "${YELLOW}modifying policy files or reinstalling the wrapper.${NC}" - echo -e "" - - # More difficult challenge: word_length=7, words_count=150, timeout=120s, initial_delay=45, post_delay=30-50 - run_word_challenge "VirtualBox Security" 7 150 120 45 30 20 + echo -e "${RED}═══════════════════════════════════════════════════════${NC}" + echo -e "${RED} VIRTUALBOX INSTALLATION ATTEMPT DETECTED ${NC}" + echo -e "${RED}═══════════════════════════════════════════════════════${NC}" + echo -e "${YELLOW}WARNING: You are trying to install VirtualBox.${NC}" + echo -e "${YELLOW}This package can be used to bypass /etc/hosts restrictions.${NC}" + echo -e "" + echo -e "${CYAN}Security measures will be automatically applied:${NC}" + echo -e " 1. VMs will use host's DNS resolution" + echo -e " 2. Host's /etc/hosts will be shared with VMs (read-only)" + echo -e " 3. Policy enforcement cannot be disabled via file editing" + echo -e "" + echo -e "${YELLOW}This is a HARDCODED restriction that cannot be bypassed by${NC}" + echo -e "${YELLOW}modifying policy files or reinstalling the wrapper.${NC}" + echo -e "" + + # More difficult challenge: word_length=7, words_count=150, timeout=120s, initial_delay=45, post_delay=30-50 + run_word_challenge "VirtualBox Security" 7 150 120 45 30 20 } # Check for wrapper-specific commands if [[ $1 == "--help-wrapper" ]]; then - show_help - exit 0 + show_help + exit 0 fi # CRITICAL: Verify policy file integrity before any operations if ! verify_policy_integrity; then - exit 1 + exit 1 fi # Before any pacman action, ensure maintenance services exist ensure_periodic_maintenance +# PROACTIVE CLEANUP: Always check and remove blocked packages at startup +# This catches packages that were installed before the wrapper or via other means +echo -e "${CYAN}Checking for blocked packages...${NC}" >&2 +remove_installed_blocked_packages "$@" +remove_installed_greylisted_packages "$@" + # Check for always blocked packages first (highest priority) if check_for_always_blocked "$@"; then - echo -e "${RED}Installation BLOCKED: This package is permanently restricted and cannot be installed.${NC}" - echo -e "${RED}Package installation has been denied by system policy.${NC}" - # Regardless of the attempted action, enforce cleanup of any installed blocked packages - remove_installed_blocked_packages "$@" - exit 1 + echo -e "${RED}Installation BLOCKED: This package is permanently restricted and cannot be installed.${NC}" + echo -e "${RED}Package installation has been denied by system policy.${NC}" + # Regardless of the attempted action, enforce cleanup of any installed blocked packages + remove_installed_blocked_packages "$@" + exit 1 fi # Check for steam (challenge-eligible package) if check_for_steam "$@"; then - if ! prompt_for_steam_challenge; then - exit 1 - fi + if ! prompt_for_steam_challenge; then + exit 1 + fi fi # Check for VirtualBox (HARDCODED - cannot be bypassed by editing policy files) if check_for_virtualbox "$@"; then - if ! prompt_for_virtualbox_challenge; then - exit 1 - fi + if ! prompt_for_virtualbox_challenge; then + exit 1 + fi fi # Check for greylisted packages (challenge-eligible) if check_for_greylisted "$@"; then - if ! prompt_for_greylist_challenge; then - exit 1 - fi + if ! prompt_for_greylist_challenge; then + exit 1 + fi fi # Display operation @@ -712,19 +718,19 @@ start_time=$(date +%s) # Execute the real pacman command (with /etc/hosts guard handling) if needs_unlock "$@"; then - pre_unlock_hosts + pre_unlock_hosts fi # Handle a possible stale DB lock before executing if ! check_and_handle_db_lock "$@"; then - exit 1 + exit 1 fi "$PACMAN_BIN" "$@" exit_code=$? if needs_unlock "$@"; then - post_relock_hosts + post_relock_hosts fi # Record end time for statistics @@ -733,9 +739,9 @@ duration=$((end_time - start_time)) # Display results if [ $exit_code -eq 0 ]; then - echo -e "${GREEN}Command completed successfully in ${duration}s.${NC}" >&2 + echo -e "${GREEN}Command completed successfully in ${duration}s.${NC}" >&2 else - echo -e "${RED}Command failed with exit code ${exit_code}.${NC}" >&2 + echo -e "${RED}Command failed with exit code ${exit_code}.${NC}" >&2 fi # After any operation, remove installed blocked packages as part of policy enforcement @@ -744,79 +750,142 @@ remove_installed_blocked_packages "$@" # Also remove installed greylisted packages remove_installed_greylisted_packages "$@" +# Auto-install LeechBlock if a browser is detected +auto_install_leechblock() { + # Only check after install operations + if [[ -z ${1:-} ]] || [[ $1 != "-S"* && $1 != "-U"* ]]; then + return 0 + fi + + # List of browser packages to check for + local browsers=("firefox" "librewolf" "chromium" "brave" "vivaldi" "google-chrome" "ungoogled-chromium") + local browser_found=0 + + for browser in "${browsers[@]}"; do + if "$PACMAN_BIN" -Qq "$browser" 2>/dev/null; then + browser_found=1 + break + fi + done + + if [[ $browser_found -eq 0 ]]; then + return 0 + fi + + # Find the LeechBlock installer + local script_dir + script_dir="$(dirname "$(readlink -f "$0")")" + local leechblock_installer="" + + if [[ -f "$script_dir/../install_leechblock.sh" ]]; then + leechblock_installer="$script_dir/../install_leechblock.sh" + elif [[ -f "$HOME/linux-configuration/scripts/digital_wellbeing/install_leechblock.sh" ]]; then + leechblock_installer="$HOME/linux-configuration/scripts/digital_wellbeing/install_leechblock.sh" + elif [[ -f "/usr/local/share/digital_wellbeing/install_leechblock.sh" ]]; then + leechblock_installer="/usr/local/share/digital_wellbeing/install_leechblock.sh" + fi + + if [[ -z $leechblock_installer ]]; then + echo -e "${YELLOW}Browser detected but LeechBlock installer not found.${NC}" >&2 + return 0 + fi + + # Check if LeechBlock is already installed (by looking for the extension directory) + if [[ -d "$HOME/.local/share/leechblockng" ]]; then + return 0 + fi + + echo -e "${CYAN}Browser detected. Installing LeechBlock extension for website blocking...${NC}" >&2 + + # Run the LeechBlock installer (as current user, not root) + if [[ $EUID -eq 0 && -n "${SUDO_USER:-}" ]]; then + sudo -u "$SUDO_USER" bash "$leechblock_installer" --install-firefox 2>&1 || { + echo -e "${YELLOW}LeechBlock auto-install failed. Please install manually:${NC}" >&2 + echo -e "${YELLOW} $leechblock_installer${NC}" >&2 + } + else + bash "$leechblock_installer" --install-firefox 2>&1 || { + echo -e "${YELLOW}LeechBlock auto-install failed. Please install manually:${NC}" >&2 + echo -e "${YELLOW} $leechblock_installer${NC}" >&2 + } + fi +} + +auto_install_leechblock "$@" + # If VirtualBox was involved in this operation, enforce hosts file sharing enforce_vbox_hosts_if_needed() { - # Only check after install operations - if [[ -z ${1:-} ]]; then - return 0 - fi - - if [[ $1 != "-S"* && $1 != "-U"* ]]; then - return 0 - fi - - # Check if ANY VirtualBox package is installed (use broader search) - local vbox_installed=0 - if "$PACMAN_BIN" -Qq 2>/dev/null | grep -Eq '^(virtualbox|vbox)'; then - vbox_installed=1 - fi - - if [[ $vbox_installed -eq 0 ]]; then - return 0 - fi - - # Locate the enforcement script - local script_dir - script_dir="$(dirname "$(readlink -f "$0")")" - local vbox_enforce_script="" - - # Try to find the enforcement script - if [[ -f "$script_dir/../virtualbox/enforce_vbox_hosts.sh" ]]; then - vbox_enforce_script="$script_dir/../virtualbox/enforce_vbox_hosts.sh" - elif [[ -f "$HOME/linux-configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" ]]; then - vbox_enforce_script="$HOME/linux-configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" - elif [[ -f "/usr/local/share/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" ]]; then - vbox_enforce_script="/usr/local/share/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" - fi - - if [[ -z $vbox_enforce_script ]]; then - echo -e "${YELLOW}VirtualBox detected but enforcement script not found. Hosts file may not be enforced in VMs.${NC}" >&2 - return 0 - fi - - # Check if enforcement is already applied - if bash "$vbox_enforce_script" check > /dev/null 2>&1; then - return 0 - fi - - # VirtualBox is installed but enforcement not applied - this is critical - echo -e "${YELLOW}VirtualBox detected. Applying /etc/hosts enforcement to VMs...${NC}" >&2 - # Note: The wrapper may be running as non-root user (via sudo pacman), but enforcement - # script needs root. We check EUID to avoid double sudo if already running as root. - if [[ $EUID -ne 0 ]]; then - if ! sudo bash "$vbox_enforce_script" enforce; then - echo -e "${RED}CRITICAL: Failed to enforce hosts on VirtualBox VMs!${NC}" >&2 - echo -e "${RED}VMs may bypass /etc/hosts restrictions. Please run manually:${NC}" >&2 - echo -e "${RED} sudo $vbox_enforce_script enforce${NC}" >&2 - fi - else - if ! bash "$vbox_enforce_script" enforce; then - echo -e "${RED}CRITICAL: Failed to enforce hosts on VirtualBox VMs!${NC}" >&2 - echo -e "${RED}VMs may bypass /etc/hosts restrictions. Please run manually:${NC}" >&2 - echo -e "${RED} $vbox_enforce_script enforce${NC}" >&2 - fi - fi + # Only check after install operations + if [[ -z ${1:-} ]]; then + return 0 + fi + + if [[ $1 != "-S"* && $1 != "-U"* ]]; then + return 0 + fi + + # Check if ANY VirtualBox package is installed (use broader search) + local vbox_installed=0 + if "$PACMAN_BIN" -Qq 2>/dev/null | grep -Eq '^(virtualbox|vbox)'; then + vbox_installed=1 + fi + + if [[ $vbox_installed -eq 0 ]]; then + return 0 + fi + + # Locate the enforcement script + local script_dir + script_dir="$(dirname "$(readlink -f "$0")")" + local vbox_enforce_script="" + + # Try to find the enforcement script + if [[ -f "$script_dir/../virtualbox/enforce_vbox_hosts.sh" ]]; then + vbox_enforce_script="$script_dir/../virtualbox/enforce_vbox_hosts.sh" + elif [[ -f "$HOME/linux-configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" ]]; then + vbox_enforce_script="$HOME/linux-configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" + elif [[ -f "/usr/local/share/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" ]]; then + vbox_enforce_script="/usr/local/share/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" + fi + + if [[ -z $vbox_enforce_script ]]; then + echo -e "${YELLOW}VirtualBox detected but enforcement script not found. Hosts file may not be enforced in VMs.${NC}" >&2 + return 0 + fi + + # Check if enforcement is already applied + if bash "$vbox_enforce_script" check >/dev/null 2>&1; then + return 0 + fi + + # VirtualBox is installed but enforcement not applied - this is critical + echo -e "${YELLOW}VirtualBox detected. Applying /etc/hosts enforcement to VMs...${NC}" >&2 + # Note: The wrapper may be running as non-root user (via sudo pacman), but enforcement + # script needs root. We check EUID to avoid double sudo if already running as root. + if [[ $EUID -ne 0 ]]; then + if ! sudo bash "$vbox_enforce_script" enforce; then + echo -e "${RED}CRITICAL: Failed to enforce hosts on VirtualBox VMs!${NC}" >&2 + echo -e "${RED}VMs may bypass /etc/hosts restrictions. Please run manually:${NC}" >&2 + echo -e "${RED} sudo $vbox_enforce_script enforce${NC}" >&2 + fi + else + if ! bash "$vbox_enforce_script" enforce; then + echo -e "${RED}CRITICAL: Failed to enforce hosts on VirtualBox VMs!${NC}" >&2 + echo -e "${RED}VMs may bypass /etc/hosts restrictions. Please run manually:${NC}" >&2 + echo -e "${RED} $vbox_enforce_script enforce${NC}" >&2 + fi + fi } enforce_vbox_hosts_if_needed "$@" # Display some helpful tips depending on the operation if [[ $1 == "-S" || $1 == "-S "* ]] && [ $exit_code -eq 0 ]; then - echo -e "${CYAN}Tip:${NC} You may need to log out or restart to use some newly installed software." + echo -e "${CYAN}Tip:${NC} You may need to log out or restart to use some newly installed software." fi if [[ $1 == "-Syu" || $1 == "-Syyu" ]] && [ $exit_code -eq 0 ]; then - echo -e "${CYAN}Tip:${NC} Consider restarting after major updates." + echo -e "${CYAN}Tip:${NC} Consider restarting after major updates." fi exit $exit_code diff --git a/scripts/digital_wellbeing/setup_midnight_shutdown.sh b/scripts/digital_wellbeing/setup_midnight_shutdown.sh index 86c1b51..ddc62c2 100755 --- a/scripts/digital_wellbeing/setup_midnight_shutdown.sh +++ b/scripts/digital_wellbeing/setup_midnight_shutdown.sh @@ -13,9 +13,9 @@ source "$SCRIPT_DIR/../lib/common.sh" # Schedule constants (single source of truth for this script) # These values are written to /etc/shutdown-schedule.conf during setup -SCHEDULE_MON_WED_HOUR=21 -SCHEDULE_THU_SUN_HOUR=22 -SCHEDULE_MORNING_END_HOUR=5 +SCHEDULE_MON_WED_HOUR=24 +SCHEDULE_THU_SUN_HOUR=24 +SCHEDULE_MORNING_END_HOUR=0 # ============================================================================ # SCHEDULE PROTECTION MECHANISM @@ -24,126 +24,108 @@ 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). -# To legitimately change the schedule, use: sudo /usr/local/sbin/unlock-shutdown-schedule # ============================================================================ CANONICAL_CONFIG="/usr/local/share/locked-shutdown-schedule.conf" # 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 - return 0 - fi + # Skip check if no canonical config exists (first install) + if [[ ! -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:-}" + # 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 ]] || [[ -z $canonical_thu_sun ]] || [[ -z $canonical_morning_end ]]; then - return 0 - fi + # If canonical values are empty, skip check + if [[ -z $canonical_mon_wed ]] || [[ -z $canonical_thu_sun ]] || [[ -z $canonical_morning_end ]]; then + return 0 + fi - local violations=() + 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 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 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 + # 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 "║ ❌ SCHEDULE MODIFICATION BLOCKED - CHEATING DETECTED! ❌ ║" - echo "╚══════════════════════════════════════════════════════════════════╝" - echo "" - echo "You modified the script to make the shutdown schedule MORE LENIENT:" - echo "" - for v in "${violations[@]}"; do - echo " • $v" - done - echo "" - echo "Current protected schedule:" - echo " Monday-Wednesday: ${canonical_mon_wed}:00 - 0${canonical_morning_end}:00" - echo " Thursday-Sunday: ${canonical_thu_sun}:00 - 0${canonical_morning_end}:00" - echo "" - echo "Nice try! But this is exactly the kind of late-night bargaining" - echo "that this protection is designed to prevent. 😉" - echo "" - echo "If you REALLY need to change the schedule, use the proper unlock:" - echo " sudo /usr/local/sbin/unlock-shutdown-schedule" - echo "" - echo "This requires waiting through a psychological delay to give you" - echo "time to reconsider whether you actually need more screen time." - echo "" - exit 1 - 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 + # 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 + 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 + 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 "" + 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 + 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) @@ -151,149 +133,142 @@ 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 "" + echo "Day-Specific Auto-Shutdown Status" + echo "=================================" + echo "Current Date: $(date)" + echo "User: $ACTUAL_USER" + echo "" - local timer_exists=false + 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 + # 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 "/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/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 + if [[ -f "/usr/local/bin/shutdown-timer-monitor.sh" ]]; then + echo "✓ Monitor script exists" + else + echo "✗ Monitor script missing" + fi - echo "" + 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 + # 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 "" + 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 + # 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 "" + echo "" - # Check config file protection status - echo "Config File Protection Status:" - local config_file="/etc/shutdown-schedule.conf" - local canonical_file="/usr/local/share/locked-shutdown-schedule.conf" + # Check config file protection status + echo "Config File Protection Status:" + local config_file="/etc/shutdown-schedule.conf" + local canonical_file="/usr/local/share/locked-shutdown-schedule.conf" - 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 [[ -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 [[ -f $canonical_file ]]; then - echo "✓ Canonical copy exists" - else - echo "✗ Canonical copy missing" - fi + if [[ -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 - echo "✓ Config path watcher is enabled" - if systemctl is-active shutdown-schedule-guard.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 + if systemctl is-enabled shutdown-schedule-guard.path &>/dev/null; then + echo "✓ Config path watcher is enabled" + if systemctl is-active shutdown-schedule-guard.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 - if [[ -f "/usr/local/sbin/unlock-shutdown-schedule" ]]; then - echo "✓ Unlock script exists" - else - echo "✗ Unlock script missing" - 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 " To modify: sudo /usr/local/sbin/unlock-shutdown-schedule" - echo "" + 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 "" } # Function to create shutdown schedule config file (shared with i3blocks countdown) # Also creates a canonical (protected) copy and sets immutable attribute create_shutdown_config() { - echo "" - echo "1. Creating Shutdown Schedule Config..." - echo "=======================================" + 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" + 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 + # 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 + cat >"$config_file" < "$enforce_script" << 'EOF' + # 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 @@ -382,11 +354,11 @@ chattr +i "$TARGET" || log "Failed to set immutable attribute" log "Enforcement complete" EOF - chmod +x "$enforce_script" - echo "✓ Created enforcement script: $enforce_script" + chmod +x "$enforce_script" + echo "✓ Created enforcement script: $enforce_script" - # Create unlock script with psychological delay - cat > "$unlock_script" << 'EOF' + # Create unlock script with psychological delay + cat >"$unlock_script" <<'EOF' #!/bin/bash # Unlock shutdown schedule config for editing with smart friction # This script: @@ -598,11 +570,11 @@ echo " Thursday-Sunday: ${THU_SUN_HOUR:-??}:00 - 0${MORNING_END_HOUR:-?}:00" echo "" EOF - chmod +x "$unlock_script" - echo "✓ Created unlock script: $unlock_script" + chmod +x "$unlock_script" + # Silently create unlock script - do not announce its existence - # Create path watcher unit - cat > "$guard_path" << 'EOF' + # Create path watcher unit + cat >"$guard_path" <<'EOF' [Unit] Description=Watch /etc/shutdown-schedule.conf and trigger enforcement @@ -614,10 +586,10 @@ Unit=shutdown-schedule-guard.service WantedBy=multi-user.target EOF - echo "✓ Created path watcher: $guard_path" + echo "✓ Created path watcher: $guard_path" - # Create enforcement service - cat > "$guard_service" << 'EOF' + # Create enforcement service + cat >"$guard_service" <<'EOF' [Unit] Description=Enforce canonical /etc/shutdown-schedule.conf contents After=local-fs.target @@ -632,27 +604,27 @@ IOSchedulingClass=idle WantedBy=multi-user.target EOF - echo "✓ Created guard service: $guard_service" + 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" + # 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" + # Run initial enforcement + "$enforce_script" || echo "⚠ Warning: Initial enforcement returned non-zero" + echo "✓ Ran initial enforcement" } # Function to create the shutdown service create_shutdown_service() { - echo "" - echo "3. Creating Systemd Shutdown Service..." - echo "======================================" + echo "" + echo "3. Creating Systemd Shutdown Service..." + echo "======================================" - local service_file="/etc/systemd/system/day-specific-shutdown.service" + local service_file="/etc/systemd/system/day-specific-shutdown.service" - cat > "$service_file" << 'EOF' + cat >"$service_file" <<'EOF' [Unit] Description=Automatic PC shutdown with day-specific time windows DefaultDependencies=false @@ -666,48 +638,48 @@ StandardOutput=journal StandardError=journal EOF - echo "✓ Created systemd service: $service_file" + echo "✓ Created systemd service: $service_file" } # Function to create the shutdown timer create_shutdown_timer() { - echo "" - echo "4. Creating Systemd Shutdown Timer..." - echo "===================================" + echo "" + echo "4. Creating Systemd Shutdown Timer..." + echo "===================================" - local timer_file="/etc/systemd/system/day-specific-shutdown.timer" + 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 + # 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 + # Generate timer entries dynamically from earliest_hour to MORNING_END_HOUR + # This ensures timer fires at all possible shutdown times + { + cat < "$timer_file" + } >"$timer_file" - echo "✓ Created systemd timer: $timer_file" - echo " Timer covers: ${earliest_hour}:00 to 0${SCHEDULE_MORNING_END_HOUR}:00" + 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 "==============================" + echo "" + echo "5. Creating Management Script..." + echo "==============================" - local script_file="/usr/local/bin/day-specific-shutdown-manager.sh" + local script_file="/usr/local/bin/day-specific-shutdown-manager.sh" - cat > "$script_file" << 'EOF' + cat >"$script_file" <<'EOF' #!/bin/bash # Day-Specific Auto-Shutdown Manager # Provides easy management of the day-specific shutdown feature @@ -810,19 +782,19 @@ case "$1" in esac EOF - chmod +x "$script_file" - echo "✓ Created management script: $script_file" + 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 "========================================" + echo "" + echo "6. Creating Smart Shutdown Check Script..." + echo "========================================" - local check_script="/usr/local/bin/day-specific-shutdown-check.sh" + local check_script="/usr/local/bin/day-specific-shutdown-check.sh" - cat > "$check_script" << 'EOF' + cat >"$check_script" <<'EOF' #!/bin/bash # Smart day-specific shutdown check script # Reads shutdown windows from /etc/shutdown-schedule.conf @@ -902,42 +874,42 @@ else fi EOF - chmod +x "$check_script" - echo "✓ Created smart shutdown check script: $check_script" + chmod +x "$check_script" + echo "✓ Created smart shutdown check script: $check_script" } # Function to enable the timer enable_timer() { - echo "" - echo "5. Enabling Shutdown Timer..." - echo "============================" + echo "" + echo "5. Enabling Shutdown Timer..." + echo "============================" - # Reload systemd daemon - systemctl daemon-reload - echo "✓ Reloaded systemd daemon" + # 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" + # 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" + # 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 "==============================================" + 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" + 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' + # 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 @@ -1015,11 +987,11 @@ while true; do done EOF - chmod +x "$monitor_script" - echo "✓ Created monitor script: $monitor_script" + chmod +x "$monitor_script" + echo "✓ Created monitor script: $monitor_script" - # Create the monitor service with RefuseManualStop to prevent manual stopping - cat > "$monitor_service" << 'EOF' + # 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 @@ -1048,10 +1020,10 @@ CPUQuota=10% WantedBy=multi-user.target EOF - echo "✓ Created monitor service: $monitor_service" + echo "✓ Created monitor service: $monitor_service" - # Create a watchdog timer that ensures the monitor stays running - cat > "$monitor_watchdog_service" << 'EOF' + # 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 @@ -1062,9 +1034,9 @@ ExecStart=/bin/bash -c 'systemctl is-active shutdown-timer-monitor.service || sy 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" + echo "✓ Created watchdog service: $monitor_watchdog_service" - cat > "$monitor_timer" << 'EOF' + cat >"$monitor_timer" <<'EOF' [Unit] Description=Watchdog Timer for Shutdown Timer Monitor After=multi-user.target @@ -1078,281 +1050,269 @@ Persistent=true WantedBy=timers.target EOF - echo "✓ Created watchdog timer: $monitor_timer" + 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" + # 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 "" + 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 + 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/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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 config_file="/etc/shutdown-schedule.conf" - local canonical_file="/usr/local/share/locked-shutdown-schedule.conf" + echo "" + echo "Config file protection status:" + local config_file="/etc/shutdown-schedule.conf" + local canonical_file="/usr/local/share/locked-shutdown-schedule.conf" - 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 [[ -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 [[ -f $canonical_file ]]; then - echo "✓ Canonical copy exists" - else - echo "✗ Canonical copy missing" - fi + if [[ -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 - echo "✓ Config guard path watcher is enabled" - else - echo "✗ Config guard path watcher is not enabled" - fi + if systemctl is-enabled shutdown-schedule-guard.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 - echo "✓ Config guard path watcher is active" - else - echo "✗ Config guard path watcher is not active" - fi + if systemctl is-active shutdown-schedule-guard.path &>/dev/null; then + echo "✓ Config guard path watcher is active" + else + echo "✗ Config guard path watcher is not active" + fi - if [[ -f "/usr/local/sbin/unlock-shutdown-schedule" ]]; then - echo "✓ Unlock script exists" - else - echo "✗ Unlock script missing" - fi - - echo "" - echo "Next scheduled checks:" - systemctl list-timers day-specific-shutdown.timer --no-pager 2> /dev/null | head -5 | grep day-specific-shutdown || echo "Timer information not available" + echo "" + echo "Next scheduled checks:" + systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | head -5 | grep day-specific-shutdown || echo "Timer information not available" } # 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" + # 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})" + 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 "To modify shutdown hours (with psychological friction):" - echo " sudo /usr/local/sbin/unlock-shutdown-schedule" - 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:" - echo " - Immutable attribute (chattr +i)" - echo " - Canonical copy at /usr/local/share/locked-shutdown-schedule.conf" - echo " - Path watcher that auto-restores if you modify the file" - 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 "" + 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 + 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 + 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" + 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 + # Check if trying to cheat by making schedule more lenient + check_schedule_protection - # Confirm setup - confirm_setup + # Confirm setup + confirm_setup - # Create config file (shared with i3blocks countdown script) - create_shutdown_config + # Create config file (shared with i3blocks countdown script) + create_shutdown_config - # Create config guard (path watcher, enforcement, unlock script) - create_config_guard + # 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 systemd files + create_shutdown_service + create_shutdown_timer + create_management_script + create_shutdown_check_script - # Enable and start timer - enable_timer + # Enable and start timer + enable_timer - # Install monitor service (protects timer from being disabled) - install_monitor_service + # Install monitor service (protects timer from being disabled) + install_monitor_service - # Test setup - test_setup + # Test setup + test_setup - # Show instructions - show_instructions + # Show instructions + show_instructions } # Parse command line arguments case "${1:-enable}" in - "enable") - check_sudo "$@" - enable_midnight_shutdown - ;; - "status") - check_sudo "$@" - show_current_status - ;; - "help" | "-h" | "--help") - show_usage - ;; - *) - echo "Error: Unknown command '$1'" - echo "" - show_usage - exit 1 - ;; +"enable") + check_sudo "$@" + enable_midnight_shutdown + ;; +"status") + check_sudo "$@" + show_current_status + ;; +"help" | "-h" | "--help") + show_usage + ;; +*) + echo "Error: Unknown command '$1'" + echo "" + show_usage + exit 1 + ;; esac diff --git a/scripts/fixes/fix_waifu2x.sh b/scripts/fixes/fix_waifu2x.sh new file mode 100755 index 0000000..2216384 --- /dev/null +++ b/scripts/fixes/fix_waifu2x.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Fix waifu2x-converter-cpp-cuda-git for CUDA 13+ +# CUDA 13 minimum supported arch is sm_75 (Turing) + +PKGBUILD="$HOME/.cache/yay/waifu2x-converter-cpp-cuda-git/PKGBUILD" + +if [[ ! -f "$PKGBUILD" ]]; then + echo "PKGBUILD not found. Run 'yay waifu2x-converter-cpp-cuda-git' first to download it." + exit 1 +fi + +# Add sed commands to prepare() function to replace sm_52/ptx52 with sm_75/ptx75 +if grep -q 's/sm_52/sm_75' "$PKGBUILD"; then + echo "PKGBUILD already patched." +else + sed -i '/^prepare() {$/a\ + # Fix for CUDA 13+ which requires sm_75+ (Turing)\ + sed -i "s/sm_52/sm_75/g" waifu2x-converter-cpp/CMakeLists.txt\ + sed -i "s/ptx52/ptx75/g" waifu2x-converter-cpp/CMakeLists.txt\ + sed -i "s/ptx52/ptx75/g" waifu2x-converter-cpp/src/modelHandler_CUDA.cpp' "$PKGBUILD" + echo "PKGBUILD patched. Now run 'yay waifu2x-converter-cpp-cuda-git' again." +fi diff --git a/test_results.log b/test_results.log new file mode 100644 index 0000000..708c7ff --- /dev/null +++ b/test_results.log @@ -0,0 +1,8 @@ +========================================== +Security Hardening Test Suite +========================================== + +Testing components in: /home/kuhy/linux-configuration + +--- HOSTS GUARD --- +✅ PASS: /etc/hosts is immutable diff --git a/tests/test_security_hardening.sh b/tests/test_security_hardening.sh new file mode 100755 index 0000000..eea2fd3 --- /dev/null +++ b/tests/test_security_hardening.sh @@ -0,0 +1,385 @@ +#!/bin/bash +# tests/test_security_hardening.sh +# Verify all security mechanisms are working +# +# Run with: bash tests/test_security_hardening.sh +# Some tests require root privileges + +set -uo pipefail +# Note: NOT using -e because we need to handle test failures gracefully + +PASS=0 +FAIL=0 +SKIP=0 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +test_result() { + local name="$1" + local result="$2" + local reason="${3:-}" + + case "$result" in + pass) + echo -e "${GREEN}✅ PASS${NC}: $name" + ((PASS++)) + ;; + fail) + echo -e "${RED}❌ FAIL${NC}: $name" + [[ -n "$reason" ]] && echo -e " ${RED}Reason: $reason${NC}" + ((FAIL++)) + ;; + skip) + echo -e "${YELLOW}⏭️ SKIP${NC}: $name" + [[ -n "$reason" ]] && echo -e " ${YELLOW}Reason: $reason${NC}" + ((SKIP++)) + ;; + esac +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" + +echo "==========================================" +echo "Security Hardening Test Suite" +echo "==========================================" +echo "" +echo "Testing components in: $REPO_DIR" +echo "" + +# ================================================================== +# HOSTS GUARD TESTS +# ================================================================== +echo "--- HOSTS GUARD ---" + +# Test 1: /etc/hosts is immutable +if [[ -f /etc/hosts ]]; then + if lsattr /etc/hosts 2>/dev/null | grep -q '^....i'; then + test_result "/etc/hosts is immutable" "pass" + else + test_result "/etc/hosts is immutable" "fail" "chattr +i not set" + fi +else + test_result "/etc/hosts is immutable" "skip" "File not found" +fi + +# Test 2: hosts-guard.path is active +if systemctl is-active --quiet hosts-guard.path 2>/dev/null; then + test_result "hosts-guard.path is active" "pass" +else + test_result "hosts-guard.path is active" "fail" "Service not running" +fi + +# Test 3: hosts-bind-mount.service is active +if systemctl is-active --quiet hosts-bind-mount.service 2>/dev/null; then + test_result "hosts-bind-mount.service is active" "pass" +else + test_result "hosts-bind-mount.service is active" "fail" "Service not running" +fi + +# Test 4: Canonical hosts copy exists +if [[ -f /usr/local/share/locked-hosts ]]; then + test_result "Canonical hosts copy exists" "pass" +else + test_result "Canonical hosts copy exists" "fail" "Not found at /usr/local/share/locked-hosts" +fi + +# Test 5: nsswitch-guard.path is active (NEW) +if systemctl is-active --quiet nsswitch-guard.path 2>/dev/null; then + test_result "nsswitch-guard.path is active" "pass" +else + test_result "nsswitch-guard.path is active" "fail" "Service not running" +fi + +# Test 6: /etc/nsswitch.conf is immutable (NEW) +if [[ -f /etc/nsswitch.conf ]]; then + if lsattr /etc/nsswitch.conf 2>/dev/null | grep -q '^....i'; then + test_result "/etc/nsswitch.conf is immutable" "pass" + else + test_result "/etc/nsswitch.conf is immutable" "fail" "chattr +i not set" + fi +else + test_result "/etc/nsswitch.conf is immutable" "skip" "File not found" +fi + +# Test 7: nsswitch.conf has correct hosts line +if [[ -f /etc/nsswitch.conf ]]; then + hosts_line=$(grep "^hosts:" /etc/nsswitch.conf 2>/dev/null || true) + if echo "$hosts_line" | grep -q 'files.*dns\|files.*myhostname'; then + test_result "nsswitch.conf has 'files' before 'dns'" "pass" + elif [[ -z "$hosts_line" ]]; then + test_result "nsswitch.conf has 'files' before 'dns'" "fail" "No hosts: line found" + else + test_result "nsswitch.conf has 'files' before 'dns'" "fail" "hosts line: $hosts_line" + fi +else + test_result "nsswitch.conf has 'files' before 'dns'" "skip" "File not found" +fi + +echo "" + +# ================================================================== +# SHUTDOWN SCHEDULE TESTS +# ================================================================== +echo "--- SHUTDOWN SCHEDULE ---" + +# Test 8: shutdown-schedule.conf is immutable +if [[ -f /etc/shutdown-schedule.conf ]]; then + if lsattr /etc/shutdown-schedule.conf 2>/dev/null | grep -q '^....i'; then + test_result "/etc/shutdown-schedule.conf is immutable" "pass" + else + test_result "/etc/shutdown-schedule.conf is immutable" "fail" "chattr +i not set" + fi +else + test_result "/etc/shutdown-schedule.conf is immutable" "skip" "Not installed" +fi + +# Test 9: shutdown timer is active +if systemctl is-active --quiet day-specific-shutdown.timer 2>/dev/null; then + test_result "day-specific-shutdown.timer is active" "pass" +else + test_result "day-specific-shutdown.timer is active" "fail" "Timer not running" +fi + +# Test 10: shutdown schedule guard is active +if systemctl is-active --quiet shutdown-schedule-guard.path 2>/dev/null; then + test_result "shutdown-schedule-guard.path is active" "pass" +else + test_result "shutdown-schedule-guard.path is active" "fail" "Guard not running" +fi + +# Test 11: Unlock script has obscure name (no helpful path) +if [[ -f /usr/local/sbin/.sd-sched-mgmt ]]; then + test_result "Unlock script uses obscure name" "pass" +elif [[ -f /usr/local/sbin/unlock-shutdown-schedule ]]; then + test_result "Unlock script uses obscure name" "fail" "Still using obvious name" +else + test_result "Unlock script uses obscure name" "skip" "Not installed" +fi + +echo "" + +# ================================================================== +# PACMAN WRAPPER TESTS +# ================================================================== +echo "--- PACMAN WRAPPER ---" + +# Test 12: pacman wrapper is installed +if [[ -L /usr/bin/pacman ]] && [[ -f /usr/bin/pacman.orig ]]; then + test_result "pacman wrapper installed" "pass" +else + if [[ ! -L /usr/bin/pacman ]]; then + test_result "pacman wrapper installed" "fail" "/usr/bin/pacman is not a symlink" + else + test_result "pacman wrapper installed" "fail" "/usr/bin/pacman.orig not found" + fi +fi + +# Test 13: google-chrome is blocked +blocked_file="$REPO_DIR/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt" +if [[ -f "$blocked_file" ]]; then + if grep -qi "google-chrome" "$blocked_file"; then + test_result "google-chrome in blocked list" "pass" + else + test_result "google-chrome in blocked list" "fail" "Not found in $blocked_file" + fi +else + test_result "google-chrome in blocked list" "skip" "Blocked keywords file not found" +fi + +# Test 14: chromium is blocked +if [[ -f "$blocked_file" ]]; then + if grep -qi "^chromium$" "$blocked_file"; then + test_result "chromium in blocked list" "pass" + else + test_result "chromium in blocked list" "fail" "Not found in $blocked_file" + fi +else + test_result "chromium in blocked list" "skip" "Blocked keywords file not found" +fi + +# Test 15: Policy integrity file exists +if [[ -f /var/lib/pacman-wrapper/policy.sha256 ]]; then + test_result "Pacman policy integrity file exists" "pass" +else + test_result "Pacman policy integrity file exists" "fail" "Not found" +fi + +# Test 16: LeechBlock auto-install function exists in wrapper +wrapper_file="$REPO_DIR/scripts/digital_wellbeing/pacman/pacman_wrapper.sh" +if [[ -f "$wrapper_file" ]]; then + if grep -q "auto_install_leechblock" "$wrapper_file"; then + test_result "LeechBlock auto-install function exists" "pass" + else + test_result "LeechBlock auto-install function exists" "fail" "Function not found" + fi +else + test_result "LeechBlock auto-install function exists" "skip" "Wrapper file not found" +fi + +echo "" + +# ================================================================== +# COMPULSIVE BLOCK TESTS +# ================================================================== +echo "--- COMPULSIVE OPENING BLOCK ---" + +compulsive_file="$REPO_DIR/scripts/digital_wellbeing/block_compulsive_opening.sh" + +# Test 17: Auto-close timer configuration exists +if [[ -f "$compulsive_file" ]]; then + if grep -q "AUTO_CLOSE_TIMEOUT_MINUTES" "$compulsive_file"; then + test_result "Auto-close timer configuration exists" "pass" + else + test_result "Auto-close timer configuration exists" "fail" "Variable not found" + fi +else + test_result "Auto-close timer configuration exists" "skip" "Script not found" +fi + +# Test 18: launch_with_timer function exists +if [[ -f "$compulsive_file" ]]; then + if grep -q "launch_with_timer" "$compulsive_file"; then + test_result "launch_with_timer function exists" "pass" + else + test_result "launch_with_timer function exists" "fail" "Function not found" + fi +else + test_result "launch_with_timer function exists" "skip" "Script not found" +fi + +# Test 19: Compulsive block wrappers installed +wrappers_ok=true +for app in beeper signal-desktop discord; do + if [[ -f "/usr/bin/$app" ]]; then + if grep -q "block-compulsive-opening" "/usr/bin/$app" 2>/dev/null; then + : # OK + else + wrappers_ok=false + fi + fi +done +if [[ "$wrappers_ok" == true ]]; then + test_result "Compulsive block wrappers installed" "pass" +else + test_result "Compulsive block wrappers installed" "fail" "Some wrappers missing or incorrect" +fi + +echo "" + +# ================================================================== +# FOCUS MODE DAEMON TESTS +# ================================================================== +echo "--- FOCUS MODE DAEMON ---" + +focus_daemon="$REPO_DIR/scripts/digital_wellbeing/focus_mode_daemon.py" +focus_installer="$REPO_DIR/scripts/digital_wellbeing/install_focus_mode_daemon.sh" + +# Test 20: Focus mode daemon script exists +if [[ -f "$focus_daemon" ]]; then + test_result "Focus mode daemon script exists" "pass" +else + test_result "Focus mode daemon script exists" "fail" "Not found at $focus_daemon" +fi + +# Test 21: Focus mode installer exists +if [[ -f "$focus_installer" ]]; then + test_result "Focus mode installer exists" "pass" +else + test_result "Focus mode installer exists" "fail" "Not found at $focus_installer" +fi + +# Test 22: Focus mode daemon is running (user service) +if systemctl --user is-active --quiet focus-mode.service 2>/dev/null; then + test_result "Focus mode daemon is running" "pass" +else + test_result "Focus mode daemon is running" "fail" "User service not running" +fi + +echo "" + +# ================================================================== +# SCREEN LOCKER TESTS +# ================================================================== +echo "--- SCREEN LOCKER ---" + +screen_locker="$HOME/testsAndMisc/python_pkg/screen_locker/screen_lock.py" + +# Test 23: Screen locker exists +if [[ -f "$screen_locker" ]]; then + test_result "Screen locker script exists" "pass" +else + test_result "Screen locker script exists" "skip" "Not found at expected location" +fi + +# Test 24: Running option removed +if [[ -f "$screen_locker" ]]; then + # Check that there's no "Running" button in ask_workout_type + if grep -A 20 "def ask_workout_type" "$screen_locker" | grep -q '"Running"'; then + test_result "Running workout option removed" "fail" "Still present in ask_workout_type" + else + test_result "Running workout option removed" "pass" + fi +else + test_result "Running workout option removed" "skip" "Script not found" +fi + +# Test 25: Table tennis minimum sets validation +if [[ -f "$screen_locker" ]]; then + if grep -q "MIN_TABLE_TENNIS_SETS" "$screen_locker"; then + test_result "Table tennis minimum sets validation exists" "pass" + else + test_result "Table tennis minimum sets validation exists" "fail" "Constant not found" + fi +else + test_result "Table tennis minimum sets validation exists" "skip" "Script not found" +fi + +# Test 26: Table tennis verification question +if [[ -f "$screen_locker" ]]; then + if grep -q "ask_table_tennis_verification" "$screen_locker"; then + test_result "Table tennis verification question exists" "pass" + else + test_result "Table tennis verification question exists" "fail" "Function not found" + fi +else + test_result "Table tennis verification question exists" "skip" "Script not found" +fi + +# Test 27: 60 second submit delay for table tennis +if [[ -f "$screen_locker" ]]; then + if grep -q "TABLE_TENNIS_SUBMIT_DELAY = 60" "$screen_locker"; then + test_result "Table tennis 60-second submit delay" "pass" + else + test_result "Table tennis 60-second submit delay" "fail" "Constant not set to 60" + fi +else + test_result "Table tennis 60-second submit delay" "skip" "Script not found" +fi + +echo "" + +# ================================================================== +# SUMMARY +# ================================================================== +echo "==========================================" +echo "RESULTS SUMMARY" +echo "==========================================" +echo "" +echo -e "${GREEN}Passed: $PASS${NC}" +echo -e "${RED}Failed: $FAIL${NC}" +echo -e "${YELLOW}Skipped: $SKIP${NC}" +echo "" +echo "==========================================" + +if [[ $FAIL -gt 0 ]]; then + echo -e "${RED}Some tests failed! Review the output above.${NC}" + exit 1 +else + echo -e "${GREEN}All active tests passed!${NC}" + exit 0 +fi