testsAndMisc/linux_configuration/scripts/periodic_background/hosts/lib/hosts_guard.sh
Krzysztof kuhy Rudnicki 47478791d8
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
hosts/install: split into lib/ under the 250-line cap
913 lines down to 150. The ten functions were only 202 lines; the other 712
were a single top-level block, now eight phase functions across six libs. As
in the previous split the bodies were wrapped WITHOUT re-indenting, because
five heredocs use quoted terminators that must stay at column 0 (shfmt
indented them afterwards, which is safe).

The ~320-line custom-blocklist heredoc became custom_entries.hosts. It was
321 of the 370 lines of hosts_write.sh and cannot be split, so that lib could
not otherwise fit the cap; a hosts-format data file is also far easier to
audit and diff than a quoted heredoc.

That move broke a guard, which is the important part of this commit:

  extract_custom_entries_from_script sliced the blocklist out of the script
  between "# Custom blocking entries" and the heredoc terminator. With the
  entries in a data file there is no terminator, so it would have extracted
  NOTHING -- and check_custom_entries_protection, the guard whose entire job
  is refusing to drop blocked domains, would have compared against an empty
  list and passed everything. It now reads the data file, and the extracted
  set is identical: 254 domains, diffed.

A second, pre-existing bug surfaced while testing:

  extract_unblock_entries_from_script matched its OWN marker lines.
  PROTECTED_UNBLOCK_LIST_START and _END are word characters, so the
  '^# [a-zA-Z0-9._-]+$' pattern pulled both into the whitelist as if they
  were domains. Both are in this machine's live state file right now.
  Renaming either marker would have read as the whitelist growing and blocked
  the install outright.

There is no dry-run or status mode, so this file cannot be proven by running
it: an aborted run leaves /etc/hosts mutable with the guard's bind mount down.
The proof is textual instead. Every one of the 8 wrapped phase bodies is
byte-identical to its original line range; a line-multiset check found 0 lines
lost; and an indentation-insensitive comparison against the pre-split file
leaves exactly 8 absent lines, each an intentional edit (the rewritten
extractor, three `readlink -f "$0"` made overridable, the /etc/hosts fallback
made overridable, and the heredoc markers). /etc/hosts is byte-unchanged
across the whole session, and `--help` output is identical.

36 assertions. Both anti-tamper guards at 100% line coverage (75/75 and
49/49), covering both directions of each: removing a blocked domain is
refused and adding one allowed; growing the whitelist is refused and shrinking
it allowed. A same-count swap is refused too, which a count-only check would
have let through.

The install phases themselves are deliberately untested -- running them would
take down the protection they install. That is weaker evidence than the two
previous splits had, and it is inherent to this file rather than a gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3PzGc75gBcdxP3PkHXGcV
2026-08-21 14:45:04 +02:00

126 lines
5.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# lib/hosts_guard.sh — the guard lifecycle around the write.
#
# stop_hosts_guard takes down the path watcher, the bind mount and the
# immutable attribute so /etc/hosts can be written; restart_hosts_guard puts
# them back; save_protection_state records the baseline the next run compares
# against. Sourced by install.sh.
# Enable systemd-resolved and make it read /etc/hosts. Without
# ReadEtcHosts=yes it ignores the file entirely and every blocked domain
# resolves normally, which defeats the whole install.
enable_resolved_reads_hosts() {
# Enable systemd-resolved
sudo systemctl enable systemd-resolved
# ============================================================================
# ENSURE systemd-resolved READS /etc/hosts
# ============================================================================
# Without this, systemd-resolved ignores /etc/hosts entries entirely,
# allowing blocked domains to resolve via DNS.
RESOLVED_CONF="/etc/systemd/resolved.conf"
if grep -q '^ReadEtcHosts=no' "$RESOLVED_CONF" 2>/dev/null; then
echo "Fixing systemd-resolved: setting ReadEtcHosts=yes..."
sudo sed -i 's/^ReadEtcHosts=no/ReadEtcHosts=yes/' "$RESOLVED_CONF"
sudo systemctl restart systemd-resolved
elif ! grep -q '^ReadEtcHosts=yes' "$RESOLVED_CONF" 2>/dev/null; then
echo "Enabling ReadEtcHosts=yes in systemd-resolved..."
if grep -q '^#ReadEtcHosts=' "$RESOLVED_CONF" 2>/dev/null; then
sudo sed -i 's/^#ReadEtcHosts=.*/ReadEtcHosts=yes/' "$RESOLVED_CONF"
else
echo 'ReadEtcHosts=yes' | sudo tee -a "$RESOLVED_CONF" >/dev/null
fi
sudo systemctl restart systemd-resolved
fi
}
# Take down the guard so /etc/hosts can be written: stop the path watcher,
# unmount the read-only bind mount, and clear the immutable/append-only
# attributes. restart_hosts_guard puts all three back.
stop_hosts_guard() {
# ============================================================================
# TEMPORARILY DISABLE HOSTS GUARD PROTECTIONS FOR INSTALLATION
# ============================================================================
# The guard system uses a read-only bind mount and path watcher that prevent
# any writes to /etc/hosts. We must stop them before installing, then restart.
GUARD_SERVICES_STOPPED=0
for svc in hosts-bind-mount.service hosts-guard.path; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
echo "Stopping $svc for installation..."
systemctl stop "$svc" 2>/dev/null || true
GUARD_SERVICES_STOPPED=1
fi
done
# If bind mount is still active, unmount it
if findmnt /etc/hosts >/dev/null 2>&1; then
echo "Unmounting read-only bind mount on /etc/hosts..."
umount /etc/hosts 2>/dev/null || mount -o remount,rw,bind /etc/hosts 2>/dev/null || true
fi
# Remove all attributes from /etc/hosts to allow modifications
sudo chattr -i -a /etc/hosts 2>/dev/null || true
}
# Bring the guard back up after the write: restart the services stopped by
# stop_hosts_guard so /etc/hosts is protected again.
restart_hosts_guard() {
# ============================================================================
# RESTART HOSTS GUARD SERVICES
# ============================================================================
if [[ $GUARD_SERVICES_STOPPED -eq 1 ]]; then
echo "Restarting hosts guard services..."
# Update the canonical copy so the guard doesn't revert our changes
if [[ -f /usr/local/share/locked-hosts ]]; then
cp /etc/hosts /usr/local/share/locked-hosts
echo " Updated canonical snapshot."
fi
for svc in hosts-bind-mount.service hosts-guard.path; do
if systemctl is-enabled --quiet "$svc" 2>/dev/null; then
systemctl start "$svc" 2>/dev/null || true
echo " Restarted $svc"
fi
done
fi
# ============================================================================
}
# Record the custom and unblock entry lists that were just installed, so the
# next run's protection checks have a baseline to compare against.
save_protection_state() {
# SAVE CUSTOM ENTRIES STATE FOR FUTURE PROTECTION CHECKS
# ============================================================================
echo "Saving custom entries state for protection mechanism..."
# The install script whose entry lists are being checked. Defaults to the
# running script, which is what production always wants; a test points it
# at a fixture instead, since these guards are the part of this file where
# a silent regression matters most.
script_path="$(readlink -f "${HOSTS_INSTALL_SCRIPT_PATH:-$0}")"
current_custom_entries=$(extract_custom_entries_from_script "$script_path")
# Remove immutable from state file if it exists
chattr -i "$CUSTOM_ENTRIES_STATE_FILE" 2>/dev/null || true
save_custom_entries_state "$current_custom_entries"
echo "✅ Custom entries state saved to $CUSTOM_ENTRIES_STATE_FILE"
# Save unblock entries state for future protection checks
current_unblock_entries=$(extract_unblock_entries_from_script "$script_path")
save_unblock_entries_state "$current_unblock_entries"
echo "✅ Unblock entries state saved to $UNBLOCK_STATE_FILE"
# Optionally flush DNS caches
if [[ $FLUSH_DNS -eq 1 ]]; then
echo "Flushing DNS caches..."
sudo systemd-resolve --flush-caches
sudo systemctl restart NetworkManager.service
else
echo "DNS cache flush skipped (use --flush-dns to enable)."
fi
# ============================================================================
}