scripts/hosts/guard/setup_hosts_guard.sh

453 lines
14 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
ENABLE_NSSWITCH=1
UNINSTALL=0
DELAY=45
DRY_RUN=0
INSTALL_SHELL_HOOKS=1
INSTALL_AUDIT_RULE=1
ADD_ALIAS_STUB=1
######################################################################
# 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:'
if [ "$#" -gt 0 ]; then
printf ' %q' "$@"
fi
printf '\n'
else
"$@"
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
;;
--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 "$@"
######################################################################
# 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"
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"
BASH_FILTER_SNIPPET="/etc/profile.d/hosts_guard_history_filter.sh"
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 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
######################################################################
# 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 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'
# 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
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'
# 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
chmod 644 "$BASH_FILTER_SNIPPET"
fi
fi
else
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'
# 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
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
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"
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
######################################################################
# 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
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
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
######################################################################
# Summary
######################################################################
echo
msg "Hosts guard setup complete"
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)"
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 "(Optional) Skip shell history hooks: --no-shell-hooks"
echo
exit 0