scripts/hosts/guard/setup_hosts_guard.sh

453 lines
14 KiB
Bash
Raw Permalink Normal View History

2025-10-01 20:50:56 +02:00
#!/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
2026-02-02 21:36:27 +01:00
ENABLE_NSSWITCH=1
2025-10-01 20:50:56 +02:00
UNINSTALL=0
DELAY=45
DRY_RUN=0
2025-10-01 20:56:04 +02:00
INSTALL_SHELL_HOOKS=1
INSTALL_AUDIT_RULE=1
ADD_ALIAS_STUB=1
2025-10-01 20:50:56 +02:00
######################################################################
# 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' "$*"; }
2025-11-01 15:36:22 +01:00
err() { printf '\e[1;31m[x]\e[0m %s\n' "$*" >&2; }
run() {
2026-02-02 21:36:27 +01:00
if [[ $DRY_RUN -eq 1 ]]; then
printf 'DRY-RUN:'
if [ "$#" -gt 0 ]; then
printf ' %q' "$@"
fi
printf '\n'
else
"$@"
fi
2025-11-01 15:36:22 +01:00
}
2025-10-01 20:50:56 +02:00
2025-11-01 15:36:22 +01:00
require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi; }
2025-10-01 20:50:56 +02:00
usage() { sed -n '1,/^set -euo pipefail/p' "$0" | sed 's/^# \{0,1\}//'; }
######################################################################
# Parse args
######################################################################
while [[ $# -gt 0 ]]; do
2026-02-02 21:36:27 +01:00
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
2025-10-01 20:50:56 +02:00
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"
2026-02-02 21:36:27 +01:00
TEMPLATE_ENFORCE_NSSWITCH="$SCRIPT_DIR/enforce-nsswitch.sh"
UNIT_NSSWITCH_SERVICE="$SCRIPT_DIR/nsswitch-guard.service"
UNIT_NSSWITCH_PATH="$SCRIPT_DIR/nsswitch-guard.path"
2025-10-01 20:50:56 +02:00
INSTALL_ENFORCE="/usr/local/sbin/enforce-hosts.sh"
INSTALL_UNLOCK="/usr/local/sbin/unlock-hosts"
2026-02-02 21:36:27 +01:00
INSTALL_ENFORCE_NSSWITCH="/usr/local/sbin/enforce-nsswitch.sh"
2025-10-01 20:50:56 +02:00
CANON="/usr/local/share/locked-hosts"
2026-02-02 21:36:27 +01:00
CANON_NSSWITCH="/usr/local/share/locked-nsswitch.conf"
2025-10-01 20:50:56 +02:00
HOSTS="/etc/hosts"
2026-02-02 21:36:27 +01:00
NSSWITCH="/etc/nsswitch.conf"
2025-10-01 20:50:56 +02:00
2025-10-01 20:56:04 +02:00
# Shell hook destinations (user agnostic system-wide skeleton + etc profile.d)
ZSH_FILTER_SNIPPET="/etc/zsh/hosts_guard_history_filter.zsh"
BASH_FILTER_SNIPPET="/etc/profile.d/hosts_guard_history_filter.sh"
2025-10-01 20:50:56 +02:00
SYSTEMD_DIR="/etc/systemd/system"
######################################################################
# Uninstall flow
######################################################################
if [[ $UNINSTALL -eq 1 ]]; then
2026-02-02 21:36:27 +01:00
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
2025-10-01 20:50:56 +02:00
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
2026-02-02 21:36:27 +01:00
[[ -f $req ]] || {
err "Missing template: $req"
exit 1
}
2025-10-01 20:50:56 +02:00
done
2025-11-01 15:36:22 +01:00
if [[ ! -f $HOSTS ]]; then
2026-02-02 21:36:27 +01:00
err "$HOSTS does not exist. Run your hosts/install.sh first."
exit 1
2025-10-01 20:50:56 +02:00
fi
######################################################################
# Snapshot
######################################################################
if [[ $DO_SNAPSHOT -eq 1 ]]; then
2026-02-02 21:36:27 +01:00
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
2025-10-01 20:50:56 +02:00
else
2026-02-02 21:36:27 +01:00
note "Skipping snapshot creation (--no-snapshot)"
2025-10-01 20:50:56 +02:00
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
2026-02-02 21:36:27 +01:00
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
2025-10-01 20:50:56 +02:00
fi
2025-10-01 20:56:04 +02:00
######################################################################
# Install shell history filters (optional)
######################################################################
if [[ $INSTALL_SHELL_HOOKS -eq 1 ]]; then
2026-02-02 21:36:27 +01:00
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'
2025-10-01 20:56:04 +02:00
# 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() {
emulate -L zsh
setopt extendedglob
local line="$1"
local _pattern='(^|;|&&|\|\|)\s*(sudo\s+)?(/usr/local/sbin/)?unlock-hosts(\s|;|$)'
if [[ $line =~ ${_pattern} ]]; then
return 1
fi
return 0
}
if typeset -f add-zsh-hook >/dev/null 2>&1; then
add-zsh-hook zshaddhistory _hosts_guard_history_filter 2>/dev/null || true
else
zshaddhistory() { _hosts_guard_history_filter "$1"; }
fi
ZEOF
2026-02-02 21:36:27 +01:00
chmod 644 "$ZSH_FILTER_SNIPPET"
fi
fi
2025-10-01 20:56:04 +02:00
2026-02-02 21:36:27 +01:00
# 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'
2025-10-01 20:56:04 +02:00
# Added by hosts guard setup suppress unlock-hosts commands from Bash history
export HISTCONTROL=ignoredups:erasedups
_hosts_guard_hist_filter() {
local last_cmd
local _pattern='(^|;|&&|\|\|)\s*(sudo\s+)?(/usr/local/sbin/)?unlock-hosts(\s|;|$)'
last_cmd=$(history 1 2>/dev/null | sed -E 's/^ *[0-9]+ +//')
if [[ -n $last_cmd && $last_cmd =~ ${_pattern} ]]; then
local id
id=$(history 1 2>/dev/null | awk '{print $1}')
if [[ -n $id ]]; then history -d $id 2>/dev/null || true; fi
history -w 2>/dev/null || true
history -c || true
history -r 2>/dev/null || true
fi
}
case :${PROMPT_COMMAND-}: in
*:_hosts_guard_hist_filter:* ) ;;
* ) PROMPT_COMMAND="_hosts_guard_hist_filter${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;;
esac
BEOF
2026-02-02 21:36:27 +01:00
chmod 644 "$BASH_FILTER_SNIPPET"
fi
fi
2025-10-01 20:56:04 +02:00
else
2026-02-02 21:36:27 +01:00
note "Skipping shell history hooks (--no-shell-hooks)"
2025-10-01 20:56:04 +02:00
fi
######################################################################
# Add alias stub to discourage raw invocation (shell-level friction)
######################################################################
if [[ $ADD_ALIAS_STUB -eq 1 ]]; then
2026-02-02 21:36:27 +01:00
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'
2025-10-01 20:56:04 +02:00
# 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
2026-02-02 21:36:27 +01:00
chmod 644 "$PROFILE_STUB"
fi
2025-10-01 20:56:04 +02:00
fi
######################################################################
# Audit rule to record executions (requires auditd)
######################################################################
if [[ $INSTALL_AUDIT_RULE -eq 1 ]]; then
2026-02-02 21:36:27 +01:00
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
2025-10-01 20:56:04 +02:00
fi
2025-10-01 20:50:56 +02:00
######################################################################
# 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"
2026-02-02 21:36:27 +01:00
run install -m 644 "$UNIT_NSSWITCH_SERVICE" "$SYSTEMD_DIR/nsswitch-guard.service"
run install -m 644 "$UNIT_NSSWITCH_PATH" "$SYSTEMD_DIR/nsswitch-guard.path"
2025-10-01 20:50:56 +02:00
if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi
######################################################################
# Enable / Start
######################################################################
if [[ $ENABLE_PATH -eq 1 ]]; then
2026-02-02 21:36:27 +01:00
msg "Enabling path watch (auto-revert)"
run systemctl enable --now hosts-guard.path
2025-10-01 20:50:56 +02:00
else
2026-02-02 21:36:27 +01:00
note "Skipping path watch (--skip-path-watch)"
2025-10-01 20:50:56 +02:00
fi
if [[ $ENABLE_BIND -eq 1 ]]; then
2026-02-02 21:36:27 +01:00
msg "Enabling read-only bind mount"
run systemctl enable --now hosts-bind-mount.service
else
note "Skipping bind mount (--skip-bind)"
fi
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
2025-10-01 20:50:56 +02:00
else
2026-02-02 21:36:27 +01:00
note "Skipping nsswitch protection (--skip-nsswitch)"
2025-10-01 20:50:56 +02:00
fi
2026-02-02 21:36:27 +01:00
msg "Performing initial hosts enforcement"
2025-10-01 20:50:56 +02:00
if [[ $DRY_RUN -eq 1 ]]; then
2026-02-02 21:36:27 +01:00
echo "DRY-RUN: would run $INSTALL_ENFORCE"
2025-10-01 20:50:56 +02:00
else
2026-02-02 21:36:27 +01:00
"$INSTALL_ENFORCE" || warn "Enforcement returned non-zero"
2025-10-01 20:50:56 +02:00
fi
######################################################################
# Summary
######################################################################
echo
msg "Hosts guard setup complete"
2026-02-02 21:36:27 +01:00
echo "Canonical hosts copy: $CANON"
echo "Canonical nsswitch copy: $CANON_NSSWITCH"
2025-11-01 15:36:22 +01:00
echo "Enforce script: $INSTALL_ENFORCE"
2026-02-02 21:36:27 +01:00
echo "nsswitch enforce: $INSTALL_ENFORCE_NSSWITCH"
2025-11-01 15:36:22 +01:00
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)"
2026-02-02 21:36:27 +01:00
echo "nsswitch protection: $([[ $ENABLE_NSSWITCH -eq 1 ]] && echo enabled || echo disabled)"
2025-10-01 20:56:04 +02:00
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)"
2025-10-01 20:50:56 +02:00
echo
2025-11-01 15:36:22 +01:00
echo "Test flow:"
echo " sudo sed -i '1s/.*/# tamper test/' /etc/hosts # Should revert automatically"
echo " sudo $INSTALL_UNLOCK # Intentional edit workflow"
2025-10-01 20:50:56 +02:00
echo
2025-11-01 15:36:22 +01:00
echo "Uninstall:"
echo " sudo $0 --uninstall"
echo "(Optional) Skip shell history hooks: --no-shell-hooks"
2025-10-01 20:50:56 +02:00
echo
exit 0