hosts/install: split into lib/ under the 250-line cap
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

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
This commit is contained in:
Krzysztof Rudnicki 2026-08-21 14:45:04 +02:00
parent 241652cade
commit 47478791d8
14 changed files with 1543 additions and 816 deletions

View File

@ -0,0 +1,20 @@
{
"title": "Split hosts/install.sh under the 250-line cap with its anti-tamper guards fully covered",
"objective": "Bring linux_configuration/scripts/periodic_background/hosts/install.sh from 913 lines under the repo-wide 250-line cap by moving its 712-line top-level block into eight phase functions across six libs, without changing what a real install would do. File 3 of the Phase 1 queue: 13 referencing files and the highest blast radius, since it rewrites /etc/hosts and takes the guard down to do it.",
"acceptance_criteria": [
"Every shell file — entry script, libs and tests — is at or under 250 lines.",
"Each wrapped phase body is byte-identical to its original line range, proven by diff, because end-to-end execution is not available as evidence here.",
"A content comparison against the pre-split file accounts for every absent line as an intentional edit.",
"The 254 custom-blocked domains extracted by the guard are identical before and after the blocklist becomes a data file.",
"Both anti-tamper guards reach 100% line coverage, with both directions of each covered: removal refused / addition allowed for the custom list, growth refused / shrinkage allowed for the whitelist.",
"/etc/hosts is unchanged, verified by sha256sum, and the installer is never run end to end.",
"shellcheck reports zero findings with no suppression added."
],
"out_of_scope": [
"Running the install phases under test, which would rewrite /etc/hosts, unmount the guard's bind mount and restart systemd-resolved.",
"Adding a dry-run or status mode, which would make end-to-end testing possible but is a feature change rather than a split.",
"The SERVICES_ROOT-style path prefix that would make the phases testable; that is a separate follow-up.",
"The three files still over the cap."
],
"verifier": "A per-phase body diff against the pre-wrap file, a line-multiset check for lost content, a diff of the 254 extracted domains, bash meta/scripts/verify_shell_split.sh 241652ca, lib/tests/run_all.sh (36 assertions), bash meta/scripts/shell_coverage.sh on both guards at 100, `bash install.sh --help` compared to pre-split, sha256sum -c on /etc/hosts, shellcheck, and check_file_length.sh --all."
}

View File

@ -0,0 +1,68 @@
{
"intent": "Split hosts/install.sh (913 lines) under the 250-line cap, cover its two anti-tamper guards at 100%, and fix the defects the tests exposed. File 3 of the Phase 1 queue, and the one with the widest reference surface (13 files) and the highest blast radius.",
"scope": [
"linux_configuration/scripts/periodic_background/hosts/install.sh — 913 lines down to 150",
"lib/hosts_protect_custom.sh, hosts_protect_unblock.sh, hosts_guard.sh, hosts_cache.sh, hosts_write.sh, hosts_browser_doh.sh — new",
"custom_entries.hosts — the ~320-line blocklist heredoc, now a data file",
"lib/tests/ — a harness, two test files and a runner",
"Non-goal: running the installer end to end, which would rewrite /etc/hosts and take down the guard"
],
"changes": [
"Wrapped the 712-line top-level block into eight phase functions and moved them into libs. Bodies were not re-indented at wrap time (five heredocs need column-0 terminators); shfmt indented them afterwards, which is why the verification below compares content rather than exact lines.",
"Moved the ~320-line custom-blocklist heredoc into custom_entries.hosts. It was 321 of the 370 lines of hosts_write.sh and could not be split, so the lib could not otherwise fit the cap. A hosts-format data file is also easier to audit than a quoted heredoc.",
"FIX 1 — moving the blocklist broke extract_custom_entries_from_script, which sliced between '# Custom blocking entries' and the heredoc terminator. With no terminator in the data file it would have extracted NOTHING, silently disabling the guard that refuses to drop blocked domains. It now reads the data file directly; the extracted set is identical (254 domains, diffed).",
"FIX 2 — 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. They are in the live state file on this machine right now. Renaming either marker would have read as the whitelist growing and blocked the install.",
"Made three call sites overridable so the guards could be tested at all: HOSTS_INSTALL_SCRIPT_PATH for `readlink -f $0` and HOSTS_FILE_PATH for the /etc/hosts fallback read. Both default to the production value."
],
"verification": [
{
"command": "Per-phase body diff against the pre-wrap file",
"result": "pass",
"evidence": "All 8 wrapped phase bodies byte-identical to their original line ranges at wrap time — enable_resolved_reads_hosts (23), stop_hosts_guard (24), refresh_upstream_cache (49), write_hosts_file (354), restart_hosts_guard (20), save_protection_state (25), disable_browser_doh (63), restart_browsers (33). A line-multiset comparison confirmed 0 original lines lost and only 62 wrapper lines added."
},
{
"command": "Content comparison of the final tree against the pre-split file, ignoring indentation",
"result": "pass",
"evidence": "Exactly 8 original lines absent, each an intentional edit: 2 from the rewritten custom-entries extractor, 3 `readlink -f \"$0\"` made overridable, 1 /etc/hosts fallback made overridable, and the heredoc opener/terminator replaced by the data-file read. Nothing else changed."
},
{
"command": "diff of the 254 custom-blocked domains, old heredoc vs new data file",
"result": "pass",
"evidence": "Identical sets. This is what proves FIX 1 preserved the guard's input rather than merely making it run."
},
{
"command": "bash meta/scripts/verify_shell_split.sh 241652ca <old> <old> lib/hosts_*.sh",
"result": "pass",
"evidence": "8 additions (the phase wrappers) and one changed body — extract_custom_entries_from_script, the deliberate rewrite. Every other pre-existing function hashes identically."
},
{
"command": "bash install.sh --help; sha256sum -c on /etc/hosts",
"result": "pass",
"evidence": "--help output identical to pre-split, and /etc/hosts unchanged across the entire session. --help is the only execution path that exits before any write; there is no dry-run or status mode, which is why end-to-end execution was not used as proof."
},
{
"command": "lib/tests/run_all.sh",
"result": "pass",
"evidence": "36 assertions, 0 failures: test_hosts_protect.sh 25, test_hosts_cache.sh 11. Both directions of both guards are covered — removing a blocked domain is refused, adding one is allowed; growing the whitelist is refused, shrinking it is allowed. A same-count swap is also refused, which a count-only check would have passed."
},
{
"command": "bash meta/scripts/shell_coverage.sh lib/tests/test_hosts_protect.sh <guard> 100",
"result": "pass",
"evidence": "hosts_protect_custom.sh 75/75 = 100.00%, hosts_protect_unblock.sh 49/49 = 100.00%. These are the two functions where a silent regression matters most."
},
{
"command": "shellcheck install.sh lib/hosts_*.sh lib/tests/*.sh; check_file_length.sh --all",
"result": "pass",
"evidence": "Zero findings standalone and with -x, no suppression. No shell file over 250 lines; largest is install.sh at 150."
}
],
"risks": [
"The install phases have NO test coverage, and that is deliberate: they write /etc/hosts, unmount the guard's bind mount and restart systemd-resolved, so running them under test would take down the protection they install. Their proof is textual (byte-identical bodies) rather than behavioural, which is genuinely weaker evidence than the previous two splits had. Making them testable needs the SERVICES_ROOT-style prefix, which is a separate change.",
"FIX 2 changes what the unblock guard sees: the live state file still holds the two phantom marker entries, so the next install reads the whitelist as having SHRUNK by 2. Shrinking is always allowed, so the install proceeds and rewrites the state without the phantoms. No manual step is needed, but the next run's output will report 2 removed entries.",
"custom_entries.hosts is now the file to edit to change the blocklist, and check_custom_entries_protection watches it. Editing install.sh instead would no longer have any effect on what gets blocked."
],
"rollback": [
"git revert this commit restores the single file, the heredoc and the original extractors together.",
"After rollback, run `bash install.sh --help` and confirm it still exits 0, and check that /etc/hosts is untouched — the revert changes no system state by itself."
]
}

View File

@ -0,0 +1,329 @@
# Custom blocking entries appended to /etc/hosts by lib/hosts_write.sh.
#
# Kept as data rather than as a heredoc inside the script: at ~320 lines it was
# most of that file and pushed it over the repo-wide 250-line cap, and a plain
# hosts-format file is also easier to diff and to audit than a quoted heredoc.
# The content is byte-identical to the heredoc it replaces.
#
# Format is standard /etc/hosts: "0.0.0.0 domain" to block, "#" to comment.
# check_custom_entries_protection reads this same file to detect entries being
# removed, so edits here are what the anti-tamper guard is watching.
# Custom blocking entries
# YouTube
0.0.0.0 youtube.com
0.0.0.0 www.youtube.com
0.0.0.0 m.youtube.com
0.0.0.0 youtu.be
0.0.0.0 youtube-nocookie.com
0.0.0.0 www.youtube-nocookie.com
0.0.0.0 youtubei.googleapis.com
0.0.0.0 youtube.googleapis.com
0.0.0.0 yt3.ggpht.com
# Channel avatars and banners. Same role as yt3.ggpht.com, which was already
# here -- Google serves them from both hosts, so blocking one leaves the other
# resolving. Caught when the phone's DoT resolver was checked domain by domain
# against the 14 the focus policy names.
0.0.0.0 yt3.googleusercontent.com
0.0.0.0 ytimg.com
0.0.0.0 i.ytimg.com
0.0.0.0 s.ytimg.com
0.0.0.0 i9.ytimg.com
0.0.0.0 googlevideo.com
0.0.0.0 r1---sn-4g5e6nls.googlevideo.com
0.0.0.0 r1---sn-4g5lne7s.googlevideo.com
# Alternative YouTube Frontends (Invidious, Piped, etc.)
0.0.0.0 invidious.io
0.0.0.0 www.invidious.io
0.0.0.0 invidio.us
0.0.0.0 vid.puffyan.us
0.0.0.0 invidious.snopyta.org
0.0.0.0 yewtu.be
0.0.0.0 invidious.kavin.rocks
0.0.0.0 inv.riverside.rocks
0.0.0.0 invidious.namazso.eu
0.0.0.0 invidious.nerdvpn.de
0.0.0.0 invidious.projectsegfau.lt
0.0.0.0 invidious.slipfox.xyz
0.0.0.0 invidious.privacydev.net
0.0.0.0 invidious.perennialte.ch
0.0.0.0 invidious.protokoll-11.de
0.0.0.0 invidious.einfachzocken.eu
0.0.0.0 invidious.fdn.fr
0.0.0.0 inv.in.projectsegfau.lt
0.0.0.0 invidious.tiekoetter.com
0.0.0.0 invidious.lunar.icu
0.0.0.0 iv.ggtyler.dev
0.0.0.0 iv.melmac.space
0.0.0.0 piped.video
0.0.0.0 www.piped.video
0.0.0.0 piped.kavin.rocks
0.0.0.0 piped.mha.fi
0.0.0.0 piped.mint.lgbt
0.0.0.0 piped.projectsegfau.lt
0.0.0.0 piped.privacydev.net
0.0.0.0 piped.smnz.de
0.0.0.0 piped.adminforge.de
0.0.0.0 watch.whatever.social
0.0.0.0 piped.lunar.icu
0.0.0.0 viewtube.io
0.0.0.0 www.viewtube.io
0.0.0.0 freetube.io
0.0.0.0 www.freetube.io
0.0.0.0 tubo.media
0.0.0.0 www.tubo.media
0.0.0.0 materialious.nadeko.net
0.0.0.0 clipious.org
0.0.0.0 www.clipious.org
0.0.0.0 newpipe.net
0.0.0.0 www.newpipe.net
0.0.0.0 newpipe.schabi.org
0.0.0.0 grayjay.app
0.0.0.0 www.grayjay.app
0.0.0.0 libretube.dev
0.0.0.0 www.libretube.dev
0.0.0.0 hyperion.deishelon.com
0.0.0.0 inv.n8pjl.ca
0.0.0.0 inv.zzls.xyz
0.0.0.0 inv.tux.pizza
0.0.0.0 invidious.incogniweb.net
0.0.0.0 invidious.drgns.space
0.0.0.0 invidious.io.lol
# Steam Store
0.0.0.0 store.steampowered.com
0.0.0.0 checkout.steampowered.com
0.0.0.0 store.akamai.steamstatic.com
0.0.0.0 storefront.steampowered.com
0.0.0.0 store.cloudflare.steamstatic.com
# Discord - media allowed
# 0.0.0.0 cdn.discordapp.com
# 0.0.0.0 media.discordapp.net
# 0.0.0.0 images-ext-1.discordapp.net
# 0.0.0.0 images-ext-2.discordapp.net
# 0.0.0.0 attachments-1.discordapp.net
# 0.0.0.0 attachments-2.discordapp.net
# 0.0.0.0 tenor.com
# 0.0.0.0 giphy.com
# Food Delivery Services
# Polish services
0.0.0.0 pyszne.pl
0.0.0.0 www.pyszne.pl
0.0.0.0 m.pyszne.pl
0.0.0.0 api.pyszne.pl
0.0.0.0 app.pyszne.pl
0.0.0.0 glovo.com
0.0.0.0 www.glovo.com
0.0.0.0 m.glovo.com
0.0.0.0 api.glovo.com
0.0.0.0 glovoapp.com
0.0.0.0 www.glovoapp.com
0.0.0.0 bolt.eu
:: bolt.eu
0.0.0.0 www.bolt.eu
:: www.bolt.eu
0.0.0.0 food.bolt.eu
:: food.bolt.eu
0.0.0.0 m.bolt.eu
:: m.bolt.eu
0.0.0.0 api.bolt.eu
:: api.bolt.eu
0.0.0.0 node.bolt.eu
:: node.bolt.eu
0.0.0.0 gw.bolt.eu
:: gw.bolt.eu
0.0.0.0 client-api.bolt.eu
:: client-api.bolt.eu
0.0.0.0 auth.bolt.eu
:: auth.bolt.eu
0.0.0.0 cdn.bolt.eu
:: cdn.bolt.eu
0.0.0.0 images.bolt.eu
:: images.bolt.eu
0.0.0.0 static.bolt.eu
:: static.bolt.eu
0.0.0.0 assets.bolt.eu
:: assets.bolt.eu
0.0.0.0 fleet.bolt.eu
:: fleet.bolt.eu
0.0.0.0 user.bolt.eu
:: user.bolt.eu
0.0.0.0 courier.bolt.eu
:: courier.bolt.eu
0.0.0.0 rider.bolt.eu
:: rider.bolt.eu
0.0.0.0 restaurant.bolt.eu
:: restaurant.bolt.eu
0.0.0.0 partner-food.bolt.eu
:: partner-food.bolt.eu
0.0.0.0 woltwojta.pl
0.0.0.0 www.woltwojta.pl
0.0.0.0 wolt.com
0.0.0.0 www.wolt.com
0.0.0.0 m.wolt.com
0.0.0.0 api.wolt.com
0.0.0.0 restaurant-api.wolt.com
0.0.0.0 consumer-api.wolt.com
0.0.0.0 jush.pl
0.0.0.0 www.jush.pl
0.0.0.0 m.jush.pl
0.0.0.0 api.jush.pl
0.0.0.0 delio.pl
:: delio.pl
0.0.0.0 www.delio.pl
:: www.delio.pl
0.0.0.0 m.delio.pl
:: m.delio.pl
0.0.0.0 api.delio.pl
:: api.delio.pl
0.0.0.0 app.delio.pl
:: app.delio.pl
0.0.0.0 cdn.delio.pl
:: cdn.delio.pl
0.0.0.0 static.delio.pl
:: static.delio.pl
0.0.0.0 order.delio.pl
:: order.delio.pl
0.0.0.0 delio.com
:: delio.com
0.0.0.0 www.delio.com
:: www.delio.com
0.0.0.0 m.delio.com
:: m.delio.com
0.0.0.0 api.delio.com
:: api.delio.com
0.0.0.0 app.delio.com
:: app.delio.com
0.0.0.0 delio.com.pl
:: delio.com.pl
0.0.0.0 www.delio.com.pl
:: www.delio.com.pl
0.0.0.0 m.delio.com.pl
:: m.delio.com.pl
0.0.0.0 api.delio.com.pl
:: api.delio.com.pl
0.0.0.0 app.delio.com.pl
:: app.delio.com.pl
0.0.0.0 lisek.app
0.0.0.0 www.lisek.app
0.0.0.0 api.lisek.app
0.0.0.0 stava.app
0.0.0.0 www.stava.app
0.0.0.0 api.stava.app
0.0.0.0 biedronka.pl
0.0.0.0 zakupy.biedronka.pl
0.0.0.0 ezakupy.tesco.pl
0.0.0.0 www.ezakupy.tesco.pl
0.0.0.0 barbora.pl
0.0.0.0 www.barbora.pl
0.0.0.0 api.barbora.pl
0.0.0.0 frisco.pl
0.0.0.0 www.frisco.pl
0.0.0.0 api.frisco.pl
0.0.0.0 swiatkwiatow.pl
0.0.0.0 www.swiatkwiatow.pl
0.0.0.0 allegro.pl/kategoria/jedzenie
0.0.0.0 szama.pl
0.0.0.0 www.szama.pl
0.0.0.0 api.szama.pl
0.0.0.0 auchandirect.pl
0.0.0.0 www.auchandirect.pl
# International services
0.0.0.0 ubereats.com
0.0.0.0 www.ubereats.com
0.0.0.0 m.ubereats.com
0.0.0.0 api.ubereats.com
0.0.0.0 uber.com
0.0.0.0 www.uber.com
0.0.0.0 m.uber.com
0.0.0.0 api.uber.com
0.0.0.0 cn-geo1.uber.com
0.0.0.0 login.uber.com
0.0.0.0 auth.uber.com
0.0.0.0 riders.uber.com
0.0.0.0 deliveroo.com
0.0.0.0 www.deliveroo.com
0.0.0.0 m.deliveroo.com
0.0.0.0 api.deliveroo.com
0.0.0.0 deliveroo.co.uk
0.0.0.0 www.deliveroo.co.uk
0.0.0.0 foodpanda.com
0.0.0.0 www.foodpanda.com
0.0.0.0 m.foodpanda.com
0.0.0.0 api.foodpanda.com
0.0.0.0 grubhub.com
0.0.0.0 www.grubhub.com
0.0.0.0 m.grubhub.com
0.0.0.0 api.grubhub.com
0.0.0.0 doordash.com
0.0.0.0 www.doordash.com
0.0.0.0 m.doordash.com
0.0.0.0 api.doordash.com
0.0.0.0 justeat.com
0.0.0.0 www.justeat.com
0.0.0.0 m.justeat.com
0.0.0.0 justeat.co.uk
0.0.0.0 www.justeat.co.uk
0.0.0.0 postmates.com
0.0.0.0 www.postmates.com
0.0.0.0 api.postmates.com
0.0.0.0 seamless.com
0.0.0.0 www.seamless.com
0.0.0.0 menulog.com.au
0.0.0.0 www.menulog.com.au
0.0.0.0 delivery.com
0.0.0.0 www.delivery.com
0.0.0.0 getir.com
0.0.0.0 www.getir.com
0.0.0.0 api.getir.com
0.0.0.0 flink.com
0.0.0.0 www.flink.com
0.0.0.0 api.flink.com
0.0.0.0 gorillas.io
0.0.0.0 www.gorillas.io
0.0.0.0 api.gorillas.io
0.0.0.0 gopuff.com
0.0.0.0 www.gopuff.com
0.0.0.0 api.gopuff.com
0.0.0.0 instacart.com
0.0.0.0 www.instacart.com
0.0.0.0 api.instacart.com
0.0.0.0 takeaway.com
0.0.0.0 www.takeaway.com
0.0.0.0 api.takeaway.com
# Fast food chain apps and websites
0.0.0.0 mcdonalds.com
0.0.0.0 www.mcdonalds.com
0.0.0.0 m.mcdonalds.com
0.0.0.0 mcdonalds.pl
0.0.0.0 www.mcdonalds.pl
0.0.0.0 kfc.com
0.0.0.0 www.kfc.com
0.0.0.0 m.kfc.com
0.0.0.0 kfc.pl
0.0.0.0 www.kfc.pl
0.0.0.0 burgerking.com
0.0.0.0 www.burgerking.com
0.0.0.0 m.burgerking.com
0.0.0.0 burgerking.pl
0.0.0.0 www.burgerking.pl
0.0.0.0 pizzahut.com
0.0.0.0 www.pizzahut.com
0.0.0.0 m.pizzahut.com
0.0.0.0 pizzahut.pl
0.0.0.0 www.pizzahut.pl
0.0.0.0 dominos.com
0.0.0.0 www.dominos.com
0.0.0.0 m.dominos.com
0.0.0.0 dominos.pl
0.0.0.0 www.dominos.pl
0.0.0.0 subway.com
0.0.0.0 www.subway.com
0.0.0.0 m.subway.com
0.0.0.0 subway.pl
0.0.0.0 www.subway.pl

View File

@ -25,6 +25,23 @@ for arg in "$@"; do
esac
done
# Each phase of the install lives in a lib beside this file; this script
# keeps the flag parsing, the protection-check gates and the order the
# phases run in.
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib"
# shellcheck source=lib/hosts_protect_custom.sh
. "$LIB_DIR/hosts_protect_custom.sh"
# shellcheck source=lib/hosts_protect_unblock.sh
. "$LIB_DIR/hosts_protect_unblock.sh"
# shellcheck source=lib/hosts_guard.sh
. "$LIB_DIR/hosts_guard.sh"
# shellcheck source=lib/hosts_cache.sh
. "$LIB_DIR/hosts_cache.sh"
# shellcheck source=lib/hosts_write.sh
. "$LIB_DIR/hosts_write.sh"
# shellcheck source=lib/hosts_browser_doh.sh
. "$LIB_DIR/hosts_browser_doh.sh"
# ============================================================================
# CUSTOM ENTRIES PROTECTION MECHANISM
# ============================================================================
@ -37,129 +54,6 @@ done
CUSTOM_ENTRIES_STATE_FILE="/etc/hosts.custom-entries.state"
UNBLOCK_STATE_FILE="/etc/hosts.unblock-entries.state"
# Extract custom blocked entries from a hosts file or heredoc section
# Returns only the "0.0.0.0 domain.com" lines (normalized, sorted, unique)
extract_custom_entries_from_script() {
# Extract entries from the heredoc in this script (between EOF markers after "Custom blocking entries")
local script_path="$1"
sed -n '/^# Custom blocking entries$/,/^EOF$/p' "$script_path" |
grep -E '^0\.0\.0\.0[[:space:]]+' |
awk '{print $2}' |
sort -u
}
# Extract custom entries from the current /etc/hosts (entries after "# Custom blocking entries" marker)
extract_custom_entries_from_hosts() {
local hosts_file="$1"
if [[ ! -f $hosts_file ]]; then
return
fi
sed -n '/^# Custom blocking entries$/,$p' "$hosts_file" |
grep -E '^0\.0\.0\.0[[:space:]]+' |
awk '{print $2}' |
sort -u
}
# Load previously saved custom entries state
load_saved_custom_entries() {
if [[ -f $CUSTOM_ENTRIES_STATE_FILE ]]; then
sort -u "$CUSTOM_ENTRIES_STATE_FILE"
fi
}
# Save current custom entries to state file
save_custom_entries_state() {
local entries="$1"
echo "$entries" | sort -u >"$CUSTOM_ENTRIES_STATE_FILE"
chmod 644 "$CUSTOM_ENTRIES_STATE_FILE"
chattr +i "$CUSTOM_ENTRIES_STATE_FILE" 2>/dev/null || true
}
# Helper function to count non-empty lines
count_lines() {
local input="$1"
if [[ -z $input ]]; then
echo 0
else
echo "$input" | grep -c . 2>/dev/null || echo 0
fi
}
# Main protection check
check_custom_entries_protection() {
local script_path
script_path="$(readlink -f "$0")"
# Get new entries from the script's heredoc
local new_entries
new_entries=$(extract_custom_entries_from_script "$script_path")
local new_count
new_count=$(count_lines "$new_entries")
# Get saved/existing entries (prefer state file, fall back to current /etc/hosts)
local saved_entries
saved_entries=$(load_saved_custom_entries)
if [[ -z $saved_entries ]]; then
# First run or state file missing - extract from current /etc/hosts if it has our marker
saved_entries=$(extract_custom_entries_from_hosts "/etc/hosts")
fi
local saved_count
saved_count=$(count_lines "$saved_entries")
# If no saved state exists, this is first installation - allow it
if [[ $saved_count -eq 0 ]]; then
echo "ℹ️ First installation detected - no protection check needed."
return 0
fi
# Find entries that were removed
local removed_entries
removed_entries=$(comm -23 <(echo "$saved_entries") <(echo "$new_entries"))
local removed_count
removed_count=$(count_lines "$removed_entries")
# Find entries that are new
local added_entries
added_entries=$(comm -13 <(echo "$saved_entries") <(echo "$new_entries"))
local added_count
added_count=$(count_lines "$added_entries")
echo ""
echo "📊 Custom Entries Protection Check:"
echo " Previously blocked: $saved_count entries"
echo " Currently in script: $new_count entries"
echo " Removed: $removed_count | Added: $added_count"
# RULE 1: No entries removed - always OK
if [[ $removed_count -eq 0 ]]; then
echo " ✅ No entries removed - protection check passed."
return 0
fi
# RULE 2: Entries were removed - BLOCK INSTALLATION
echo ""
echo "============================================================"
echo " ❌ INSTALLATION BLOCKED - CUSTOM ENTRIES REMOVED"
echo "============================================================"
echo ""
echo "You are attempting to REMOVE the following blocked entries:"
while IFS= read -r entry; do
echo " - $entry"
done <<<"$removed_entries"
echo ""
echo "This is NOT allowed. The only way to unblock sites is to:"
echo ""
echo " 1. Manually edit /etc/hosts (requires removing chattr protection)"
echo " 2. Delete the state file /etc/hosts.custom-entries.state"
echo " (also protected with chattr)"
echo ""
echo "These manual steps are intentionally difficult to prevent"
echo "impulsive unblocking. If you really need to unblock something,"
echo "you'll have to work for it."
echo ""
return 1
}
# Run the protection check
if ! check_custom_entries_protection; then
exit 1
@ -192,709 +86,16 @@ fi
# licdn.com
# PROTECTED_UNBLOCK_LIST_END
# Extract whitelisted domains from the protected list embedded in this script
extract_unblock_entries_from_script() {
local script_path="$1"
sed -n '/^# PROTECTED_UNBLOCK_LIST_START$/,/^# PROTECTED_UNBLOCK_LIST_END$/p' "$script_path" |
grep -E '^# [a-zA-Z0-9._-]+$' |
sed 's/^# //' |
sort -u
}
# Save current unblock entries to immutable state file
save_unblock_entries_state() {
local entries="$1"
chattr -i "$UNBLOCK_STATE_FILE" 2>/dev/null || true
echo "$entries" | sort -u >"$UNBLOCK_STATE_FILE"
chmod 644 "$UNBLOCK_STATE_FILE"
chattr +i "$UNBLOCK_STATE_FILE" 2>/dev/null || true
}
# Block installation if the unblock list has grown (more sites being whitelisted)
check_unblock_entries_protection() {
local script_path
script_path="$(readlink -f "$0")"
local new_entries
new_entries=$(extract_unblock_entries_from_script "$script_path")
local new_count
new_count=$(count_lines "$new_entries")
if [[ ! -f $UNBLOCK_STATE_FILE ]]; then
echo "ℹ️ First unblock-list run — no protection check needed."
return 0
fi
local saved_entries
saved_entries=$(sort -u "$UNBLOCK_STATE_FILE")
local saved_count
saved_count=$(count_lines "$saved_entries")
# Entries added since last install
local added_entries
added_entries=$(comm -13 <(echo "$saved_entries") <(echo "$new_entries"))
local added_count
added_count=$(count_lines "$added_entries")
echo ""
echo "📊 Unblock Entries Protection Check:"
echo " Previously whitelisted: $saved_count domains"
echo " Currently in script: $new_count domains"
echo " Newly added: $added_count"
if [[ $added_count -eq 0 ]]; then
echo " ✅ No new unblocks — protection check passed."
return 0
fi
echo ""
echo "============================================================"
echo " ❌ INSTALLATION BLOCKED — NEW UNBLOCK ENTRIES DETECTED"
echo "============================================================"
echo ""
echo "You are attempting to WHITELIST these additional domains:"
while IFS= read -r entry; do
echo " + $entry"
done <<<"$added_entries"
echo ""
echo "To proceed, manually delete the state file first:"
echo " sudo chattr -i $UNBLOCK_STATE_FILE && sudo rm $UNBLOCK_STATE_FILE"
echo ""
return 1
}
# Run the unblock protection check
if ! check_unblock_entries_protection; then
exit 1
fi
# 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
# ============================================================================
# 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
# Source and local cache configuration
URL="https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-gambling-porn-social/hosts"
# Cache stores the RAW upstream file (without our custom modifications)
LOCAL_CACHE="/etc/hosts.stevenblack"
# Helpers
extract_date_epoch_from_file() {
# Grep "# Date:" line and convert to epoch seconds (UTC)
local f="$1"
local line
line=$(grep -m1 '^# Date:' "$f" 2>/dev/null | sed -E 's/^# Date:[[:space:]]*(.*)[[:space:]]*\(UTC\).*/\1 UTC/')
if [[ -n $line ]]; then
date -u -d "$line" +%s 2>/dev/null || echo ""
else
echo ""
fi
}
fetch_remote_header() {
# Try to fetch only the first ~4KB using HTTP Range; fallback to piping to head
local out="$1"
if curl -LfsS --max-time 10 -H 'Range: bytes=0-4095' "$URL" -o "$out"; then
return 0
fi
# Fallback – may download more, but we only keep first lines
if curl -LfsS --max-time 10 "$URL" | head -n 20 >"$out"; then
return 0
fi
return 1
}
download_remote_full_to() {
local out="$1"
curl -LfsS "$URL" -o "$out"
}
# Decide whether to use cache or update
TMP_REMOTE_HEAD=$(mktemp)
trap 'rm -f "$TMP_REMOTE_HEAD"' EXIT
REMOTE_AVAILABLE=0
if fetch_remote_header "$TMP_REMOTE_HEAD"; then
REMOTE_AVAILABLE=1
fi
NEED_UPDATE=0
if [[ -f $LOCAL_CACHE ]]; then
local_epoch=$(extract_date_epoch_from_file "$LOCAL_CACHE")
else
local_epoch=""
fi
if [[ $REMOTE_AVAILABLE -eq 1 ]]; then
remote_epoch=$(extract_date_epoch_from_file "$TMP_REMOTE_HEAD")
if [[ -n $local_epoch && -n $remote_epoch && $local_epoch -ge $remote_epoch ]]; then
echo "Using cached StevenBlack hosts (up-to-date)."
else
echo "Cached version is missing or outdated; downloading latest StevenBlack hosts..."
NEED_UPDATE=1
fi
else
if [[ -f $LOCAL_CACHE ]]; then
echo "No internet; using cached StevenBlack hosts."
else
echo "Error: No internet and no cached StevenBlack hosts found." >&2
exit 1
fi
fi
# Ensure we have a fresh cache if needed
if [[ $NEED_UPDATE -eq 1 ]]; then
TMP_DL=$(mktemp)
if download_remote_full_to "$TMP_DL"; then
# Save raw upstream to cache
sudo mv "$TMP_DL" "$LOCAL_CACHE"
sudo chmod 644 "$LOCAL_CACHE"
echo "Saved latest StevenBlack hosts to cache: $LOCAL_CACHE"
else
rm -f "$TMP_DL"
echo "Error: Failed to download latest StevenBlack hosts." >&2
exit 1
fi
fi
# Install the base hosts from cache into /etc/hosts
echo "Installing base hosts from cache to /etc/hosts..."
sudo cp "$LOCAL_CACHE" /etc/hosts
# Comment out any 4chan blocking entries from the downloaded file
echo "Allowing 4chan by commenting out any blocking entries..."
sudo sed -i 's/^0\.0\.0\.0 4chan\.com/#0.0.0.0 4chan.com/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 www\.4chan\.com/#0.0.0.0 www.4chan.com/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 4chan\.org/#0.0.0.0 4chan.org/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 boards\.4chan\.org/#0.0.0.0 boards.4chan.org/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 sys\.4chan\.org/#0.0.0.0 sys.4chan.org/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 www\.4chan\.org/#0.0.0.0 www.4chan.org/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?facebook\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?messenger\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?fbcdn\.net)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?facebook\.net)/#\1/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 delio\.com.pl/#0.0.0.0 delio.com.pl/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 loverslab\.com/#0.0.0.0 loverslab.com/' /etc/hosts
# Allow LinkedIn and all subdomains (linkedin.com + licdn.com CDN)
echo "Allowing LinkedIn by commenting out any blocking entries..."
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?linkedin\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?licdn\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?loverslab\.com)/#\1/' /etc/hosts
# Add custom entries for YouTube and Discord
echo "Adding custom entries for YouTube and Discord..."
tee -a /etc/hosts >/dev/null <<'EOF'
# Custom blocking entries
# YouTube
0.0.0.0 youtube.com
0.0.0.0 www.youtube.com
0.0.0.0 m.youtube.com
0.0.0.0 youtu.be
0.0.0.0 youtube-nocookie.com
0.0.0.0 www.youtube-nocookie.com
0.0.0.0 youtubei.googleapis.com
0.0.0.0 youtube.googleapis.com
0.0.0.0 yt3.ggpht.com
# Channel avatars and banners. Same role as yt3.ggpht.com, which was already
# here -- Google serves them from both hosts, so blocking one leaves the other
# resolving. Caught when the phone's DoT resolver was checked domain by domain
# against the 14 the focus policy names.
0.0.0.0 yt3.googleusercontent.com
0.0.0.0 ytimg.com
0.0.0.0 i.ytimg.com
0.0.0.0 s.ytimg.com
0.0.0.0 i9.ytimg.com
0.0.0.0 googlevideo.com
0.0.0.0 r1---sn-4g5e6nls.googlevideo.com
0.0.0.0 r1---sn-4g5lne7s.googlevideo.com
# Alternative YouTube Frontends (Invidious, Piped, etc.)
0.0.0.0 invidious.io
0.0.0.0 www.invidious.io
0.0.0.0 invidio.us
0.0.0.0 vid.puffyan.us
0.0.0.0 invidious.snopyta.org
0.0.0.0 yewtu.be
0.0.0.0 invidious.kavin.rocks
0.0.0.0 inv.riverside.rocks
0.0.0.0 invidious.namazso.eu
0.0.0.0 invidious.nerdvpn.de
0.0.0.0 invidious.projectsegfau.lt
0.0.0.0 invidious.slipfox.xyz
0.0.0.0 invidious.privacydev.net
0.0.0.0 invidious.perennialte.ch
0.0.0.0 invidious.protokoll-11.de
0.0.0.0 invidious.einfachzocken.eu
0.0.0.0 invidious.fdn.fr
0.0.0.0 inv.in.projectsegfau.lt
0.0.0.0 invidious.tiekoetter.com
0.0.0.0 invidious.lunar.icu
0.0.0.0 iv.ggtyler.dev
0.0.0.0 iv.melmac.space
0.0.0.0 piped.video
0.0.0.0 www.piped.video
0.0.0.0 piped.kavin.rocks
0.0.0.0 piped.mha.fi
0.0.0.0 piped.mint.lgbt
0.0.0.0 piped.projectsegfau.lt
0.0.0.0 piped.privacydev.net
0.0.0.0 piped.smnz.de
0.0.0.0 piped.adminforge.de
0.0.0.0 watch.whatever.social
0.0.0.0 piped.lunar.icu
0.0.0.0 viewtube.io
0.0.0.0 www.viewtube.io
0.0.0.0 freetube.io
0.0.0.0 www.freetube.io
0.0.0.0 tubo.media
0.0.0.0 www.tubo.media
0.0.0.0 materialious.nadeko.net
0.0.0.0 clipious.org
0.0.0.0 www.clipious.org
0.0.0.0 newpipe.net
0.0.0.0 www.newpipe.net
0.0.0.0 newpipe.schabi.org
0.0.0.0 grayjay.app
0.0.0.0 www.grayjay.app
0.0.0.0 libretube.dev
0.0.0.0 www.libretube.dev
0.0.0.0 hyperion.deishelon.com
0.0.0.0 inv.n8pjl.ca
0.0.0.0 inv.zzls.xyz
0.0.0.0 inv.tux.pizza
0.0.0.0 invidious.incogniweb.net
0.0.0.0 invidious.drgns.space
0.0.0.0 invidious.io.lol
# Steam Store
0.0.0.0 store.steampowered.com
0.0.0.0 checkout.steampowered.com
0.0.0.0 store.akamai.steamstatic.com
0.0.0.0 storefront.steampowered.com
0.0.0.0 store.cloudflare.steamstatic.com
# Discord - media allowed
# 0.0.0.0 cdn.discordapp.com
# 0.0.0.0 media.discordapp.net
# 0.0.0.0 images-ext-1.discordapp.net
# 0.0.0.0 images-ext-2.discordapp.net
# 0.0.0.0 attachments-1.discordapp.net
# 0.0.0.0 attachments-2.discordapp.net
# 0.0.0.0 tenor.com
# 0.0.0.0 giphy.com
# Food Delivery Services
# Polish services
0.0.0.0 pyszne.pl
0.0.0.0 www.pyszne.pl
0.0.0.0 m.pyszne.pl
0.0.0.0 api.pyszne.pl
0.0.0.0 app.pyszne.pl
0.0.0.0 glovo.com
0.0.0.0 www.glovo.com
0.0.0.0 m.glovo.com
0.0.0.0 api.glovo.com
0.0.0.0 glovoapp.com
0.0.0.0 www.glovoapp.com
0.0.0.0 bolt.eu
:: bolt.eu
0.0.0.0 www.bolt.eu
:: www.bolt.eu
0.0.0.0 food.bolt.eu
:: food.bolt.eu
0.0.0.0 m.bolt.eu
:: m.bolt.eu
0.0.0.0 api.bolt.eu
:: api.bolt.eu
0.0.0.0 node.bolt.eu
:: node.bolt.eu
0.0.0.0 gw.bolt.eu
:: gw.bolt.eu
0.0.0.0 client-api.bolt.eu
:: client-api.bolt.eu
0.0.0.0 auth.bolt.eu
:: auth.bolt.eu
0.0.0.0 cdn.bolt.eu
:: cdn.bolt.eu
0.0.0.0 images.bolt.eu
:: images.bolt.eu
0.0.0.0 static.bolt.eu
:: static.bolt.eu
0.0.0.0 assets.bolt.eu
:: assets.bolt.eu
0.0.0.0 fleet.bolt.eu
:: fleet.bolt.eu
0.0.0.0 user.bolt.eu
:: user.bolt.eu
0.0.0.0 courier.bolt.eu
:: courier.bolt.eu
0.0.0.0 rider.bolt.eu
:: rider.bolt.eu
0.0.0.0 restaurant.bolt.eu
:: restaurant.bolt.eu
0.0.0.0 partner-food.bolt.eu
:: partner-food.bolt.eu
0.0.0.0 woltwojta.pl
0.0.0.0 www.woltwojta.pl
0.0.0.0 wolt.com
0.0.0.0 www.wolt.com
0.0.0.0 m.wolt.com
0.0.0.0 api.wolt.com
0.0.0.0 restaurant-api.wolt.com
0.0.0.0 consumer-api.wolt.com
0.0.0.0 jush.pl
0.0.0.0 www.jush.pl
0.0.0.0 m.jush.pl
0.0.0.0 api.jush.pl
0.0.0.0 delio.pl
:: delio.pl
0.0.0.0 www.delio.pl
:: www.delio.pl
0.0.0.0 m.delio.pl
:: m.delio.pl
0.0.0.0 api.delio.pl
:: api.delio.pl
0.0.0.0 app.delio.pl
:: app.delio.pl
0.0.0.0 cdn.delio.pl
:: cdn.delio.pl
0.0.0.0 static.delio.pl
:: static.delio.pl
0.0.0.0 order.delio.pl
:: order.delio.pl
0.0.0.0 delio.com
:: delio.com
0.0.0.0 www.delio.com
:: www.delio.com
0.0.0.0 m.delio.com
:: m.delio.com
0.0.0.0 api.delio.com
:: api.delio.com
0.0.0.0 app.delio.com
:: app.delio.com
0.0.0.0 delio.com.pl
:: delio.com.pl
0.0.0.0 www.delio.com.pl
:: www.delio.com.pl
0.0.0.0 m.delio.com.pl
:: m.delio.com.pl
0.0.0.0 api.delio.com.pl
:: api.delio.com.pl
0.0.0.0 app.delio.com.pl
:: app.delio.com.pl
0.0.0.0 lisek.app
0.0.0.0 www.lisek.app
0.0.0.0 api.lisek.app
0.0.0.0 stava.app
0.0.0.0 www.stava.app
0.0.0.0 api.stava.app
0.0.0.0 biedronka.pl
0.0.0.0 zakupy.biedronka.pl
0.0.0.0 ezakupy.tesco.pl
0.0.0.0 www.ezakupy.tesco.pl
0.0.0.0 barbora.pl
0.0.0.0 www.barbora.pl
0.0.0.0 api.barbora.pl
0.0.0.0 frisco.pl
0.0.0.0 www.frisco.pl
0.0.0.0 api.frisco.pl
0.0.0.0 swiatkwiatow.pl
0.0.0.0 www.swiatkwiatow.pl
0.0.0.0 allegro.pl/kategoria/jedzenie
0.0.0.0 szama.pl
0.0.0.0 www.szama.pl
0.0.0.0 api.szama.pl
0.0.0.0 auchandirect.pl
0.0.0.0 www.auchandirect.pl
# International services
0.0.0.0 ubereats.com
0.0.0.0 www.ubereats.com
0.0.0.0 m.ubereats.com
0.0.0.0 api.ubereats.com
0.0.0.0 uber.com
0.0.0.0 www.uber.com
0.0.0.0 m.uber.com
0.0.0.0 api.uber.com
0.0.0.0 cn-geo1.uber.com
0.0.0.0 login.uber.com
0.0.0.0 auth.uber.com
0.0.0.0 riders.uber.com
0.0.0.0 deliveroo.com
0.0.0.0 www.deliveroo.com
0.0.0.0 m.deliveroo.com
0.0.0.0 api.deliveroo.com
0.0.0.0 deliveroo.co.uk
0.0.0.0 www.deliveroo.co.uk
0.0.0.0 foodpanda.com
0.0.0.0 www.foodpanda.com
0.0.0.0 m.foodpanda.com
0.0.0.0 api.foodpanda.com
0.0.0.0 grubhub.com
0.0.0.0 www.grubhub.com
0.0.0.0 m.grubhub.com
0.0.0.0 api.grubhub.com
0.0.0.0 doordash.com
0.0.0.0 www.doordash.com
0.0.0.0 m.doordash.com
0.0.0.0 api.doordash.com
0.0.0.0 justeat.com
0.0.0.0 www.justeat.com
0.0.0.0 m.justeat.com
0.0.0.0 justeat.co.uk
0.0.0.0 www.justeat.co.uk
0.0.0.0 postmates.com
0.0.0.0 www.postmates.com
0.0.0.0 api.postmates.com
0.0.0.0 seamless.com
0.0.0.0 www.seamless.com
0.0.0.0 menulog.com.au
0.0.0.0 www.menulog.com.au
0.0.0.0 delivery.com
0.0.0.0 www.delivery.com
0.0.0.0 getir.com
0.0.0.0 www.getir.com
0.0.0.0 api.getir.com
0.0.0.0 flink.com
0.0.0.0 www.flink.com
0.0.0.0 api.flink.com
0.0.0.0 gorillas.io
0.0.0.0 www.gorillas.io
0.0.0.0 api.gorillas.io
0.0.0.0 gopuff.com
0.0.0.0 www.gopuff.com
0.0.0.0 api.gopuff.com
0.0.0.0 instacart.com
0.0.0.0 www.instacart.com
0.0.0.0 api.instacart.com
0.0.0.0 takeaway.com
0.0.0.0 www.takeaway.com
0.0.0.0 api.takeaway.com
# Fast food chain apps and websites
0.0.0.0 mcdonalds.com
0.0.0.0 www.mcdonalds.com
0.0.0.0 m.mcdonalds.com
0.0.0.0 mcdonalds.pl
0.0.0.0 www.mcdonalds.pl
0.0.0.0 kfc.com
0.0.0.0 www.kfc.com
0.0.0.0 m.kfc.com
0.0.0.0 kfc.pl
0.0.0.0 www.kfc.pl
0.0.0.0 burgerking.com
0.0.0.0 www.burgerking.com
0.0.0.0 m.burgerking.com
0.0.0.0 burgerking.pl
0.0.0.0 www.burgerking.pl
0.0.0.0 pizzahut.com
0.0.0.0 www.pizzahut.com
0.0.0.0 m.pizzahut.com
0.0.0.0 pizzahut.pl
0.0.0.0 www.pizzahut.pl
0.0.0.0 dominos.com
0.0.0.0 www.dominos.com
0.0.0.0 m.dominos.com
0.0.0.0 dominos.pl
0.0.0.0 www.dominos.pl
0.0.0.0 subway.com
0.0.0.0 www.subway.com
0.0.0.0 m.subway.com
0.0.0.0 subway.pl
0.0.0.0 www.subway.pl
EOF
# Set proper permissions (readable by all, writable only by root)
sudo chmod 644 /etc/hosts
# Make the file immutable and append-only for maximum protection
sudo chattr +ia /etc/hosts
# ============================================================================
# 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
# ============================================================================
# SAVE CUSTOM ENTRIES STATE FOR FUTURE PROTECTION CHECKS
# ============================================================================
echo "Saving custom entries state for protection mechanism..."
script_path="$(readlink -f "$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
# ============================================================================
# DISABLE DNS OVER HTTPS (DoH) IN BROWSERS
# ============================================================================
# DoH bypasses /etc/hosts entirely, defeating all our blocking!
# We disable it in Firefox profiles for all users.
echo ""
echo "Disabling DNS over HTTPS (DoH) in browsers..."
# Get the actual user (not root) who invoked this script
REAL_USER="${SUDO_USER:-$USER}"
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
# Firefox: disable DoH via user.js
if [[ -d "$REAL_HOME/.mozilla/firefox" ]]; then
for profile in "$REAL_HOME/.mozilla/firefox"/*.default*; do
if [[ -d "$profile" ]]; then
cat >>"$profile/user.js" <<'FIREFOXEOF'
// Disable DNS over HTTPS (DoH) to ensure /etc/hosts blocking works
// Added by linux-configuration hosts installer
user_pref("network.trr.mode", 5); // 5 = Off by user choice
user_pref("doh-rollout.enabled", false);
user_pref("doh-rollout.disable-heuristics", true);
FIREFOXEOF
chown "$REAL_USER:$REAL_USER" "$profile/user.js"
echo " Firefox DoH disabled in: $(basename "$profile")"
fi
done
else
echo " No Firefox profiles found"
fi
# Chromium-based browsers: use policy file
CHROME_POLICY_DIR="/etc/chromium/policies/managed"
if [[ -d "/etc/chromium" ]] || command -v chromium &>/dev/null; then
mkdir -p "$CHROME_POLICY_DIR"
cat >"$CHROME_POLICY_DIR/disable-doh.json" <<'CHROMEEOF'
{
"DnsOverHttpsMode": "off",
"BuiltInDnsClientEnabled": false
}
CHROMEEOF
echo " Chromium DoH disabled via policy"
fi
# Google Chrome policy
GCHROME_POLICY_DIR="/etc/opt/chrome/policies/managed"
if [[ -d "/etc/opt/chrome" ]] || command -v google-chrome &>/dev/null; then
mkdir -p "$GCHROME_POLICY_DIR"
cat >"$GCHROME_POLICY_DIR/disable-doh.json" <<'GCHROMEEOF'
{
"DnsOverHttpsMode": "off",
"BuiltInDnsClientEnabled": false
}
GCHROMEEOF
echo " Google Chrome DoH disabled via policy"
fi
echo ""
echo "✅ Installation complete!"
echo " Custom entries protection is now active."
echo " Removing blocked entries from the script will be blocked."
echo " DNS over HTTPS (DoH) has been disabled in browsers."
# ============================================================================
# FORCE BROWSER RESTART TO APPLY DOH CHANGES
# ============================================================================
# Kill all browser processes so DoH changes take effect immediately
echo ""
echo "Killing browsers to apply DoH policy changes..."
BROWSERS_KILLED=0
for browser in chrome chromium chromium-browser brave brave-browser firefox firefox-esr thorium vivaldi opera; do
if pgrep -x "$browser" &>/dev/null || pgrep -f "/opt/.*/$browser" &>/dev/null; then
echo " Killing $browser..."
pkill -9 -f "$browser" 2>/dev/null || true
BROWSERS_KILLED=1
fi
done
# Also kill by common binary paths
for pattern in "/opt/google/chrome" "/opt/brave" "/opt/thorium" "/usr/lib/firefox" "/usr/lib/chromium"; do
if pgrep -f "$pattern" &>/dev/null; then
echo " Killing processes matching $pattern..."
pkill -9 -f "$pattern" 2>/dev/null || true
BROWSERS_KILLED=1
fi
done
if [[ $BROWSERS_KILLED -eq 1 ]]; then
echo ""
echo "⚠️ Browsers were killed to apply DNS settings."
echo " Reopen your browser - hosts blocking is now enforced."
else
echo " No browsers were running."
fi
# ============================================================================
# NOTE: this used to `chattr +i` its own source and generate_hosts_file.sh
# ("lock against silent edits"). Do NOT reintroduce that: making a git-tracked
# file immutable breaks git tooling. `pre-commit run --all-files` (which the
@ -911,3 +112,19 @@ fi
# changes nothing until install.sh is re-run as root, which regenerates the
# guarded artifacts.
# ============================================================================
# ============================================================================
# MAIN
# ============================================================================
# The phases above are defined in the order they run, and run here in that same
# order. Split out of a single top-level block so the file fits the 250-line
# cap; the sequence is unchanged, including the guard being taken down before
# the write and restarted immediately after it.
enable_resolved_reads_hosts
stop_hosts_guard
refresh_upstream_cache
write_hosts_file
restart_hosts_guard
save_protection_state
disable_browser_doh
restart_browsers

View File

@ -0,0 +1,112 @@
#!/usr/bin/env bash
# lib/hosts_browser_doh.sh — stop browsers resolving around /etc/hosts.
#
# DNS-over-HTTPS resolves names over the browser's own encrypted channel and
# never consults /etc/hosts, so leaving it on defeats every entry this script
# installs. Sourced by install.sh.
# Turn off DNS-over-HTTPS in every installed browser. DoH resolves names
# over the browser's own encrypted channel, bypassing /etc/hosts completely.
disable_browser_doh() {
# DISABLE DNS OVER HTTPS (DoH) IN BROWSERS
# ============================================================================
# DoH bypasses /etc/hosts entirely, defeating all our blocking!
# We disable it in Firefox profiles for all users.
echo ""
echo "Disabling DNS over HTTPS (DoH) in browsers..."
# Get the actual user (not root) who invoked this script
REAL_USER="${SUDO_USER:-$USER}"
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
# Firefox: disable DoH via user.js
if [[ -d "$REAL_HOME/.mozilla/firefox" ]]; then
for profile in "$REAL_HOME/.mozilla/firefox"/*.default*; do
if [[ -d "$profile" ]]; then
cat >>"$profile/user.js" <<'FIREFOXEOF'
// Disable DNS over HTTPS (DoH) to ensure /etc/hosts blocking works
// Added by linux-configuration hosts installer
user_pref("network.trr.mode", 5); // 5 = Off by user choice
user_pref("doh-rollout.enabled", false);
user_pref("doh-rollout.disable-heuristics", true);
FIREFOXEOF
chown "$REAL_USER:$REAL_USER" "$profile/user.js"
echo " Firefox DoH disabled in: $(basename "$profile")"
fi
done
else
echo " No Firefox profiles found"
fi
# Chromium-based browsers: use policy file
CHROME_POLICY_DIR="/etc/chromium/policies/managed"
if [[ -d "/etc/chromium" ]] || command -v chromium &>/dev/null; then
mkdir -p "$CHROME_POLICY_DIR"
cat >"$CHROME_POLICY_DIR/disable-doh.json" <<'CHROMEEOF'
{
"DnsOverHttpsMode": "off",
"BuiltInDnsClientEnabled": false
}
CHROMEEOF
echo " Chromium DoH disabled via policy"
fi
# Google Chrome policy
GCHROME_POLICY_DIR="/etc/opt/chrome/policies/managed"
if [[ -d "/etc/opt/chrome" ]] || command -v google-chrome &>/dev/null; then
mkdir -p "$GCHROME_POLICY_DIR"
cat >"$GCHROME_POLICY_DIR/disable-doh.json" <<'GCHROMEEOF'
{
"DnsOverHttpsMode": "off",
"BuiltInDnsClientEnabled": false
}
GCHROMEEOF
echo " Google Chrome DoH disabled via policy"
fi
echo ""
echo "✅ Installation complete!"
echo " Custom entries protection is now active."
echo " Removing blocked entries from the script will be blocked."
echo " DNS over HTTPS (DoH) has been disabled in browsers."
# ============================================================================
}
# Kill running browsers so the DoH policy changes take effect now rather
# than at the user's next restart.
restart_browsers() {
# FORCE BROWSER RESTART TO APPLY DOH CHANGES
# ============================================================================
# Kill all browser processes so DoH changes take effect immediately
echo ""
echo "Killing browsers to apply DoH policy changes..."
BROWSERS_KILLED=0
for browser in chrome chromium chromium-browser brave brave-browser firefox firefox-esr thorium vivaldi opera; do
if pgrep -x "$browser" &>/dev/null || pgrep -f "/opt/.*/$browser" &>/dev/null; then
echo " Killing $browser..."
pkill -9 -f "$browser" 2>/dev/null || true
BROWSERS_KILLED=1
fi
done
# Also kill by common binary paths
for pattern in "/opt/google/chrome" "/opt/brave" "/opt/thorium" "/usr/lib/firefox" "/usr/lib/chromium"; do
if pgrep -f "$pattern" &>/dev/null; then
echo " Killing processes matching $pattern..."
pkill -9 -f "$pattern" 2>/dev/null || true
BROWSERS_KILLED=1
fi
done
if [[ $BROWSERS_KILLED -eq 1 ]]; then
echo ""
echo "⚠️ Browsers were killed to apply DNS settings."
echo " Reopen your browser - hosts blocking is now enforced."
else
echo " No browsers were running."
fi
# ============================================================================
}

View File

@ -0,0 +1,91 @@
#!/usr/bin/env bash
# lib/hosts_cache.sh — keep the local copy of the upstream StevenBlack list
# current.
#
# The cache holds the RAW upstream file, without our own entries, so a refresh
# never has to re-derive what we added. Sourced by install.sh.
# Helpers
extract_date_epoch_from_file() {
# Grep "# Date:" line and convert to epoch seconds (UTC)
local f="$1"
local line
line=$(grep -m1 '^# Date:' "$f" 2>/dev/null | sed -E 's/^# Date:[[:space:]]*(.*)[[:space:]]*\(UTC\).*/\1 UTC/')
if [[ -n $line ]]; then
date -u -d "$line" +%s 2>/dev/null || echo ""
else
echo ""
fi
}
fetch_remote_header() {
# Try to fetch only the first ~4KB using HTTP Range; fallback to piping to head
local out="$1"
if curl -LfsS --max-time 10 -H 'Range: bytes=0-4095' "$URL" -o "$out"; then
return 0
fi
# Fallback – may download more, but we only keep first lines
if curl -LfsS --max-time 10 "$URL" | head -n 20 >"$out"; then
return 0
fi
return 1
}
download_remote_full_to() {
local out="$1"
curl -LfsS "$URL" -o "$out"
}
# Decide whether the cached upstream blocklist is current, and re-download
# it if not. The cache holds the RAW upstream file, without our additions.
refresh_upstream_cache() {
# Decide whether to use cache or update
TMP_REMOTE_HEAD=$(mktemp)
trap 'rm -f "$TMP_REMOTE_HEAD"' EXIT
REMOTE_AVAILABLE=0
if fetch_remote_header "$TMP_REMOTE_HEAD"; then
REMOTE_AVAILABLE=1
fi
NEED_UPDATE=0
if [[ -f $LOCAL_CACHE ]]; then
local_epoch=$(extract_date_epoch_from_file "$LOCAL_CACHE")
else
local_epoch=""
fi
if [[ $REMOTE_AVAILABLE -eq 1 ]]; then
remote_epoch=$(extract_date_epoch_from_file "$TMP_REMOTE_HEAD")
if [[ -n $local_epoch && -n $remote_epoch && $local_epoch -ge $remote_epoch ]]; then
echo "Using cached StevenBlack hosts (up-to-date)."
else
echo "Cached version is missing or outdated; downloading latest StevenBlack hosts..."
NEED_UPDATE=1
fi
else
if [[ -f $LOCAL_CACHE ]]; then
echo "No internet; using cached StevenBlack hosts."
else
echo "Error: No internet and no cached StevenBlack hosts found." >&2
exit 1
fi
fi
# Ensure we have a fresh cache if needed
if [[ $NEED_UPDATE -eq 1 ]]; then
TMP_DL=$(mktemp)
if download_remote_full_to "$TMP_DL"; then
# Save raw upstream to cache
sudo mv "$TMP_DL" "$LOCAL_CACHE"
sudo chmod 644 "$LOCAL_CACHE"
echo "Saved latest StevenBlack hosts to cache: $LOCAL_CACHE"
else
rm -f "$TMP_DL"
echo "Error: Failed to download latest StevenBlack hosts." >&2
exit 1
fi
fi
}

View File

@ -0,0 +1,125 @@
#!/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
# ============================================================================
}

View File

@ -0,0 +1,146 @@
#!/usr/bin/env bash
# lib/hosts_protect_custom.sh — the anti-tamper guard on the CUSTOM blocking
# entries.
#
# Refuses the install when the custom block list has shrunk against the state
# file saved by the previous run, so entries cannot be quietly dropped by
# editing this repo. Sourced by install.sh.
# Extract custom blocked entries from a hosts file or heredoc section
# Returns only the "0.0.0.0 domain.com" lines (normalized, sorted, unique)
# The custom blocking entries used to live in a heredoc inside install.sh, and
# this read them by slicing between "# Custom blocking entries" and the heredoc
# terminator. They are now a data file beside it, so the whole file is the list
# and no slicing is needed.
#
# The argument is still the script path, because that is what every caller has
# and the data file sits at a known place relative to it. Passing a path with no
# data file beside it yields an empty list, which check_custom_entries_protection
# treats as "everything was removed" and refuses -- the safe direction.
extract_custom_entries_from_script() {
local script_path="$1"
local entries_file
entries_file="$(dirname "$script_path")/custom_entries.hosts"
[[ -f $entries_file ]] || return 0
grep -E '^0\.0\.0\.0[[:space:]]+' "$entries_file" |
awk '{print $2}' |
sort -u
}
# Extract custom entries from the current /etc/hosts (entries after "# Custom blocking entries" marker)
extract_custom_entries_from_hosts() {
local hosts_file="$1"
if [[ ! -f $hosts_file ]]; then
return
fi
sed -n '/^# Custom blocking entries$/,$p' "$hosts_file" |
grep -E '^0\.0\.0\.0[[:space:]]+' |
awk '{print $2}' |
sort -u
}
# Load previously saved custom entries state
load_saved_custom_entries() {
if [[ -f $CUSTOM_ENTRIES_STATE_FILE ]]; then
sort -u "$CUSTOM_ENTRIES_STATE_FILE"
fi
}
# Save current custom entries to state file
save_custom_entries_state() {
local entries="$1"
echo "$entries" | sort -u >"$CUSTOM_ENTRIES_STATE_FILE"
chmod 644 "$CUSTOM_ENTRIES_STATE_FILE"
chattr +i "$CUSTOM_ENTRIES_STATE_FILE" 2>/dev/null || true
}
# Helper function to count non-empty lines
count_lines() {
local input="$1"
if [[ -z $input ]]; then
echo 0
else
echo "$input" | grep -c . 2>/dev/null || echo 0
fi
}
# Main protection check
check_custom_entries_protection() {
local script_path
# 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}")"
# Get new entries from the script's heredoc
local new_entries
new_entries=$(extract_custom_entries_from_script "$script_path")
local new_count
new_count=$(count_lines "$new_entries")
# Get saved/existing entries (prefer state file, fall back to current /etc/hosts)
local saved_entries
saved_entries=$(load_saved_custom_entries)
if [[ -z $saved_entries ]]; then
# First run or state file missing - extract from current /etc/hosts if it has our marker
# The live hosts file, overridable so a test can supply a fixture; this
# is a read, never a write.
saved_entries=$(extract_custom_entries_from_hosts "${HOSTS_FILE_PATH:-/etc/hosts}")
fi
local saved_count
saved_count=$(count_lines "$saved_entries")
# If no saved state exists, this is first installation - allow it
if [[ $saved_count -eq 0 ]]; then
echo "ℹ️ First installation detected - no protection check needed."
return 0
fi
# Find entries that were removed
local removed_entries
removed_entries=$(comm -23 <(echo "$saved_entries") <(echo "$new_entries"))
local removed_count
removed_count=$(count_lines "$removed_entries")
# Find entries that are new
local added_entries
added_entries=$(comm -13 <(echo "$saved_entries") <(echo "$new_entries"))
local added_count
added_count=$(count_lines "$added_entries")
echo ""
echo "📊 Custom Entries Protection Check:"
echo " Previously blocked: $saved_count entries"
echo " Currently in script: $new_count entries"
echo " Removed: $removed_count | Added: $added_count"
# RULE 1: No entries removed - always OK
if [[ $removed_count -eq 0 ]]; then
echo " ✅ No entries removed - protection check passed."
return 0
fi
# RULE 2: Entries were removed - BLOCK INSTALLATION
echo ""
echo "============================================================"
echo " ❌ INSTALLATION BLOCKED - CUSTOM ENTRIES REMOVED"
echo "============================================================"
echo ""
echo "You are attempting to REMOVE the following blocked entries:"
while IFS= read -r entry; do
echo " - $entry"
done <<<"$removed_entries"
echo ""
echo "This is NOT allowed. The only way to unblock sites is to:"
echo ""
echo " 1. Manually edit /etc/hosts (requires removing chattr protection)"
echo " 2. Delete the state file /etc/hosts.custom-entries.state"
echo " (also protected with chattr)"
echo ""
echo "These manual steps are intentionally difficult to prevent"
echo "impulsive unblocking. If you really need to unblock something,"
echo "you'll have to work for it."
echo ""
return 1
}

View File

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# lib/hosts_protect_unblock.sh — the anti-tamper guard on the UNBLOCK list.
#
# The mirror of hosts_protect_custom.sh: it refuses the install when the
# whitelist has GROWN, so domains cannot be quietly un-blocked. Sourced by
# install.sh.
# Extract whitelisted domains from the protected list embedded in this script
extract_unblock_entries_from_script() {
local script_path="$1"
# The two marker lines match the domain pattern themselves -- they are
# word characters and underscores -- so a plain grep pulled
# PROTECTED_UNBLOCK_LIST_START and _END into the whitelist as if they were
# domains. They then went into the saved state, and renaming either marker
# would have read as the whitelist growing and blocked the install. Drop
# them explicitly rather than relying on the pattern to exclude them.
sed -n '/^# PROTECTED_UNBLOCK_LIST_START$/,/^# PROTECTED_UNBLOCK_LIST_END$/p' "$script_path" |
grep -E '^# [a-zA-Z0-9._-]+$' |
sed 's/^# //' |
grep -vxE 'PROTECTED_UNBLOCK_LIST_(START|END)' |
sort -u
}
# Save current unblock entries to immutable state file
save_unblock_entries_state() {
local entries="$1"
chattr -i "$UNBLOCK_STATE_FILE" 2>/dev/null || true
echo "$entries" | sort -u >"$UNBLOCK_STATE_FILE"
chmod 644 "$UNBLOCK_STATE_FILE"
chattr +i "$UNBLOCK_STATE_FILE" 2>/dev/null || true
}
# Block installation if the unblock list has grown (more sites being whitelisted)
check_unblock_entries_protection() {
local script_path
# 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}")"
local new_entries
new_entries=$(extract_unblock_entries_from_script "$script_path")
local new_count
new_count=$(count_lines "$new_entries")
if [[ ! -f $UNBLOCK_STATE_FILE ]]; then
echo "ℹ️ First unblock-list run — no protection check needed."
return 0
fi
local saved_entries
saved_entries=$(sort -u "$UNBLOCK_STATE_FILE")
local saved_count
saved_count=$(count_lines "$saved_entries")
# Entries added since last install
local added_entries
added_entries=$(comm -13 <(echo "$saved_entries") <(echo "$new_entries"))
local added_count
added_count=$(count_lines "$added_entries")
echo ""
echo "📊 Unblock Entries Protection Check:"
echo " Previously whitelisted: $saved_count domains"
echo " Currently in script: $new_count domains"
echo " Newly added: $added_count"
if [[ $added_count -eq 0 ]]; then
echo " ✅ No new unblocks — protection check passed."
return 0
fi
echo ""
echo "============================================================"
echo " ❌ INSTALLATION BLOCKED — NEW UNBLOCK ENTRIES DETECTED"
echo "============================================================"
echo ""
echo "You are attempting to WHITELIST these additional domains:"
while IFS= read -r entry; do
echo " + $entry"
done <<<"$added_entries"
echo ""
echo "To proceed, manually delete the state file first:"
echo " sudo chattr -i $UNBLOCK_STATE_FILE && sudo rm $UNBLOCK_STATE_FILE"
echo ""
return 1
}

View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# lib/hosts_write.sh — write /etc/hosts.
#
# The cached upstream list, then the exceptions we comment back out, then our
# own blocking entries as one ~320-line quoted heredoc, then the permissions
# and the immutable attribute. The heredoc is a single unit: splitting its
# interior would change the file this produces. Sourced by install.sh.
# Write /etc/hosts: the cached upstream list, the per-site exceptions we
# comment back out, then our own blocking entries, and finally the
# permissions and immutable attribute.
#
# The custom entries are one ~320-line quoted heredoc and move as a single
# unit; splitting its interior would change the file this writes.
# The custom blocking entries, as data beside this lib rather than inline.
CUSTOM_ENTRIES_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/custom_entries.hosts"
write_hosts_file() {
# Install the base hosts from cache into /etc/hosts
echo "Installing base hosts from cache to /etc/hosts..."
sudo cp "$LOCAL_CACHE" /etc/hosts
# Comment out any 4chan blocking entries from the downloaded file
echo "Allowing 4chan by commenting out any blocking entries..."
sudo sed -i 's/^0\.0\.0\.0 4chan\.com/#0.0.0.0 4chan.com/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 www\.4chan\.com/#0.0.0.0 www.4chan.com/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 4chan\.org/#0.0.0.0 4chan.org/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 boards\.4chan\.org/#0.0.0.0 boards.4chan.org/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 sys\.4chan\.org/#0.0.0.0 sys.4chan.org/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 www\.4chan\.org/#0.0.0.0 www.4chan.org/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?facebook\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?messenger\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?fbcdn\.net)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?facebook\.net)/#\1/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 delio\.com.pl/#0.0.0.0 delio.com.pl/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 loverslab\.com/#0.0.0.0 loverslab.com/' /etc/hosts
# Allow LinkedIn and all subdomains (linkedin.com + licdn.com CDN)
echo "Allowing LinkedIn by commenting out any blocking entries..."
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?linkedin\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?licdn\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?loverslab\.com)/#\1/' /etc/hosts
# Add custom entries for YouTube and Discord
echo "Adding custom entries for YouTube and Discord..."
tee -a /etc/hosts >/dev/null <"$CUSTOM_ENTRIES_FILE"
# Set proper permissions (readable by all, writable only by root)
sudo chmod 644 /etc/hosts
# Make the file immutable and append-only for maximum protection
sudo chattr +ia /etc/hosts
}

View File

@ -0,0 +1,165 @@
#!/usr/bin/env bash
# lib/tests/hosts_harness.sh — shared fixture for the hosts/install.sh split.
#
# Sourced, not executed. Scope is deliberately narrow: only the two anti-tamper
# guards and their helpers are exercised here. The install phases themselves
# write /etc/hosts, unmount the guard's bind mount and restart
# systemd-resolved, so running them under test would take down the very
# protection they exist to install — they are covered by the textual
# wrap-verification in the evidence instead.
#
# The state-file paths were already variables in the pre-split script, so a
# test can point them at a tmpdir. chattr is shimmed because the real one
# cannot mark a file immutable on a tmpfs and would abort the guard.
set -euo pipefail
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1"
local got="$2"
local what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
_t_has() {
local haystack="$1"
local needle="$2"
local what="$3"
if [[ "$haystack" == *"$needle"* ]]; then
_t_pass "$what"
else
_t_fail "$what (want a substring '${needle}')"
fi
}
# The guards return 0 to allow the install and 1 to block it. Which way they
# fail is the entire point, so assert on the exit status explicitly rather than
# letting `set -e` decide.
_t_allows() {
local what="$2"
if ("$1"); then
_t_pass "$what"
else
_t_fail "$what (the guard blocked when it should have allowed)"
fi
}
_t_blocks() {
local what="$2"
if ("$1"); then
_t_fail "$what (the guard allowed when it should have blocked)"
else
_t_pass "$what"
fi
}
_t_summary() {
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
}
TEST_TMPDIR="$(mktemp -d)"
trap 'rm -rf "${TEST_TMPDIR}"' EXIT
readonly FAKE_BIN="${TEST_TMPDIR}/fake_bin"
mkdir -p "${FAKE_BIN}"
# chattr cannot set the immutable bit on a tmpfs and exits non-zero; the guards
# tolerate that with `|| true`, but shimming it keeps the test output clean and
# makes "was the state file locked" observable.
cat >"${FAKE_BIN}/chattr" <<'CHATTRSHIM'
#!/usr/bin/env bash
printf '%s\n' "chattr $*" >>"${HOSTS_TEST_CALLS}"
exit 0
CHATTRSHIM
chmod +x "${FAKE_BIN}/chattr"
export HOSTS_TEST_CALLS="${TEST_TMPDIR}/calls"
export PATH="${FAKE_BIN}:${PATH}"
# --- globals the guards read ------------------------------------------------
export CUSTOM_ENTRIES_STATE_FILE="${TEST_TMPDIR}/custom.state"
export UNBLOCK_STATE_FILE="${TEST_TMPDIR}/unblock.state"
# A fake install.sh plus the custom_entries.hosts beside it. The guards resolve
# the data file from the script path, so this reproduces the real layout.
readonly FAKE_ROOT="${TEST_TMPDIR}/root"
readonly FAKE_SCRIPT="${FAKE_ROOT}/install.sh"
# Stage the "new" entry lists the guards will read: the custom blocklist as a
# data file, and the unblock list as marked-up comments inside the script.
stage_script() { # <custom-domain>... -- <unblock-domain>...
mkdir -p "${FAKE_ROOT}"
local mode="custom"
local custom=()
local unblock=()
local arg
for arg in "$@"; do
if [[ "$arg" == "--" ]]; then
mode="unblock"
continue
fi
if [[ "$mode" == "custom" ]]; then
custom+=("$arg")
else
unblock+=("$arg")
fi
done
{
printf '# Custom blocking entries\n'
local d
for d in "${custom[@]}"; do
printf '0.0.0.0 %s\n' "$d"
done
} >"${FAKE_ROOT}/custom_entries.hosts"
{
printf '#!/bin/bash\n'
printf '# PROTECTED_UNBLOCK_LIST_START\n'
local d
for d in "${unblock[@]}"; do
printf '# %s\n' "$d"
done
printf '# PROTECTED_UNBLOCK_LIST_END\n'
} >"${FAKE_SCRIPT}"
}
# Seed the saved state the guards compare against.
stage_custom_state() {
printf '%s\n' "$@" | sort -u >"${CUSTOM_ENTRIES_STATE_FILE}"
}
stage_unblock_state() {
printf '%s\n' "$@" | sort -u >"${UNBLOCK_STATE_FILE}"
}
reset_state() {
rm -rf "${FAKE_ROOT}"
rm -f "${CUSTOM_ENTRIES_STATE_FILE}" "${UNBLOCK_STATE_FILE}"
# Markers a test sets to steer a shim (e.g. curl_fails) are part of the
# machine that test describes, so they reset too -- otherwise a failure
# marker leaks into the next group and it passes for the wrong reason.
rm -f "$(dirname "${HOSTS_TEST_CALLS}")"/curl_fails \
"$(dirname "${HOSTS_TEST_CALLS}")"/remote_body
: >"${HOSTS_TEST_CALLS}"
mkdir -p "${FAKE_ROOT}"
}
reset_state

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# lib/tests/run_all.sh — run every hosts/install.sh test in one process tree.
#
# Scope note: these cover the two anti-tamper guards and the cache helpers, not
# the install phases. The phases write /etc/hosts, unmount the guard's bind
# mount and restart systemd-resolved, so executing them under test would take
# down the protection they exist to install. They are verified textually
# instead — see the evidence artifact for this split.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
failed=0
for test_script in "${SCRIPT_DIR}"/test_hosts_*.sh; do
printf '\n### %s\n' "$(basename "$test_script")"
if ! "$test_script"; then
failed=$((failed + 1))
fi
done
if [[ $failed -gt 0 ]]; then
printf '\n%d test file(s) FAILED\n' "$failed"
exit 1
fi
printf '\nAll hosts install test files passed.\n'

View File

@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Tests for lib/hosts_cache.sh — keeping the local copy of the upstream
# StevenBlack list current.
#
# The date parsing is what decides whether a download happens at all: the
# upstream file carries a "# Date:" header, and a cache whose header is not
# older than the remote one is reused. Misreading that header means either
# re-downloading a 4 MB file on every install, or never refreshing at all.
#
# curl is shimmed, so nothing here touches the network.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=hosts_harness.sh
. "${SCRIPT_DIR}/hosts_harness.sh"
# Fake curl. `-o <file>` writes whatever $DEV/remote_body holds; a
# $DEV/curl_fails marker makes every invocation fail, which is what exercises
# the "keep using the stale cache" path.
cat >"${FAKE_BIN}/curl" <<'CURLSHIM'
#!/usr/bin/env bash
set -uo pipefail
printf '%s\n' "curl $*" >>"${HOSTS_TEST_CALLS}"
dev="$(dirname "${HOSTS_TEST_CALLS}")"
[[ -f "${dev}/curl_fails" ]] && exit 22
out=""
prev=""
for a in "$@"; do
[[ "$prev" == "-o" ]] && out="$a"
prev="$a"
done
body="${dev}/remote_body"
[[ -f "$body" ]] || : >"$body"
if [[ -n "$out" ]]; then
cat "$body" >"$out"
else
cat "$body"
fi
exit 0
CURLSHIM
chmod +x "${FAKE_BIN}/curl"
DEV="$(dirname "${HOSTS_TEST_CALLS}")"
# shellcheck source=../hosts_cache.sh
. "${SCRIPT_DIR}/../hosts_cache.sh"
# The lib reads $URL; point it somewhere obviously fake so a missing shim would
# be an immediate failure rather than a real request.
URL="http://test.invalid/hosts"
write_dated_file() { # <path> <date-header-or-empty>
{
[[ -n "${2:-}" ]] && printf '# Date: %s (UTC)\n' "$2"
printf '# Title: fake\n0.0.0.0 blocked.example\n'
} >"$1"
}
echo "== extract_date_epoch_from_file =="
reset_state
f="${TEST_TMPDIR}/dated"
write_dated_file "$f" "01 January 2026 00:00:00"
_t_eq "1767225600" "$(extract_date_epoch_from_file "$f")" "a dated header parses to epoch seconds"
write_dated_file "$f" ""
_t_eq "" "$(extract_date_epoch_from_file "$f")" "a file with no Date header yields nothing"
write_dated_file "$f" "not a real date"
_t_eq "" "$(extract_date_epoch_from_file "$f")" "an unparsable date yields nothing rather than a bad epoch"
_t_eq "" "$(extract_date_epoch_from_file "${TEST_TMPDIR}/no-such-file")" \
"a missing file yields nothing"
echo "== fetch_remote_header =="
reset_state
write_dated_file "${DEV}/remote_body" "02 February 2026 00:00:00"
out="${TEST_TMPDIR}/header"
if fetch_remote_header "$out"; then
_t_pass "a successful fetch reports success"
else
_t_fail "a successful fetch reports success"
fi
_t_eq "1769990400" "$(extract_date_epoch_from_file "$out")" "the fetched header is readable"
if grep -q 'Range: bytes=0-4095' "${HOSTS_TEST_CALLS}"; then
_t_pass "it asks for a byte range rather than the whole file"
else
_t_fail "it asks for a byte range rather than the whole file"
fi
echo "== fetch_remote_header: both attempts fail =="
reset_state
: >"${DEV}/curl_fails"
if fetch_remote_header "${TEST_TMPDIR}/header2"; then
_t_fail "a total fetch failure reports failure"
else
_t_pass "a total fetch failure reports failure"
fi
echo "== download_remote_full_to =="
reset_state
write_dated_file "${DEV}/remote_body" "03 March 2026 00:00:00"
full="${TEST_TMPDIR}/full"
if download_remote_full_to "$full"; then
_t_pass "a full download reports success"
else
_t_fail "a full download reports success"
fi
_t_eq "1772496000" "$(extract_date_epoch_from_file "$full")" "the downloaded file is the remote body"
reset_state
: >"${DEV}/curl_fails"
if download_remote_full_to "${TEST_TMPDIR}/full2"; then
_t_fail "a failed download reports failure"
else
_t_pass "a failed download reports failure"
fi
_t_summary

View File

@ -0,0 +1,168 @@
#!/usr/bin/env bash
# Tests for the two anti-tamper guards: lib/hosts_protect_custom.sh and
# lib/hosts_protect_unblock.sh.
#
# These are the highest-stakes functions in the file. They exist to stop the
# blocklist being quietly weakened by editing this repo — the custom guard
# refuses when blocked domains are REMOVED, the unblock guard refuses when the
# whitelist GROWS. A split that silently disabled either would be invisible
# until the day it mattered, so both directions of both guards get a case.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=hosts_harness.sh
. "${SCRIPT_DIR}/hosts_harness.sh"
# shellcheck source=../hosts_protect_custom.sh
. "${SCRIPT_DIR}/../hosts_protect_custom.sh"
# shellcheck source=../hosts_protect_unblock.sh
. "${SCRIPT_DIR}/../hosts_protect_unblock.sh"
export HOSTS_INSTALL_SCRIPT_PATH="${FAKE_SCRIPT}"
echo "== extract_custom_entries_from_script =="
reset_state
stage_script a.com b.com c.com -- x.com
_t_eq "a.com
b.com
c.com" "$(extract_custom_entries_from_script "${FAKE_SCRIPT}")" \
"the blocked domains are read out of the data file"
reset_state
mkdir -p "${FAKE_ROOT}"
printf '#!/bin/bash\n' >"${FAKE_SCRIPT}"
# No data file beside the script: the list reads as empty, which the guard
# treats as "everything was removed" and refuses. Failing safe is the point.
_t_eq "" "$(extract_custom_entries_from_script "${FAKE_SCRIPT}")" \
"a missing data file yields an empty list rather than an error"
echo "== extract_custom_entries_from_hosts =="
reset_state
hosts_fixture="${TEST_TMPDIR}/hosts.sample"
{
printf '127.0.0.1 localhost\n'
printf '0.0.0.0 upstream-junk.com\n'
printf '# Custom blocking entries\n'
printf '0.0.0.0 mine-one.com\n'
printf '0.0.0.0 mine-two.com\n'
} >"$hosts_fixture"
_t_eq "mine-one.com
mine-two.com" "$(extract_custom_entries_from_hosts "$hosts_fixture")" \
"only entries after the marker count as ours"
reset_state
# A machine with no /etc/hosts at all is the pre-install state; reading it must
# yield an empty list rather than an error, or the first install could not run.
_t_eq "" "$(extract_custom_entries_from_hosts "${TEST_TMPDIR}/no-such-hosts")" \
"a missing hosts file yields an empty list"
echo "== count_lines =="
_t_eq "0" "$(count_lines "")" "an empty string counts as zero, not one"
_t_eq "1" "$(count_lines "one")" "a single entry counts as one"
_t_eq "3" "$(count_lines "$(printf 'a\nb\nc')")" "three entries count as three"
echo "== load_saved_custom_entries =="
reset_state
_t_eq "" "$(load_saved_custom_entries)" "no state file reads as empty"
stage_custom_state b.com a.com
_t_eq "a.com
b.com" "$(load_saved_custom_entries)" "a state file reads back sorted"
echo "== save_custom_entries_state =="
reset_state
save_custom_entries_state "$(printf 'z.com\na.com\n')"
_t_eq "a.com
z.com" "$(cat "${CUSTOM_ENTRIES_STATE_FILE}")" "the saved state is sorted and unique"
if grep -q "chattr +i" "${HOSTS_TEST_CALLS}"; then
_t_pass "the state file is made immutable after writing"
else
_t_fail "the state file is made immutable after writing"
fi
echo "== check_custom_entries_protection: first run =="
reset_state
stage_script a.com b.com -- x.com
# No state file and nothing in /etc/hosts to fall back on means this is the
# first install, which must be allowed or the system could never be set up.
CUSTOM_ENTRIES_STATE_FILE="${TEST_TMPDIR}/nonexistent.state"
# The fallback reads the live hosts file, which on a real machine already has
# our entries; point it at an empty fixture so this is genuinely a first run.
HOSTS_FILE_PATH="${TEST_TMPDIR}/empty.hosts"
: >"${HOSTS_FILE_PATH}"
out="$(check_custom_entries_protection || true)"
_t_has "$out" "First installation" "a first run is allowed"
CUSTOM_ENTRIES_STATE_FILE="${TEST_TMPDIR}/custom.state"
unset HOSTS_FILE_PATH
echo "== check_custom_entries_protection: nothing removed =="
reset_state
stage_script a.com b.com -- x.com
stage_custom_state a.com b.com
_t_allows check_custom_entries_protection "an unchanged list is allowed"
echo "== check_custom_entries_protection: entries added =="
reset_state
stage_script a.com b.com c.com -- x.com
stage_custom_state a.com b.com
_t_allows check_custom_entries_protection "adding entries is allowed"
echo "== check_custom_entries_protection: an entry removed is BLOCKED =="
reset_state
stage_script a.com -- x.com
stage_custom_state a.com b.com
_t_blocks check_custom_entries_protection "removing a blocked domain is refused"
out="$(check_custom_entries_protection || true)"
_t_has "$out" "INSTALLATION BLOCKED" "the refusal is stated plainly"
_t_has "$out" "b.com" "the removed domain is named"
echo "== check_custom_entries_protection: swapping entries is still a removal =="
reset_state
# Same count, different contents. A count-only check would pass this, which is
# exactly the hole the removed-set comparison exists to close.
stage_script a.com NEW.com -- x.com
stage_custom_state a.com b.com
_t_blocks check_custom_entries_protection "swapping one domain for another is refused"
echo "== extract_unblock_entries_from_script =="
reset_state
stage_script a.com -- one.com two.com
_t_eq "one.com
two.com" "$(extract_unblock_entries_from_script "${FAKE_SCRIPT}")" \
"the whitelist is read from between the markers"
echo "== check_unblock_entries_protection: first run =="
reset_state
stage_script a.com -- one.com
UNBLOCK_STATE_FILE="${TEST_TMPDIR}/nonexistent-unblock.state"
_t_allows check_unblock_entries_protection "a first run is allowed"
UNBLOCK_STATE_FILE="${TEST_TMPDIR}/unblock.state"
echo "== check_unblock_entries_protection: unchanged =="
reset_state
stage_script a.com -- one.com two.com
stage_unblock_state one.com two.com
_t_allows check_unblock_entries_protection "an unchanged whitelist is allowed"
echo "== check_unblock_entries_protection: shrinking is allowed =="
reset_state
# Removing a domain from the whitelist means blocking MORE, which is always
# the safe direction and must never be refused.
stage_script a.com -- one.com
stage_unblock_state one.com two.com
_t_allows check_unblock_entries_protection "removing a whitelist entry is allowed"
echo "== check_unblock_entries_protection: growing is BLOCKED =="
reset_state
stage_script a.com -- one.com two.com three.com
stage_unblock_state one.com two.com
_t_blocks check_unblock_entries_protection "adding a whitelist entry is refused"
out="$(check_unblock_entries_protection || true)"
_t_has "$out" "three.com" "the newly whitelisted domain is named"
echo "== save_unblock_entries_state =="
reset_state
save_unblock_entries_state "$(printf 'z.com\na.com\n')"
_t_eq "a.com
z.com" "$(cat "${UNBLOCK_STATE_FILE}")" "the saved whitelist is sorted and unique"
_t_summary