feat: more etc hosts friction

This commit is contained in:
Krzysztof kuhy Rudnicki 2025-10-01 20:50:56 +02:00
parent 7ae41b5490
commit a46dc183ef
7 changed files with 368 additions and 0 deletions

25
hosts/guard/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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