diff --git a/docs/superpowers/evidence/hosts-guard-rollback-fresh-install-2026-08-24.json b/docs/superpowers/evidence/hosts-guard-rollback-fresh-install-2026-08-24.json new file mode 100644 index 00000000..0e386a08 --- /dev/null +++ b/docs/superpowers/evidence/hosts-guard-rollback-fresh-install-2026-08-24.json @@ -0,0 +1,47 @@ +{ + "intent": "Give a freshly installed machine a working escape hatch from the hosts file-guards. Until now --rollback refused to run on exactly the machines the previous commit created.", + "scope": [ + "linux_configuration/fixes/lib/hosts_guard_rollback.sh only.", + "Follow-up to 4c426eae, which made hosts-blocker's install.sh register the guard-lib instances directly.", + "Non-goal: changing what the migration path records; a migrated machine keeps its existing legacy backup and restore behaviour unchanged." + ], + "changes": [ + "do_rollback no longer exits when /var/lib/guard-lib-migration is absent. That state dir is written by the one-shot migration, but a machine set up by hosts-blocker's install.sh has no legacy layer to back up and therefore no state dir -- so --rollback bailed out while the very instances it exists to remove were live. It now proceeds whenever a guard-lib instance is registered, warns that there is no legacy layer to restore, and still refuses when there is genuinely nothing to roll back.", + "After uninstalling the instances, re-assert chattr +i on /etc/hosts when no legacy unit did it. The existing re-assertion relied on starting hosts-guard.service / hosts-bind-mount.service, which do not exist on a fresh machine, so every systemctl cat skipped and the rollback left /etc/hosts with no immutable flag and no bind mount -- strictly less protected than before the rollback." + ], + "verification": [ + { + "command": "sudo bash linux_configuration/fixes/migrate_hosts_guard_to_guard_lib.sh --rollback --dry-run (pristine vmbox guest, guards installed by install_core_system.sh --all)", + "result": "pass", + "evidence": "DRYRUN_RC=0, reporting it would uninstall the hosts, nsswitch and resolved instances. Before this change the same command printed 'no rollback state at /var/lib/guard-lib-migration - nothing to roll back to' and exited 1." + }, + { + "command": "sudo bash linux_configuration/fixes/migrate_hosts_guard_to_guard_lib.sh --rollback (same guest)", + "result": "pass", + "evidence": "ROLLBACK_RC=0. Uninstalled file-guard hosts, nsswitch and resolved, then logged 're-asserted chattr +i on /etc/hosts (no legacy unit to do it)'." + }, + { + "command": "ls /etc/guard-lib/targets; lsattr /etc/hosts; findmnt /etc/hosts; wc -l /etc/hosts (after rollback)", + "result": "pass", + "evidence": "Only shutdown-schedule.conf remains, which belongs to a different feature and must not be touched. /etc/hosts shows ----i----------------- and still holds all 178410 lines; the bind mount is gone, which is the intended post-rollback state." + }, + { + "command": "shellcheck -x linux_configuration/fixes/lib/hosts_guard_rollback.sh", + "result": "pass", + "evidence": "Clean. An initial A && B || C form was rewritten as if/else after SC2015 pointed out the C branch can run when A succeeds, which matters because `run` is a no-op under --dry-run." + }, + { + "command": "bash ./linux_configuration/fixes/lib/tests/run_all.sh", + "result": "pass", + "evidence": "hosts_guard_rollback: 31 passed, 0 failed; whole suite exits 0. The first attempt failed one case: the refusal message is asserted verbatim by test_hosts_guard_rollback.sh:115, and the new no-instance branch had dropped the trailing 'to'. The wording was restored rather than the assertion loosened, since the test is checking the user-visible explanation." + } + ], + "risks": [ + "Rollback is now reachable in more situations, so an accidental invocation can unregister live guards. It still refuses when no instance is registered, and it keeps the canonical copy via --keep-canonical, so the file itself is never lost.", + "The re-assertion only covers /etc/hosts. nsswitch.conf and resolved.conf are left to their plugins, matching the behaviour the migration path already had." + ], + "rollback": [ + "git revert this commit; the previous behaviour (refuse without a state dir) returns.", + "After reverting, confirm with sudo bash linux_configuration/fixes/migrate_hosts_guard_to_guard_lib.sh --status that the instance list matches expectations before relying on it." + ] +} diff --git a/linux_configuration/fixes/lib/hosts_guard_rollback.sh b/linux_configuration/fixes/lib/hosts_guard_rollback.sh index d7440490..eae53c34 100755 --- a/linux_configuration/fixes/lib/hosts_guard_rollback.sh +++ b/linux_configuration/fixes/lib/hosts_guard_rollback.sh @@ -75,10 +75,27 @@ save_rollback_state() { # Rollback # ---------------------------------------------------------------------------- do_rollback() { - [[ -d $STATE_DIR ]] || { - err "no rollback state at $STATE_DIR - nothing to roll back to" - exit 1 - } + # 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 @@ -137,6 +154,21 @@ do_rollback() { 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"