mirror of
https://github.com/kuhyx/scripts.git
synced 2026-07-04 17:03:13 +02:00
407 lines
13 KiB
Bash
Executable File
407 lines
13 KiB
Bash
Executable File
#!/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
|
||
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
|
||
;;
|
||
--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"
|
||
|
||
INSTALL_ENFORCE="/usr/local/sbin/enforce-hosts.sh"
|
||
INSTALL_UNLOCK="/usr/local/sbin/unlock-hosts"
|
||
CANON="/usr/local/share/locked-hosts"
|
||
HOSTS="/etc/hosts"
|
||
|
||
# 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; 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
|
||
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"
|
||
|
||
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 "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
|