From a69dfff12516aca3baf7474242b4335c69c4d06d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 1 Oct 2025 20:50:56 +0200 Subject: [PATCH] feat: more etc hosts friction --- hosts/guard/README.md | 25 +++ hosts/guard/enforce-hosts.sh | 32 ++++ hosts/guard/hosts-bind-mount.service | 14 ++ hosts/guard/hosts-guard.path | 9 + hosts/guard/hosts-guard.service | 12 ++ hosts/guard/psychological/unlock-hosts.sh | 59 ++++++ hosts/guard/setup_hosts_guard.sh | 217 ++++++++++++++++++++++ 7 files changed, 368 insertions(+) create mode 100644 hosts/guard/README.md create mode 100644 hosts/guard/enforce-hosts.sh create mode 100644 hosts/guard/hosts-bind-mount.service create mode 100644 hosts/guard/hosts-guard.path create mode 100644 hosts/guard/hosts-guard.service create mode 100644 hosts/guard/psychological/unlock-hosts.sh create mode 100644 hosts/guard/setup_hosts_guard.sh diff --git a/hosts/guard/README.md b/hosts/guard/README.md new file mode 100644 index 0000000..357dce2 --- /dev/null +++ b/hosts/guard/README.md @@ -0,0 +1,25 @@ +Hosts Guard Components +====================== + +This directory contains templates for hardening /etc/hosts against impulsive tampering by adding friction, NOT providing absolute security against a determined root user. + +Components: +1. enforce-hosts.sh – Idempotent script that: compares /etc/hosts with canonical copy at /usr/local/share/locked-hosts and restores if different; reapplies immutable attribute. +2. systemd units (to be installed under /etc/systemd/system): + - hosts-guard.service (oneshot enforcement) + - hosts-guard.path (triggers on PathChanged=/etc/hosts) + - hosts-bind-mount.service (bind mounts /etc/hosts read-only after boot) +3. psychological/ directory – scripts that add delay + journaling before allowing a maintenance/unlock operation. + +Install Flow (suggested): +1. After generating /etc/hosts via your existing hosts/install.sh, copy it to /usr/local/share/locked-hosts. +2. Install enforce-hosts.sh to /usr/local/sbin/ (chmod 755). +3. Place units and enable: + systemctl daemon-reload + systemctl enable --now hosts-guard.path + systemctl enable --now hosts-bind-mount.service +4. (Optional) Use psychological/unlock-hosts.sh as the ONLY sanctioned way to modify hosts (it removes protections temporarily, launches an editor after a delay, and re-enforces on close). + +Limitations: +- A root user can still disable units, remount, remove attributes. +- Purpose is to interrupt habit loops and create intentional friction. diff --git a/hosts/guard/enforce-hosts.sh b/hosts/guard/enforce-hosts.sh new file mode 100644 index 0000000..ba4e4a0 --- /dev/null +++ b/hosts/guard/enforce-hosts.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Template guard script to enforce canonical /etc/hosts +# This will be installed into /usr/local/sbin/enforce-hosts.sh by a setup script. + +set -euo pipefail + +CANONICAL_SOURCE="/usr/local/share/locked-hosts" +TARGET="/etc/hosts" +LOG_FILE="/var/log/hosts-guard.log" + +log() { + printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2 +} + +if [[ ! -f "$CANONICAL_SOURCE" ]]; then + log "Canonical hosts not found at $CANONICAL_SOURCE; aborting enforcement" + exit 0 +fi + +if ! cmp -s "$CANONICAL_SOURCE" "$TARGET"; then + log "Difference detected – restoring $TARGET from canonical copy" + cp "$CANONICAL_SOURCE" "$TARGET" + chmod 644 "$TARGET" +else + log "No drift detected (contents identical)" +fi + +# Re-apply protective attributes: immutable first, then read-only bind mount handled by separate unit +chattr -i -a "$TARGET" 2>/dev/null || true +chattr +i "$TARGET" || log "Failed to set immutable attribute" + +log "Enforcement complete" \ No newline at end of file diff --git a/hosts/guard/hosts-bind-mount.service b/hosts/guard/hosts-bind-mount.service new file mode 100644 index 0000000..998c2f4 --- /dev/null +++ b/hosts/guard/hosts-bind-mount.service @@ -0,0 +1,14 @@ +[Unit] +Description=Bind mount /etc/hosts over itself as read-only (friction layer) +After=local-fs.target +Before=network.target + +[Service] +Type=oneshot +ExecStart=/bin/mount --bind /etc/hosts /etc/hosts +ExecStart=/bin/mount -o remount,ro,bind /etc/hosts +ExecStartPost=/usr/bin/logger -t hosts-bind-mount "Hosts file bind-mounted read-only" +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/hosts/guard/hosts-guard.path b/hosts/guard/hosts-guard.path new file mode 100644 index 0000000..0e3371c --- /dev/null +++ b/hosts/guard/hosts-guard.path @@ -0,0 +1,9 @@ +[Unit] +Description=Watch /etc/hosts and trigger enforcement + +[Path] +PathChanged=/etc/hosts +Unit=hosts-guard.service + +[Install] +WantedBy=multi-user.target diff --git a/hosts/guard/hosts-guard.service b/hosts/guard/hosts-guard.service new file mode 100644 index 0000000..0cf52b9 --- /dev/null +++ b/hosts/guard/hosts-guard.service @@ -0,0 +1,12 @@ +[Unit] +Description=Enforce canonical /etc/hosts contents +After=local-fs.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/enforce-hosts.sh +Nice=10 +IOSchedulingClass=idle + +[Install] +WantedBy=multi-user.target diff --git a/hosts/guard/psychological/unlock-hosts.sh b/hosts/guard/psychological/unlock-hosts.sh new file mode 100644 index 0000000..9e8d431 --- /dev/null +++ b/hosts/guard/psychological/unlock-hosts.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Guided, delayed unlock procedure to intentionally slow down impulsive edits. +set -euo pipefail + +TARGET=/etc/hosts +CANON=/usr/local/share/locked-hosts +LOG=/var/log/hosts-guard.log +EDITOR_CMD=${EDITOR:-nano} +DELAY_SECONDS=45 + +log() { printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG" >&2; } + +require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi } +require_root "$@" + +log "Requested intentional /etc/hosts modification session." +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 +done + +# Remove attributes to allow edit +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 +done +echo >&2 + +# Launch editor +sha_before=$(sha256sum "$TARGET" | awk '{print $1}') +"$EDITOR_CMD" "$TARGET" +sha_after=$(sha256sum "$TARGET" | awk '{print $1}') + +if [[ "$sha_before" == "$sha_after" ]]; then + log "No changes made to $TARGET." +else + log "Changes detected. Updating canonical copy and re-enforcing." + cp "$TARGET" "$CANON" +fi + +# Re-run enforcement +/usr/local/sbin/enforce-hosts.sh || log "Enforcement script returned non-zero" + +# Restart watchers and bind mount +systemctl start hosts-guard.path || true +systemctl start hosts-bind-mount.service || true + +log "Unlock session complete." +echo "Done." >&2 diff --git a/hosts/guard/setup_hosts_guard.sh b/hosts/guard/setup_hosts_guard.sh new file mode 100644 index 0000000..c73bbfa --- /dev/null +++ b/hosts/guard/setup_hosts_guard.sh @@ -0,0 +1,217 @@ +#!/bin/bash +# One-shot installer for hosts guard + psychological friction + read-only bind mount +# Layers implemented: +# - Canonical snapshot of /etc/hosts at /usr/local/share/locked-hosts +# - Enforcement script (/usr/local/sbin/enforce-hosts.sh) +# - Systemd path-based auto-revert (hosts-guard.path + hosts-guard.service) +# - Read-only bind mount (hosts-bind-mount.service) +# - Delayed edit workflow (/usr/local/sbin/unlock-hosts) +# +# This script is idempotent. Re-running updates installed artifacts safely. +# +# Usage: +# sudo ./setup_hosts_guard.sh [options] +# Options: +# --force-snapshot Overwrite canonical snapshot even if it exists +# --no-snapshot Skip creating canonical snapshot (assume already present) +# --skip-bind Do not enable read-only bind mount service +# --skip-path-watch Do not enable path watch auto-revert +# --delay N Set unlock delay seconds (default 45) +# --dry-run Show actions without performing changes +# --uninstall Remove installed units/scripts (does NOT restore original hosts) +# -h|--help Show help +# +# Exit codes: +# 0 success, 1 generic failure, 2 argument error + +set -euo pipefail + +###################################################################### +# Defaults / Config +###################################################################### +FORCE_SNAPSHOT=0 +DO_SNAPSHOT=1 +ENABLE_BIND=1 +ENABLE_PATH=1 +UNINSTALL=0 +DELAY=45 +DRY_RUN=0 + +###################################################################### +# Helpers +###################################################################### +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; } +run() { if [[ $DRY_RUN -eq 1 ]]; then printf 'DRY-RUN: %s\n' "$*"; else eval "$@"; fi } + +require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi } + +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 ;; + --uninstall) UNINSTALL=1 ; shift ;; + -h|--help) usage; exit 0 ;; + *) err "Unknown argument: $1"; usage; exit 2 ;; + esac +done + +require_root "$@" + +###################################################################### +# Paths +###################################################################### +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +TEMPLATE_ENFORCE="$SCRIPT_DIR/enforce-hosts.sh" +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" + +INSTALL_ENFORCE="/usr/local/sbin/enforce-hosts.sh" +INSTALL_UNLOCK="/usr/local/sbin/unlock-hosts" +CANON="/usr/local/share/locked-hosts" +HOSTS="/etc/hosts" + +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"; 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 +fi + +###################################################################### +# Pre-flight checks +###################################################################### +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; } +done + +if [[ ! -f "$HOSTS" ]]; then + 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 +else + note "Skipping snapshot creation (--no-snapshot)" +fi + +###################################################################### +# Install scripts +###################################################################### +msg "Installing enforcement script -> $INSTALL_ENFORCE" +run install -m 755 "$TEMPLATE_ENFORCE" "$INSTALL_ENFORCE" + +msg "Installing unlock script -> $INSTALL_UNLOCK" +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 +fi + +###################################################################### +# Install systemd units +###################################################################### +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" + +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 +else + 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 +else + note "Skipping bind mount (--skip-bind)" +fi + +msg "Performing initial enforcement" +if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would run $INSTALL_ENFORCE" +else + "$INSTALL_ENFORCE" || warn "Enforcement returned non-zero" +fi + +###################################################################### +# Summary +###################################################################### +echo +msg "Hosts guard setup complete" +echo "Canonical copy: $CANON" +echo "Enforce script: $INSTALL_ENFORCE" +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 +echo "Test flow:" +echo " sudo sed -i '1s/.*/# tamper test/' /etc/hosts # Should revert automatically" +echo " sudo $INSTALL_UNLOCK # Intentional edit workflow" +echo +echo "Uninstall:" +echo " sudo $0 --uninstall" +echo +exit 0