#!/usr/bin/env bash # Rollback state capture, rollback itself, and status reporting for the # hosts-guard migration. # # Sourced by the entry script alongside hosts_guard_migrate.sh. # ---------------------------------------------------------------------------- # Status # ---------------------------------------------------------------------------- show_status() { printf "\n%s\n" "=== guard-lib instances ===" local name for name in "${INSTANCES[@]}"; do if instance_registered "$name"; then msg "$name registered" "$GUARDCTL" file-guard status "$name" 2>&1 | sed 's/^/ /' else warn "$name NOT registered" fi done printf "\n%s\n" "=== legacy pacman hooks ===" local hook for hook in "${LEGACY_HOOKS[@]}"; do if [[ -f "$HOOKS_DIR/$hook" ]]; then warn "$hook still present" else msg "$hook retired" fi done printf "\n%s\n" "=== legacy systemd units ===" local unit state for unit in "${LEGACY_UNITS[@]}"; do # `is-enabled` PRINTS the state but EXITS 1 for anything not enabled, so # `|| echo absent` would append a second line to a perfectly good answer. state="$(systemctl is-enabled "$unit" 2>/dev/null)" || true [[ -n $state ]] || state="absent" if [[ $state == "enabled" ]]; then warn "$unit is $state" else msg "$unit is $state" fi done } # ---------------------------------------------------------------------------- # Rollback state # ---------------------------------------------------------------------------- save_rollback_state() { run mkdir -p "$STATE_DIR/hooks" local hook for hook in "${LEGACY_HOOKS[@]}"; do if [[ -f "$HOOKS_DIR/$hook" && ! -f "$STATE_DIR/hooks/$hook" ]]; then run cp -a "$HOOKS_DIR/$hook" "$STATE_DIR/hooks/$hook" note "backed up $hook" fi done # Record enablement so rollback restores the exact prior state rather than # guessing (resolved-guard.* were already disabled here, for instance). if [[ ! -f "$STATE_DIR/units.state" ]] && ((DRY_RUN == 0)); then local unit ustate for unit in "${LEGACY_UNITS[@]}"; do ustate="$(systemctl is-enabled "$unit" 2>/dev/null)" || true [[ -n $ustate ]] || ustate="absent" printf '%s=%s\n' "$unit" "$ustate" done >"$STATE_DIR/units.state" note "recorded legacy unit states in $STATE_DIR/units.state" fi } # ---------------------------------------------------------------------------- # Rollback # ---------------------------------------------------------------------------- do_rollback() { # Two ways a machine gets these guards now: this script's migration (which # records legacy hooks/units in STATE_DIR first), or hosts-blocker's # install.sh calling setup_hosts_guards on a fresh machine, where there is # no legacy layer to back up and so no STATE_DIR at all. Refusing without # STATE_DIR left the second kind of machine -- every freshly installed one # -- with NO escape hatch: `--rollback` bailed out while the instances it # is supposed to remove were live. Uninstalling the instances needs no # backup; only restoring the legacy layer does, and that part already # checks for its own files. if [[ ! -d $STATE_DIR ]]; then local have_instance=0 _n for _n in "${INSTANCES[@]}"; do instance_registered "$_n" && have_instance=1 done if ((have_instance == 0)); then err "no rollback state at $STATE_DIR and no guard-lib instance registered - nothing to roll back to" exit 1 fi warn "no legacy state at $STATE_DIR (guards were installed fresh, not migrated)" warn "uninstalling the guard-lib instances; there is no legacy layer to restore" fi local name for name in "${INSTANCES[@]}"; do if instance_registered "$name"; then # Keep the canonical: it is the known-good copy of the file, and # the legacy layer re-snapshots from the live file, which may have # drifted. Losing it would turn a rollback into data loss. run "$GUARDCTL" file-guard uninstall "$name" --keep-canonical || true msg "uninstalled guard-lib instance $name" fi done local hook for hook in "${LEGACY_HOOKS[@]}"; do if [[ -f "$STATE_DIR/hooks/$hook" ]]; then run cp -a "$STATE_DIR/hooks/$hook" "$HOOKS_DIR/$hook" msg "restored $hook" fi done if [[ -f "$STATE_DIR/units.state" ]]; then local unit state while IFS='=' read -r unit state; do [[ $state == "enabled" ]] || continue run systemctl enable --now "$unit" 2>/dev/null || warn "could not re-enable $unit" msg "re-enabled $unit" done <"$STATE_DIR/units.state" else warn "no units.state recorded - legacy units left as-is" fi # Re-assert the legacy protections NOW rather than waiting for a watcher. # `guardctl file-guard uninstall` chattr -i's the target on its way out, and # the legacy *-guard.path units only fire on PathModified - so without this # a rolled-back /etc/hosts sits with NO immutable flag until something # happens to write to it. Caught by actually running --rollback and looking # at lsattr, which is the only reason it is handled at all. # # Order matters for hosts specifically: it is bind-mounted read-only, and # chattr cannot write through a ro mount. enforce-hosts.sh does a bare # `chattr +i` with no collapse (it relies on running before the mount at # boot), so it fails silently here unless the mount is dropped first. So: # collapse, enforce, then let the bind-mount unit rebuild the ro layer. # HOSTS_FILE is a seam for the tests; production always passes the real # path, which is the file this whole rollback exists to re-protect. collapse_mounts "${HOSTS_FILE:-/etc/hosts}" local svc for svc in hosts-guard.service nsswitch-guard.service resolved-guard.service; do systemctl cat "$svc" &>/dev/null || continue run systemctl start "$svc" 2>/dev/null || warn "could not run $svc" done if systemctl cat hosts-bind-mount.service &>/dev/null; then run systemctl restart hosts-bind-mount.service 2>/dev/null || warn "could not restart hosts-bind-mount.service" fi msg "re-ran legacy enforcement (restores chattr +i and the ro bind mount)" # On a machine that never had the legacy layer, every `systemctl cat` above # skipped and nothing re-asserted anything -- so /etc/hosts would be left # with no immutable flag and no bind mount, which is strictly less # protected than before the rollback. Re-assert immutability directly. This # is the same hazard the block above documents, in the case where there is # no legacy unit to do it for us. local hosts_file="${HOSTS_FILE:-/etc/hosts}" if [[ -e $hosts_file ]] && ! lsattr -d "$hosts_file" 2>/dev/null | grep -q 'i'; then if run chattr +i "$hosts_file" 2>/dev/null; then msg "re-asserted chattr +i on $hosts_file (no legacy unit to do it)" else warn "could not re-assert chattr +i on $hosts_file" fi fi printf "\n" msg "rollback complete" note "verify with: sudo $SCRIPT_NAME --status" }