Archive legacy hosts-guard subsystem: superseded by guard-lib

setup_hosts_guard.sh was NEXT_SESSION_PROMPT.md's next split target, but
live-machine inspection showed it (and 16 supporting files - enforce-*.sh,
6 systemd units, install_pacman_hooks.sh, pacman-hooks/, psychological/,
both READMEs) is fully retired in favor of guard-lib, migrated 2026-07-04.
User confirmed archive over split, then confirmed the larger 17-file scope
once the directory was surveyed. Full history preserved via git filter-repo,
live at github.com/kuhyx/testsAndMisc-archive/tree/main/linux_configuration/scripts/periodic_background/hosts/guard.
plugins/{nsswitch,resolved}-plugin.sh stay - they're guard-lib's live
plugins, referenced by absolute path from /etc/guard-lib/targets/*.conf.

check_and_enable_services.sh's check_hosts() rewritten to check guard-lib
state (guardctl file-guard status) instead of the retired units/scripts it
used to check and self-repair - verified live, now reports the hosts
service ok against the actual migrated machine instead of a false error.
copilot-instructions.md and one install.sh comment updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-18 22:49:54 +02:00
parent 015eb48714
commit 327d48badd
24 changed files with 277 additions and 1819 deletions

View File

@ -1,15 +1,16 @@
# Next session: §3's next restructure — setup_hosts_guard.sh
# Next session: pick the next §3/§4 target — no split candidate is currently in scope
> **Paste this whole file into a fresh Claude session opened in `~/testsAndMisc`.**
> It is self-contained. Do not go looking for the previous session's context.
Over-cap: **12** (11 shell, 2 kotlin, 1 dart). Working tree clean, `main` in
sync as of the diagnose_pacman_hook_stall.sh split commit (see §0.5).
Over-cap: **11** (10 shell, 2 kotlin, 1 dart — `focus_owner`'s two Kotlin files
count once regardless of listing style). Working tree clean, `main` in sync
as of the `setup_hosts_guard.sh` archive commit (see §0).
Two standing decisions from the user, already made — do **not** re-litigate:
1. **100% line coverage on what you extract** — same bar every session so far
has applied.
has applied. Does not apply to archived/deleted code (no split happened).
2. **Split blind where there is no device.** Do not deploy to the phone, do
not ask to. Prove splits with tests, hashes and real runs instead.
@ -17,254 +18,208 @@ Two standing decisions from the user, already made — do **not** re-litigate:
## 0. What just happened (read this, don't repeat it)
Two sessions ago: `install_plagiarism_tools.sh` was split (534→94 lines +
3 libs, 100% coverage) — then the user said they no longer use the
plagiarism tools at all, so the whole thing was archived to
`kuhyx/testsAndMisc-archive` with full git history via `git filter-repo`.
One session ago: the same one-line "do you still use this?" check-in was
asked about `libre_translate.sh` _before_ any code was touched, and got the
same answer — also archived, not split.
`setup_hosts_guard.sh` (576 lines) was this session's assigned split target.
The dead-code check-in (per the §0 lesson from two sessions before this one)
turned up evidence the file might be fully superseded — confirmed by
inspecting live machine state: the legacy pacman hooks it installs were
absent from `/etc/pacman.d/hooks/`, its systemd units were all `disabled`,
and guard-lib's `/etc/guard-lib/targets/{hosts,nsswitch,resolved}.conf` were
all already populated. The user confirmed: archive, don't split.
**Lesson (2 for 2, now reinforced a third way):** keep asking the check-in
before every future §3/§4 split. It is not a formality; it has changed scope
in both of the two times it's been tried.
Scope grew past the one named file once the directory was surveyed: the
**whole legacy hosts-guard subsystem** (17 files — `setup_hosts_guard.sh`,
`enforce-hosts.sh`, `enforce-nsswitch.sh`, `enforce-resolved.sh`, 6 systemd
units, `install_pacman_hooks.sh`, `pacman-hooks/*`, `psychological/*`, both
READMEs) only existed to support the retired system, so it went to
`testsAndMisc-archive` together via `git filter-repo` (same procedure as the
plagiarism-tools and libre_translate archives). `plugins/nsswitch-plugin.sh`
and `plugins/resolved-plugin.sh` were **kept** — they're guard-lib's live
plugins, registered by absolute path in `/etc/guard-lib/targets/*.conf`.
## 0.5. What just happened (this session) — diagnose_pacman_hook_stall.sh split, confirmed live
`check_and_enable_services.sh`'s `check_hosts()` function was rewritten in
the same commit (user confirmed this too) to check guard-lib state
(`guardctl file-guard status <name>`) instead of the legacy systemd
units/enforce-scripts it used to check and self-repair. Verified live:
`sudo bash check_and_enable_services.sh --status` now reports the hosts
service `ok` against the actual migrated machine, where the old code would
have reported `error` (three units it expected enabled were `disabled` by
design). `linux_configuration/.github/copilot-instructions.md` (always-loaded)
and one comment in `hosts/install.sh` were also updated to stop naming the
archived files.
Asked the dead-code check-in first, per §0's lesson. User confirmed this
script **is** still in active use (unlike the last two candidates) — so the
split proceeded as originally planned. 493 lines → 249 (entry) + 6 libs
(pacman_hook_stall_setup.sh, _load.sh, _capture.sh, _watch.sh, _usage.sh,
_summary.sh), all at 100% line coverage, plus a PATH-shim test suite (56
lib-level + 13 entry-level subprocess assertions, 0 failed).
**Lesson (now 3 for 4):** the check-in keeps changing scope, and this time it
changed scope _twice_ — first archive-vs-split, then "just this file" vs
"the whole dead subsystem." Keep surveying the directory (`grep -rl`, not
just the named file) before proposing an archive's file list, and keep
confirming scope growth with the user before executing it.
**Two things worth knowing before touching this area again:**
## 0.1 Known defect found but NOT fixed this session — flag again if touching guard-lib
1. `run_one`/`cleanup`/`log_size`/`main` had to stay together in the entry
script — they share `PACMAN_PID`/`LAST_ELAPSED` across the `EXIT` trap,
and a prior session (commit `2527fb3`) already proved that splitting
`run_one` into a lib produces a **real** SC2153/SC2034 pair (not a
suppressible false positive), because a sourced lib has no way to declare
where its globals come from. If a future split looks like it wants to
move a function that both writes a global the entry script reads AND
reads a global only the entry script sets, do the same scratch
shellcheck-dry-run check (with `.shellcheckrc`'s `external-sources=true` /
`source-path=SCRIPTDIR`, which is what the real hook uses) **before**
committing to a lib boundary, not after.
2. **kcov cannot see `done <redirect>`, `} >redirect`, or lines inside an
inline awk script**, no matter how thoroughly the code executes (verified
empirically with `bash -x` on isolated repros — those lines never emit a
trace event). If a coverage gap on a future file's line list looks
suspiciously like a loop-closing or group-closing line, or an awk block,
check whether it's this exact class before assuming the test is weak. The
user was asked and chose "refactor the code" over "fix the coverage
tool" — that decision holds for this repo going forward unless
re-raised.
3. **Test files are not exempt from the 250-line cap.** The entry-script
subprocess test grew to 295 lines while adding coverage and had to be
split itself, mid-session, after `check_file_length.sh --all` caught it.
Budget for this: a test file that drives real subprocess timing (unshare,
real sleep, multiple flag combinations) grows fast — split its fixture
setup into a shared harness early rather than after it's already over.
4. Evidence + contract:
`docs/superpowers/evidence/split-diagnose-pacman-hook-stall-2026-08-18.json`
and the matching `contracts/` file are the current format reference —
more detailed than the plagiarism-tools pair since this split involved
several genuine (not mechanical) function rewrites, all individually
justified there.
`/etc/guard-lib/targets/nsswitch.conf` and `.../resolved.conf` both have
`PLUGIN=` pointing at the **repo checkout**
(`/home/kuhy/testsAndMisc/linux_configuration/scripts/periodic_background/hosts/guard/plugins/*.sh`)
instead of `/usr/local/share/guard-lib-plugins/`, which
`migrate_hosts_guard_to_guard_lib.sh`'s own header warns against by name
("pointing it at a repo checkout... silently breaks enforcement the day that
directory moves"). `/usr/local/share/guard-lib-plugins/` does not exist on
this machine at all — the migration's plugin-install step appears to have
never run. Not fixed this session: it needs a sudo mutation of a live
security-enforcement system plus `guardctl` re-registration, which the user
was not asked to approve. If a future session touches guard-lib, nsswitch,
resolved, or moves/renames this checkout, raise this first.
## 1. This session's task: `setup_hosts_guard.sh`
## 1. This session's task: none pre-selected — ask the user
Location:
`linux_configuration/scripts/periodic_background/hosts/guard/setup_hosts_guard.sh`
(576 lines).
Every file currently over the 250-line cap is already on an explicit
out-of-scope list from a prior session (§3 below, or the equivalent of the
old §4). There is no unclaimed split candidate right now. Options, in
roughly ascending order of friction:
**Ask the dead-code check-in first** (see §0.5) — the streak is 2-for-3 so
far (2 archived, 1 confirmed live); don't assume either outcome.
- Re-run `bash meta/scripts/check_file_length.sh --all` fresh (files may have
grown/shrunk since this doc was written) and see if a genuinely new
candidate appeared.
- Ask the user whether any §3/§4 file should be taken out of its hold (e.g.
"is `install_leechblock.sh`/`block_compulsive_opening.sh` still deployed at
`/usr/local/…`? still preferred by `pacman_wrapper.sh:831`?" — re-verify,
don't assume the old note is still true).
- Ask the user for a completely different task.
**Before you start**, confirm scope hasn't drifted: `git status --short` and
`wc -l` on the target file, and re-run
`bash meta/scripts/check_file_length.sh --all` to get the current exact
list — files change size between sessions, and this file's line count may
not still be 576.
**Ask before picking** — do not silently start splitting a file that a prior
session explicitly parked, and do not assume "no candidate" means "nothing to
do here" without checking with the user first.
Check what this file references before planning a split boundary —
`hosts_guard_migrate.sh`/`hosts_guard_rollback.sh` already exist as siblings
in `linux_configuration/scripts/single_use/fixes/lib/`, and
`fixes/migrate_hosts_guard_to_guard_lib.sh` is the entry script that
migrated _to_ this guard-lib system. Read that migration script's header
comment first — it explains what guard-lib actually does and may reveal
whether `setup_hosts_guard.sh` itself is now redundant with the migrated
state, which is exactly the kind of thing the dead-code check-in is for.
## 3. §4 — do not touch this session
## 2. §3 — do not touch this session (unless the user lifts it)
`install_leechblock.sh` (485) and `block_compulsive_opening.sh` (705) are
copied to `/usr/local/…`, and `pacman_wrapper.sh:831` prefers the deployed
copy on **every pacman invocation**. Splitting them naively breaks every
`pacman -S` on this machine. Out of scope until `setup_hosts_guard.sh` is
done (or archived) and the user explicitly says to continue into §4.
`pacman -S` on this machine. Re-verify this is still true (grep
`pacman_wrapper.sh` for the deployed-copy preference) before assuming the old
note still holds — a lot has changed guard-lib-side this session, worth the
30-second re-check.
You cannot run `sudo pacman -S` from the Bash tool — it deadlocks on
`db.lck`. Hand the user a `! sudo pacman -S <pkg>` line plus the expected
output if `pacman_wrapper.sh` ever needs live verification.
## 4. Out of scope until the user says otherwise
## 3. Out of scope until the user says otherwise
`setup_midnight_shutdown.sh` (1734), `check_and_enable_services.sh` (1301),
`setup_midnight_shutdown.sh` (1734), `check_and_enable_services.sh` (1225,
edited this session but not split — same out-of-scope reasoning as before),
`generate_study_materials.sh` (1017), `pacman_wrapper.sh` (929),
`setup_night_lockdown.sh` (918), `hosts/install.sh` (912),
`EnforcementRunner.kt` (564), `DevicePolicyBridge.kt` (415),
`setup_night_lockdown.sh` (918), `hosts/install.sh` (913, one comment edited
this session), `EnforcementRunner.kt` (564), `DevicePolicyBridge.kt` (415),
`status_page_state.dart` (307) — check `check_file_length.sh --all` for the
current exact list before assuming this is still accurate, since files
change size between sessions.
`pacman_wrapper.sh` carries the same live-deployment trap as §3/§4. The
Kotlin and Dart files need a different verification stack — `focus_owner`
gradle needs `JAVA_HOME=/usr/lib/jvm/java-21-openjdk` and `--rerun-tasks`; a
plain `gradlew test` reports `UP-TO-DATE` and proves nothing.
`pacman_wrapper.sh` carries the same live-deployment trap as §2. The Kotlin
and Dart files need a different verification stack — `focus_owner` gradle
needs `JAVA_HOME=/usr/lib/jvm/java-21-openjdk` and `--rerun-tasks`; a plain
`gradlew test` reports `UP-TO-DATE` and proves nothing.
## 5. Rules that will bite you (carried forward, still true)
## 4. Rules that will bite you (carried forward, still true)
- **Ask before large scope changes.** Two of the last three sessions turned
into archive-and-delete mid-session because the user volunteered new
information partway through (or, this session, confirmed the file WAS
still live). That is fine and expected — just don't assume silence means
"keep going as originally scoped" if something about the target file's
relevance seems worth a quick check.
- **Ask before large scope changes.** Three of the last four sessions have
changed scope mid-session because the user volunteered new information, or
a check-in (dead-code, or in this session's case a directory survey)
surfaced something bigger than the named file. That is fine and expected —
just don't assume silence means "keep going as originally scoped."
- **Survey the whole directory before scoping an archive, not just the named
file.** `grep -rl <filename-stem>` across the repo, then `ls` the file's
own directory — this session's file list grew from 1 to 17 files this way.
- **No suppressions, ever.** No `# noqa`, no per-file-ignore added without
asking every time, no shellcheck disable beyond what's already justified
inline. If a coverage or lint finding looks like it fundamentally
conflicts with the file's purpose, ask before working around it — this
session's kcov-blind-spot finding (§0.5.2) is the exact pattern: verify
the finding is real (bash -x trace check) before assuming it's a tool
limitation to route around vs. a real behavioral gap to test.
inline.
- **A file must not assign a global it never reads** (SC2034). The hook runs
`shellcheck` with **no `-x`**, but `.shellcheckrc`'s
`external-sources=true` + `source-path=SCRIPTDIR` means a `source=`
annotation on the _sourcing_ file still resolves correctly — the failure
mode that actually breaks is a _lib_ file trying to reference a global
only the entry script sets, since a lib has no annotation pointing
"upward" to whoever sources it. Constants travel with their readers; a
global genuinely written on both sides of a seam stays in the entry
script.
- **New sourced libs need a shebang AND the executable bit**, even though
they're sourced, not executed — `lib/hosts_guard_migrate.sh` and this
session's 6 new libs both carry `100755` in the git index, matching
convention. The hook reads the **git index**: stage with
`git add --chmod=+x`, and note that a plain `git add` afterwards resets
it.
annotation on the _sourcing_ file still resolves correctly.
- **New sourced libs need a shebang AND the executable bit**, staged with
`git add --chmod=+x` — a plain `git add` afterwards resets it.
- `pre-commit run --files <changed>` before committing, but run it **after**
`git add`, not before — index-mode checks (executable bit) only fire
against what's staged. **`prettier` and `ci-mirror` run on pre-push.**
`npx prettier --write` any `.md`/`.json` you touch (evidence/contract files
included — they are JSON and prettier will reformat them).
`git add`. **`prettier` and `ci-mirror` run on pre-push.** `npx prettier
--write` any `.md`/`.json` you touch (evidence/contract files included).
- **Every commit touching code needs evidence** in
`docs/superpowers/evidence/<slug>-<date>.json`. Staging **≥4 code files
also needs** a fresh `docs/superpowers/contracts/*.json`. Put the
**measured** coverage number in it, not a rounded-up one. This session's
pair (`split-diagnose-pacman-hook-stall-2026-08-18.json` in both
`evidence/` and `contracts/`) is the current best format reference —
it documents several genuine mid-session findings (a real bug, a
memory-safety catch, a test-file-too-long split) as they happened, which
is the level of detail to aim for when the session isn't a pure mechanical
move.
**measured** number in it, not a rounded-up one. This session's pair
(`archive-setup-hosts-guard-2026-08-18.json` in both `evidence/` and
`contracts/`) documents an archive-plus-live-script-fix, not a pure split —
read it if the next task is another archive-vs-split judgment call.
- **An archive does not need new tests.** The 100%-coverage standing decision
binds code that gets _split_, not code that gets _deleted_. Neither this
session's archive nor the two before it added tests for the archived code.
- Work directly on `main`. `git stash` and branch creation are blocked; use
`git worktree add --detach` for a clean baseline (but NEVER `git filter-repo`
inside a worktree — it shares the object store with the main repo and will
corrupt it; always a throwaway `git clone` for that).
- **Do not wire the file-length hook into pre-commit.** It lands last, once
`check_file_length.sh --all` exits 0.
- Watch `jscpd` (fails above 2%). Measure in a **clean HEAD worktree**
(`git worktree add --detach <tmp> HEAD`, copy the new files in, run jscpd
there, remove the worktree) — the working tree reads higher because of
vendored `.venv`, and several near-identical test files sharing a fixture
preamble is exactly the shape that trips this gate.
- Watch `jscpd` (fails above 2%). Measure in a **clean HEAD worktree**.
- Cap pytest memory:
`systemd-run --user --scope -p MemorySwapMax=0 -p MemoryMax=2G`.
- **Never let a test allocate unbounded real memory.** This session's
`--with-load` entry test would have `dd`ed 13-20+ GB into `/dev/shm` using
this box's real available memory before it was caught in review — any
script under test that computes an allocation size from real
`/proc/meminfo` needs its floor/ceiling constants made env-overridable
(`: "${VAR:=default}"`, not a bare or `readonly` assignment) so tests can
pin them to a small, bounded amount instead of trusting the live system
state to stay favorable.
- **Never let a test allocate unbounded real memory.**
## 6. Tooling reference
## 5. Tooling reference
**`meta/scripts/check_file_length.sh --all`** — the 250-line gate. Run
**before every commit**. Applies to test files too, not just the file
you're splitting.
**before every commit**. Applies to test files too, not just the file you're
splitting.
**`meta/scripts/shell_coverage.sh <test> <subject> [min]`** — kcov wrapper,
100% minimum. Hand it the test script **directly**; `bash script.sh`
instruments the bash binary and silently reports 0/0. If a test suite for
one subject is split across multiple test files, `shell_coverage.sh` only
takes one at a time — run kcov manually into the same output dir for each
test file, then call `meta/scripts/shell_coverage_report.py <outdir> <subject> <min>`
once to get the merged number (it sums hits per line-number across every
`cov.xml` it finds, so multiple runs against the same subject combine
correctly).
instruments the bash binary and silently reports 0/0.
**`meta/scripts/extract_shell_functions.py`** — moves functions
brace-by-brace. **Never slice by line range**. **Do not trust where it puts
the source line** — verify with `grep -c` on the anchor and check placement.
The `--header` text is written **verbatim, uncommented** — pass a full
`#!/bin/bash\n# comment` string, not bare prose, or the generated lib won't
parse.
brace-by-brace. **Never slice by line range**. Verify placement with `grep -c`
on the anchor.
**`meta/scripts/verify_shell_split.sh <pre-split-rev> <old> <new>...`** —
proves a split moved every function verbatim (hashes function bodies only,
blind to top-level code). For a **partial** split list the old path among the
new paths too, or it reports a false `DIFFERENCE`. A function that was
genuinely rewritten (not moved) will legitimately show as `DIFFERENCE` —
that's expected, not a bug; explain it in evidence rather than trying to make
the tool agree.
proves a split moved every function verbatim. For a **partial** split list
the old path among the new paths too.
**`meta/scripts/mutate_shell.py <spec>`** — mutation testing against specs in
`meta/scripts/fixtures/mutations/`. No spec exists for anything outside
`phone_focus_mode/`; optional, not required — 100% line coverage with real
assertions is the stated bar.
**`meta/scripts/mutate_shell.py <spec>`** — mutation testing; optional, not
required.
## 7. Testing pattern to follow
**Archiving a dead file/subsystem** (no dedicated script — hand-run each
time): clone to a scratch dir, `git filter-repo --force --path <each current
path> --path <each historical path from `git log --all --follow
--name-only`>`, clone `testsAndMisc-archive` separately, add the filtered
clone as a remote, `git merge --allow-unrelated-histories`, remove stray
remote-tracking branches and the remote, push, verify with `gh api
repos/kuhyx/testsAndMisc-archive/contents/<dir>`, **then** `git rm` from
`testsAndMisc`. Read `docs/superpowers/evidence/archive-setup-hosts-guard-2026-08-18.json`
or `archive-plagiarism-tools-2026-08-18.json` for the full worked example.
## 6. Testing pattern to follow (for an actual split, if one gets picked)
The established pattern for `single_use/`-adjacent shell scripts with no
prior test coverage (now used across three splits):
prior test coverage (used across three prior splits):
- A `lib/tests/` directory sibling to the split libs.
- A `*_harness.sh` file (sourced, not executed) with `_t_pass`/`_t_fail`/
`_t_eq` helpers, `mktemp -d` + `trap cleanup EXIT`, and PATH-shim fakes for
any external tool the code shells out to (record invocation to a file,
minimum realistic behavior).
any external tool the code shells out to.
- One `test_<lib>.sh` per lib, sourcing the harness, asserting against real
function calls (not mocks at the call-site level).
- A `test_<entry-script>.sh` that runs the **actual entry script as a
subprocess** against the same fakes, since `verify_shell_split.sh` and kcov
are blind to code that only lives in the entry script's `main()`. If this
test file threatens to grow past 250 lines (it will, once you're covering
several arg combinations and timing branches), split the fixture setup
into its own `*_entry_harness.sh` early and write two or more thinner test
files against it — this session had to do this mid-stream and it's
cheaper to plan for upfront.
subprocess** against the same fakes. If this test file threatens to grow
past 250 lines, split the fixture setup into its own `*_entry_harness.sh`
early.
- Sharp edges hit and fixed across sessions, watch for all of them again:
- `local a="$1" b="${a}"` **fails under `set -u`** — bash evaluates all
RHS expressions in the new scope before any assignment lands. Split into
separate `local` statements.
- `local a="$1" b="${a}"` **fails under `set -u`** — split into separate
`local` statements.
- A function that calls `exit` (not `return`) on a fatal condition will
kill the whole test script if called directly inside an `if` condition.
Run it in a subshell: `if (fn args); then ...`.
- A backgrounded process needs `disown` before `kill`/`wait` reliably
affects only it, not the calling job — this bit both a `stop_load` test
and the entry script's SIGTERM test this session.
affects only it.
- **SIGINT delivery to a backgrounded job was unreliable in this
environment; SIGTERM was not.** If a script's trap handles both signals
identically (`trap handler EXIT INT TERM`), prefer testing via SIGTERM.
environment; SIGTERM was not.**
- If a script under test computes a real resource allocation (memory,
disk) from live system state, make the relevant constants
env-overridable and pin them to something tiny in every test — never
trust "the box currently has enough headroom" to stay true.
env-overridable and pin them to something tiny in every test.
Check the target file for anything that shells out (pacman? systemctl?
journalctl? guardctl?) before assuming a prior session's fakes apply — each
file is a different domain, so the PATH-shim list will differ.
Check the target file for anything that shells out before assuming a prior
session's fakes apply — each file is a different domain.

View File

@ -0,0 +1,21 @@
{
"title": "Archive the legacy hosts-guard subsystem and repoint check_and_enable_services.sh at guard-lib",
"objective": "setup_hosts_guard.sh was NEXT_SESSION_PROMPT.md's next split target, but live-machine inspection showed the whole legacy hosts-guard subsystem it belongs to (17 files) has been fully superseded by guard-lib (migrate_hosts_guard_to_guard_lib.sh, run 2026-07-04). The user confirmed archiving the whole subsystem instead of splitting it, and confirmed fixing the one live script (check_and_enable_services.sh) that still checked and self-repaired against the retired system.",
"acceptance_criteria": [
"All 17 legacy hosts-guard files (setup_hosts_guard.sh, enforce-hosts.sh, enforce-nsswitch.sh, enforce-resolved.sh, hosts-bind-mount.service, hosts-guard.path, hosts-guard.service, nsswitch-guard.path, nsswitch-guard.service, resolved-guard.path, resolved-guard.service, install_pacman_hooks.sh, pacman-hooks/hosts-guard-common.sh, pacman-hooks/pacman-post-relock-hosts.sh, pacman-hooks/pacman-pre-unlock-hosts.sh, psychological/unlock-hosts.sh, README.md, README_FOR_LLM.md) exist in kuhyx/testsAndMisc-archive with git history intact, including both pre-reorg path prefixes (hosts/guard/* and linux_configuration/hosts/guard/*).",
"The archive push completed and was verified live on GitHub via the API before any deletion happened in the main repo.",
"The same 17 files are removed from testsAndMisc via git rm; plugins/nsswitch-plugin.sh and plugins/resolved-plugin.sh (guard-lib's live plugins) are untouched.",
"check_and_enable_services.sh's check_hosts() no longer references any deleted file or legacy systemd unit name; it checks guard-lib instance health via guardctl file-guard status instead, and running it --status against the live (already-migrated) machine reports the hosts service as ok, not error.",
"linux_configuration/.github/copilot-instructions.md (always-loaded) contains no remaining references to the archived files or the legacy mechanism.",
"bash meta/scripts/check_file_length.sh --all reports fewer or equal over-cap files than before this session (12 -> 11), and check_and_enable_services.sh (already over cap, on the out-of-scope list) did not grow.",
"pre-commit run --files passes on the full staged set for both the deletion+edit commit and the evidence/contract commit."
],
"out_of_scope": [
"Splitting check_and_enable_services.sh itself -- it is on NEXT_SESSION_PROMPT.md §4's out-of-scope list; edited in place only.",
"Fixing the pre-existing defect where /etc/guard-lib/targets/{nsswitch,resolved}.conf point PLUGIN= at the repo checkout instead of /usr/local/share/guard-lib-plugins/ -- flagged in evidence risks[], not fixed (requires sudo mutation of live security enforcement + guardctl re-registration, not something the user was asked to approve this session).",
"Updating linux_configuration/docs/security-hardening/{01-current-state,03-implementation,04-agent-personas,05-llm-readmes}.md -- these predate the 2026-07-04 guard-lib migration itself and were already stale before this session; fixing them is a larger doc-accuracy pass outside what was scoped.",
"§4's other listed files (setup_midnight_shutdown.sh, generate_study_materials.sh, pacman_wrapper.sh, setup_night_lockdown.sh, install_leechblock.sh, block_compulsive_opening.sh, the Kotlin/Dart files) -- untouched, per standing scope.",
"Any test suite for the archived subsystem -- it never had one, and the standing 100%-coverage decision applies to code that is split, not code that is deleted/archived (same precedent as archive-plagiarism-tools and archive-libre-translate, neither of which added tests)."
],
"verifier": "gh api repos/kuhyx/testsAndMisc-archive/contents/linux_configuration/scripts/periodic_background/hosts/guard confirming all 17 files present before the git rm commit; sudo bash check_and_enable_services.sh --status showing the Hosts File and Guards section all-OK against live guard-lib state; bash -n + shellcheck on check_and_enable_services.sh and hosts/install.sh; bash meta/scripts/check_file_length.sh --all; pre-commit run --files on the full staged set."
}

View File

@ -0,0 +1,61 @@
{
"intent": "Archive the pre-guard-lib hosts-guard subsystem (setup_hosts_guard.sh and everything that only existed to support it), since guardctl/guard-lib has fully replaced it on this machine, then update the one live script that still checked and self-repaired against the retired system.",
"scope": [
"linux_configuration/scripts/periodic_background/hosts/guard/{setup_hosts_guard.sh, enforce-hosts.sh, enforce-nsswitch.sh, enforce-resolved.sh, hosts-bind-mount.service, hosts-guard.path, hosts-guard.service, nsswitch-guard.path, nsswitch-guard.service, resolved-guard.path, resolved-guard.service, install_pacman_hooks.sh, pacman-hooks/*, psychological/unlock-hosts.sh, README.md, README_FOR_LLM.md} (deleted, moved to testsAndMisc-archive)",
"linux_configuration/scripts/periodic_background/hosts/guard/plugins/{nsswitch-plugin.sh, resolved-plugin.sh} left in place — these are guard-lib's live plugins, referenced by absolute path from /etc/guard-lib/targets/{nsswitch,resolved}.conf on this machine",
"linux_configuration/scripts/periodic_background/check_and_enable_services.sh check_hosts() rewritten to check guard-lib state (guardctl file-guard status) instead of the legacy systemd units/enforce-scripts/canonical-snapshot paths",
"linux_configuration/.github/copilot-instructions.md: 8 references to the archived files updated to describe the guard-lib mechanism instead",
"linux_configuration/scripts/periodic_background/hosts/install.sh: one comment describing enforcement mechanism updated (mentioned hosts-guard.path/enforce-hosts.sh/hosts-bind-mount.service by name)",
"Not in scope: linux_configuration/docs/security-hardening/{01-current-state,03-implementation,04-agent-personas,05-llm-readmes}.md — these are dated design/planning docs that already predated the 2026-07-04 guard-lib migration (i.e. were already stale before this session, not freshly broken by it); fixing them is a larger doc-accuracy pass the user did not scope in"
],
"changes": [
"Confirmed live-machine state before touching anything: legacy pacman hooks (10-unlock-etc-hosts.hook, 90-relock-etc-hosts.hook) absent from /etc/pacman.d/hooks/, legacy systemd units (hosts-guard.path/.service, nsswitch-guard.*, resolved-guard.*) all `disabled`, and /etc/guard-lib/targets/{hosts,nsswitch,resolved}.conf all populated — confirming the migration is complete and setup_hosts_guard.sh is dead code.",
"Asked the user (dead-code check-in per NEXT_SESSION_PROMPT.md §0/§0.5 lesson) whether to archive vs split; user confirmed archive.",
"Surveyed the whole hosts/guard/ directory and its cross-references (grep -rl) rather than archiving only the named file; found the legacy subsystem is 17 files across enforce-*.sh, systemd units, pacman-hooks/, psychological/, install_pacman_hooks.sh, and both READMEs, versus plugins/ (2 files) which is guard-lib's live plugin layer. Asked the user to confirm this larger scope before touching anything; confirmed.",
"Verified two potential blockers before archiving: hosts/install.sh's reference to enforce-hosts.sh is a descriptive comment only (not a live call — fixed for accuracy, not correctness); single_use/fixes/lib/hosts_guard_rollback.sh's do_rollback() restores from a captured $STATE_DIR snapshot and the deployed /usr/local/sbin/*.sh copies, not the repo checkout, so archiving the repo source does not break --rollback today.",
"Enumerated every historical path for the 17 files (git log --all --follow --name-only --diff-filter=A) to build a complete git filter-repo --path list, including two pre-reorg prefixes: hosts/guard/* (before the scripts/periodic_background/ reorg) and linux_configuration/hosts/guard/* (older still, before the scripts/ reorg — needed for enforce-resolved.sh and resolved-guard.path/.service specifically).",
"Cloned testsAndMisc into a throwaway scratchpad location (never a worktree — filter-repo rewrites refs and a worktree shares the object store), ran git filter-repo --force with the full 34-entry --path list, verified all 17 files present with history (17 relevant commits).",
"Cloned testsAndMisc-archive separately, added the filtered clone as a remote, merged with --allow-unrelated-histories, removed the stray remote-tracking branches (copilot-wt, copilot/worktree-*, fix-steam-backlog-needy-streamer) picked up from the source repo's other refs, removed the remote itself, then pushed to origin/main.",
"Verified the push landed via `gh api repos/kuhyx/testsAndMisc-archive/contents/.../hosts/guard` before touching the main repo — confirmed all 16 top-level entries (files + the 2 subdirs) present.",
"Only after the archive push was confirmed live: git rm the same 17 files from testsAndMisc.",
"Rewrote check_and_enable_services.sh's check_hosts(): replaced per-unit systemctl is-enabled/is-active checks and /usr/local/sbin/enforce-*.sh + /usr/local/share/locked-*.conf existence checks with a new guard_lib_instance_healthy() helper wrapping `guardctl file-guard status <name>` (checks 'path unit: active' and an immutable target attr) for the hosts/nsswitch/resolved instances. hosts_pacman_hooks_installed() lost its legacy-hook fallback branch (guard-lib hooks only, since they're confirmed installed). The repair path that used to re-run setup_hosts_guard.sh and install_pacman_hooks.sh now re-runs migrate_hosts_guard_to_guard_lib.sh instead, which the migration script's own header documents as idempotent. Removed the now-dead HOSTS_GUARD_SCRIPT/HOSTS_PACMAN_HOOKS_SCRIPT constants, added GUARD_LIB_MIGRATE_SCRIPT.",
"Updated linux_configuration/.github/copilot-instructions.md (an always-loaded instruction file) at all 8 locations that named archived files or the legacy mechanism by name, replacing them with the guard-lib equivalent (guardctl, migrate_hosts_guard_to_guard_lib.sh, the generic pacman hooks, plugins/)."
],
"verification": [
{
"command": "bash -n linux_configuration/scripts/periodic_background/check_and_enable_services.sh && shellcheck linux_configuration/scripts/periodic_background/check_and_enable_services.sh",
"result": "pass",
"evidence": "Syntax OK; shellcheck clean, no warnings."
},
{
"command": "sudo bash linux_configuration/scripts/periodic_background/check_and_enable_services.sh --status",
"result": "pass",
"evidence": "Hosts File and Guards section: all 11 checks report [OK], including \"guard-lib 'hosts' instance is active and enforced\", \"guard-lib 'nsswitch' instance is active and enforced\", \"guard-lib 'resolved' instance is active and enforced\", and \"Pacman hooks installed\". Overall exit code 0; Summary table shows hosts=ok. Before this edit, the same run against the same live machine state would have reported hosts-guard.path/nsswitch-guard.path/resolved-guard.path as disabled (status=error) because those legacy checks were being run against a migrated machine — this is the actual bug this session's rewrite fixes, not just a cosmetic rename."
},
{
"command": "gh api repos/kuhyx/testsAndMisc-archive/contents/linux_configuration/scripts/periodic_background/hosts/guard --jq '.[].name'",
"result": "pass",
"evidence": "Returned README.md, README_FOR_LLM.md, enforce-hosts.sh, enforce-nsswitch.sh, enforce-resolved.sh, hosts-bind-mount.service, hosts-guard.path, hosts-guard.service, install_pacman_hooks.sh, nsswitch-guard.path, nsswitch-guard.service, pacman-hooks, psychological, resolved-guard.path, resolved-guard.service, setup_hosts_guard.sh -- confirmed live on GitHub before the main-repo deletion commit was made."
},
{
"command": "bash meta/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "11 files over cap (was 12 before this session — setup_hosts_guard.sh at 576 lines is gone). check_and_enable_services.sh is still over cap (1225 lines, was 1301) but net shrank; it is on NEXT_SESSION_PROMPT.md §4's explicit out-of-scope list for splitting, so being over cap is expected and unchanged in kind, just smaller."
},
{
"command": "pre-commit run --files <all staged files>",
"result": "pass",
"evidence": "All hooks passed except ai-evidence-contract (expected — this file is what satisfies it) and workflow-contract, addressed by the accompanying contracts/ file. shellcheck, shfmt-equivalent whitespace/EOF checks, codespell, secret scanning all clean."
}
],
"risks": [
"/etc/guard-lib/targets/{nsswitch,resolved}.conf point their PLUGIN= paths at the repo checkout (/home/kuhy/testsAndMisc/linux_configuration/scripts/periodic_background/hosts/guard/plugins/*.sh) instead of /usr/local/share/guard-lib-plugins/, which migrate_hosts_guard_to_guard_lib.sh's own header explicitly warns against ('an ABSOLUTE plugin path... pointing it at a repo checkout... silently breaks enforcement the day that directory moves'). /usr/local/share/guard-lib-plugins/ does not exist on this machine at all -- the migration's plugin-install step appears not to have run. This is a pre-existing defect, unrelated to and not caused by this session's archive (plugins/ was left in place specifically so this would keep working), but it means moving or renaming the testsAndMisc checkout will silently break nsswitch/resolved enforcement. Flagged for the user; not fixed in this commit since it requires a sudo mutation of a live security-enforcement system and guardctl re-registration, which is out of the two things the user approved this session.",
"linux_configuration/docs/security-hardening/{01-current-state,03-implementation,04-agent-personas,05-llm-readmes}.md still describe the pre-guard-lib system in detail and were not updated -- they were already stale before this session (guard-lib migration was 2026-07-04), so this is not a regression, but a reader following those docs today will find dead paths.",
"The 4 dated security-hardening docs above were left untouched by choice, not oversight -- see 'Not in scope' in scope[]."
],
"rollback": [
"In testsAndMisc: git revert the deletion commit to restore the 17 files (content/permissions unchanged from their last live commit, so a straight revert is safe) and git revert the check_and_enable_services.sh/copilot-instructions.md/install.sh edit commit to restore the legacy-aware checks.",
"The testsAndMisc-archive copy can stay regardless of rollback -- redundant, not harmful.",
"Restoring the legacy hosts-guard system itself (not just the repo files) requires sudo scripts/single_use/fixes/migrate_hosts_guard_to_guard_lib.sh --rollback, which is unaffected by this commit since it restores from /var/lib/guard-lib-migration/ and the already-deployed /usr/local/sbin/*.sh copies, not the repo checkout."
]
}

View File

@ -7,7 +7,7 @@ This repo automates Linux desktop bootstrap, hardening, and i3 setup. It’s pri
- fresh-install/: end-to-end bootstrap for Arch/Ubuntu workstations. Reads package lists, configures pacman/makepkg, sets up GPU drivers, i3, hosts guard, pacman wrapper, and useful services. Example: `fresh-install/main.sh` orchestrates most steps and sources `detect_gpu*.sh`.
- hosts/: manages a highly-opinionated `/etc/hosts` via StevenBlack upstream with custom edits, plus “guard” friction:
- `hosts/install.sh` builds and locks `/etc/hosts` (immutable/append-only; selective unblocks; custom blocks).
- `hosts/guard/` installs enforcement: `enforce-hosts.sh`, path-watcher `hosts-guard.path` -> `hosts-guard.service`, optional RO bind mount, pacman hooks, and a delayed editor `psychological/unlock-hosts.sh`.
- `hosts/guard/` enforcement is provided by guard-lib (`guardctl`, installed outside this repo): a `file-guard` instance per target (`hosts`, `nsswitch`, `resolved`) with a path-watcher, optional RO bind mount, generic pacman hooks, and per-target plugins under `hosts/guard/plugins/`. The pre-guard-lib scripts (`setup_hosts_guard.sh`, `enforce-*.sh`, per-target systemd units, `psychological/unlock-hosts.sh`) are archived at github.com/kuhyx/testsAndMisc-archive — see `scripts/single_use/fixes/migrate_hosts_guard_to_guard_lib.sh` for how the migration works.
- scripts/periodic_background/digital_wellbeing/pacman/: a policy-aware pacman wrapper with friction mechanics.
- `pacman_wrapper.sh` intercepts transactions, runs hosts-guard pre/post hooks, handles stale db lock, auto-wires maintenance services, and enforces package policy (blocked/whitelisted lists); adds weekend-only “Steam” challenge and a VirtualBox challenge powered by `words.txt`.
- `install_pacman_wrapper.sh` backs up `/usr/bin/pacman` to `pacman.orig` and symlinks to the wrapper.
@ -17,7 +17,7 @@ This repo automates Linux desktop bootstrap, hardening, and i3 setup. It’s pri
## Conventions you should follow
- Bash style: use `set -e` or `set -euo pipefail`, re-exec with sudo if not root, be idempotent, and log to `/var/log/*` with timestamps. Examples: `setup_periodic_system.sh`, `hosts/guard/setup_hosts_guard.sh`.
- Bash style: use `set -e` or `set -euo pipefail`, re-exec with sudo if not root, be idempotent, and log to `/var/log/*` with timestamps. Examples: `setup_periodic_system.sh`, `scripts/single_use/fixes/migrate_hosts_guard_to_guard_lib.sh`.
- Install via templates: scripts under `scripts/periodic_background/system-maintenance/bin` and `.../systemd` are templates. The setup script substitutes placeholders like `__HOSTS_INSTALL_SCRIPT__` and `__PACMAN_WRAPPER_INSTALL__` before installing to `/usr/local/bin` and `/etc/systemd/system`. Don’t edit installed copies directly; modify templates and the setup script.
- Package lists: `fresh-install/pacman_packages.txt` and `aur_packages.txt` treat any line not starting with lowercase alnum as a comment.
@ -29,20 +29,20 @@ This repo automates Linux desktop bootstrap, hardening, and i3 setup. It’s pri
- Pacman wrapper only: `sudo scripts/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh` (backs up pacman and wires the wrapper). The wrapper auto-runs hosts-guard pre/post hooks and can self-setup periodic services when missing.
- Hosts guard:
- `sudo hosts/install.sh` to (re)build `/etc/hosts` from cache/upstream then lock it.
- `sudo hosts/guard/setup_hosts_guard.sh` to install guard layers; then `hosts/guard/install_pacman_hooks.sh` to add pacman pre/post unlock hooks.
- To edit `/etc/hosts`: run `/usr/local/sbin/unlock-hosts` (delays, opens editor, re-applies protections).
- Guard layers (hosts/nsswitch/resolved) are managed by `guardctl file-guard <install|status|unlock|uninstall> <name>`; see `scripts/single_use/fixes/migrate_hosts_guard_to_guard_lib.sh` to (re)install them.
- To edit a guarded file: `sudo guardctl file-guard unlock <name>`, edit, then let the path-watcher re-lock it (or `guardctl file-guard enforce <name>`).
- i3 config: `i3-configuration/install.sh` (copies `i3` and `i3blocks`, adjusts font size; installs required tools conditionally for Arch/Ubuntu).
## Integration points and gotchas
- Pacman interception: `pacman_wrapper.sh` sets `PACMAN_BIN=/usr/bin/pacman.orig` and symlinks `/usr/bin/pacman` -> wrapper. Keep this invariant when changing the wrapper.
- Hosts hooks: Wrapper calls `/usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh` and `...post-relock-hosts.sh` if installed; keep paths stable or update both installer and wrapper.
- Hosts hooks: pacman transactions are unlocked/relocked by guard-lib's generic hooks (`/etc/pacman.d/hooks/10-guard-lib-unlock-all.hook`, `90-guard-lib-relock-all.hook`), which iterate every registered `file-guard` instance rather than being hosts-specific.
- Logs: check `/var/log/periodic-system-maintenance.log` and `/var/log/hosts-file-monitor.log` for service behavior; timer and services live under `scripts/periodic_background/system-maintenance/systemd/` (templates).
- Browser pre-exec: setup creates `/usr/local/bin/browser-preexec-wrapper` and symlinks common browser names to it; it silently re-runs the hosts installer before launching the real binary in `/usr/bin`.
## Patterns to reuse when adding features
- Follow the sudo re-exec + idempotent install pattern from `setup_periodic_system.sh` and `hosts/guard/setup_hosts_guard.sh`.
- Follow the sudo re-exec + idempotent install pattern from `setup_periodic_system.sh` and `scripts/single_use/fixes/migrate_hosts_guard_to_guard_lib.sh`.
- Add new periodic behaviors as templates under `scripts/periodic_background/system-maintenance/bin` and `.../systemd`, then extend `setup_periodic_system.sh` to install/enable them.
- Extend package policy by updating `scripts/periodic_background/digital_wellbeing/pacman/pacman_blocked_keywords.txt` or by adding `check_for_<pkg>` + `prompt_for_<pkg>_challenge` blocks in the wrapper.
- Run `scripts/meta/shell_check.sh` to detect things to fix before committing.
@ -51,7 +51,7 @@ This repo automates Linux desktop bootstrap, hardening, and i3 setup. It’s pri
For in-depth understanding of specific components, see these dedicated guides:
- **Hosts Guard**: [hosts/guard/README_FOR_LLM.md](../hosts/guard/README_FOR_LLM.md) - Protection layers, canonical copies, path watchers
- **Hosts Guard**: [scripts/single_use/fixes/migrate_hosts_guard_to_guard_lib.sh](../scripts/single_use/fixes/migrate_hosts_guard_to_guard_lib.sh) header comment - guard-lib migration, protection layers, canonical copies, path watchers. The pre-guard-lib README is archived at github.com/kuhyx/testsAndMisc-archive.
- **Pacman Wrapper**: [scripts/periodic_background/digital_wellbeing/pacman/README_FOR_LLM.md](../scripts/periodic_background/digital_wellbeing/pacman/README_FOR_LLM.md) - Policy files, integrity checks, challenges
- **Midnight Shutdown**: [scripts/periodic_background/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md](../scripts/periodic_background/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md) - Schedule protection, timer system
- **Compulsive Block**: [scripts/periodic_background/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md](../scripts/periodic_background/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md) - App launch limiting
@ -61,7 +61,7 @@ For in-depth understanding of specific components, see these dedicated guides:
| Component | Purpose | Key Files |
| ----------------- | ----------------------------- | --------------------------------------------------------------------------- |
| Hosts Guard | Block websites via /etc/hosts | `hosts/install.sh`, `hosts/guard/*` |
| Hosts Guard | Block websites via /etc/hosts | `hosts/install.sh`, `guardctl` (guard-lib), `hosts/guard/plugins/*` |
| Pacman Wrapper | Block package installation | `scripts/periodic_background/digital_wellbeing/pacman/*` |
| Midnight Shutdown | Auto-shutdown at night | `scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh` |
| Compulsive Block | Limit app launches | `scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh` |

View File

@ -39,8 +39,7 @@ MIDNIGHT_SHUTDOWN_SCRIPT="$CONFIG_DIR/scripts/periodic_background/digital_wellbe
STARTUP_MONITOR_SCRIPT="$CONFIG_DIR/scripts/periodic_background/digital_wellbeing/setup_pc_startup_monitor.sh"
PERIODIC_SYSTEM_SCRIPT="$CONFIG_DIR/scripts/periodic_background/setup_periodic_system.sh"
HOSTS_INSTALL_SCRIPT="$CONFIG_DIR/scripts/periodic_background/hosts/install.sh"
HOSTS_GUARD_SCRIPT="$CONFIG_DIR/scripts/periodic_background/hosts/guard/setup_hosts_guard.sh"
HOSTS_PACMAN_HOOKS_SCRIPT="$CONFIG_DIR/scripts/periodic_background/hosts/guard/install_pacman_hooks.sh"
GUARD_LIB_MIGRATE_SCRIPT="$CONFIG_DIR/scripts/single_use/fixes/migrate_hosts_guard_to_guard_lib.sh"
COMPULSIVE_BLOCK_SCRIPT="$CONFIG_DIR/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh"
LEECHBLOCK_SCRIPT="$CONFIG_DIR/scripts/periodic_background/digital_wellbeing/install_leechblock.sh"
REMOVE_GUEST_MODE_SCRIPT="$CONFIG_DIR/scripts/periodic_background/digital_wellbeing/remove_guest_mode.sh"
@ -105,10 +104,20 @@ user_systemctl() { # <user> <systemctl args...>
# guard-lib pair as authoritative (see pacman_hooks_manage_guard_lib) — match it,
# while still accepting the legacy pair if an older install is present.
hosts_pacman_hooks_installed() {
{ [[ -f /etc/pacman.d/hooks/10-guard-lib-unlock-all.hook ]] &&
[[ -f /etc/pacman.d/hooks/90-guard-lib-relock-all.hook ]]; } ||
{ [[ -f /etc/pacman.d/hooks/10-unlock-etc-hosts.hook ]] &&
[[ -f /etc/pacman.d/hooks/90-relock-etc-hosts.hook ]]; }
[[ -f /etc/pacman.d/hooks/10-guard-lib-unlock-all.hook ]] &&
[[ -f /etc/pacman.d/hooks/90-guard-lib-relock-all.hook ]]
}
# True if guard-lib's file-guard instance <name> is installed AND healthy:
# the path unit is active and the target carries the immutable attribute.
# `guardctl file-guard status` exits 1 for an unregistered instance (missing
# /etc/guard-lib/targets/<name>.conf), so a missing instance and an unhealthy
# one both fail this check without needing separate handling.
guard_lib_instance_healthy() { # <name>
local name="$1" out
out=$(guardctl file-guard status "$name" 2>/dev/null) || return 1
grep -q '^path unit: active$' <<<"$out" || return 1
grep -q '^target attrs: .*i' <<<"$out" || return 1
}
# Replay a drift manifest written at install time. The manifest holds plain
@ -625,50 +634,15 @@ check_hosts() {
status="warning"
fi
# Check hosts guard path watcher
if systemctl is-enabled hosts-guard.path &>/dev/null; then
msg "hosts-guard.path is enabled"
# Check the guard-lib "hosts" file-guard instance (path unit active +
# immutable attribute on the target). Replaces the legacy hosts-guard.path
# / hosts-bind-mount.service / enforce-hosts.sh checks now that this
# machine's migration to guard-lib is complete — see
# migrate_hosts_guard_to_guard_lib.sh.
if guard_lib_instance_healthy hosts; then
msg "guard-lib 'hosts' instance is active and enforced"
else
issues+=("hosts-guard.path is not enabled")
status="error"
fi
if systemctl is-active hosts-guard.path &>/dev/null; then
msg "hosts-guard.path is active"
else
issues+=("hosts-guard.path is not active")
status="warning"
fi
# Check hosts bind mount service
if systemctl is-enabled hosts-bind-mount.service &>/dev/null; then
msg "hosts-bind-mount.service is enabled"
else
issues+=("hosts-bind-mount.service is not enabled")
status="warning"
fi
# Check enforcement script
if [[ -f /usr/local/sbin/enforce-hosts.sh ]]; then
msg "Enforcement script exists at /usr/local/sbin/enforce-hosts.sh"
else
issues+=("enforce-hosts.sh not found")
status="error"
fi
# Check unlock script
if [[ -f /usr/local/sbin/unlock-hosts ]]; then
msg "Unlock script exists at /usr/local/sbin/unlock-hosts"
else
issues+=("unlock-hosts not found")
status="warning"
fi
# Check locked hosts snapshot
if [[ -f /usr/local/share/locked-hosts ]]; then
msg "Canonical hosts snapshot exists at /usr/local/share/locked-hosts"
else
issues+=("Canonical hosts snapshot not found")
issues+=("guard-lib 'hosts' instance is missing or unhealthy")
status="error"
fi
@ -695,27 +669,11 @@ check_hosts() {
status="error"
fi
# Check nsswitch guard
if systemctl is-enabled nsswitch-guard.path &>/dev/null; then
msg "nsswitch-guard.path is enabled"
# Check the guard-lib "nsswitch" file-guard instance
if guard_lib_instance_healthy nsswitch; then
msg "guard-lib 'nsswitch' instance is active and enforced"
else
issues+=("nsswitch-guard.path is not enabled")
status="error"
fi
# Check nsswitch enforcement script
if [[ -f /usr/local/sbin/enforce-nsswitch.sh ]]; then
msg "nsswitch enforcement script exists at /usr/local/sbin/enforce-nsswitch.sh"
else
issues+=("enforce-nsswitch.sh not found")
status="error"
fi
# Check canonical nsswitch snapshot
if [[ -f /usr/local/share/locked-nsswitch.conf ]]; then
msg "Canonical nsswitch snapshot exists at /usr/local/share/locked-nsswitch.conf"
else
issues+=("Canonical nsswitch snapshot not found at /usr/local/share/locked-nsswitch.conf")
issues+=("guard-lib 'nsswitch' instance is missing or unhealthy")
status="error"
fi
@ -766,27 +724,11 @@ check_hosts() {
[[ "$status" == "ok" ]] && status="warning"
fi
# Check resolved guard
if systemctl is-enabled resolved-guard.path &>/dev/null; then
msg "resolved-guard.path is enabled"
# Check the guard-lib "resolved" file-guard instance
if guard_lib_instance_healthy resolved; then
msg "guard-lib 'resolved' instance is active and enforced"
else
issues+=("resolved-guard.path is not enabled")
status="error"
fi
# Check resolved enforcement script
if [[ -f /usr/local/sbin/enforce-resolved.sh ]]; then
msg "resolved enforcement script exists at /usr/local/sbin/enforce-resolved.sh"
else
issues+=("enforce-resolved.sh not found")
status="error"
fi
# Check canonical resolved.conf snapshot
if [[ -f /usr/local/share/locked-resolved.conf ]]; then
msg "Canonical resolved.conf snapshot exists at /usr/local/share/locked-resolved.conf"
else
issues+=("Canonical resolved.conf snapshot not found at /usr/local/share/locked-resolved.conf")
issues+=("guard-lib 'resolved' instance is missing or unhealthy")
status="error"
fi
@ -881,46 +823,28 @@ check_hosts() {
fi
fi
# Run hosts guard setup (also installs nsswitch-guard and resolved-guard)
if ! systemctl is-enabled hosts-guard.path &>/dev/null ||
! systemctl is-enabled nsswitch-guard.path &>/dev/null ||
! systemctl is-enabled resolved-guard.path &>/dev/null ||
[[ ! -f /usr/local/sbin/enforce-hosts.sh ]] ||
[[ ! -f /usr/local/sbin/enforce-nsswitch.sh ]] ||
[[ ! -f /usr/local/sbin/enforce-resolved.sh ]] ||
[[ ! -f /usr/local/share/locked-nsswitch.conf ]] ||
[[ ! -f /usr/local/share/locked-resolved.conf ]]; then
note "Setting up hosts guard (includes nsswitch-guard and resolved-guard)..."
if [[ -f $HOSTS_GUARD_SCRIPT ]]; then
run bash "$HOSTS_GUARD_SCRIPT"
# Re-run the guard-lib migration if any instance is missing/unhealthy
# or the pacman hooks aren't installed. The migration script is
# documented as idempotent (see its header), so re-running it against
# an already-migrated, healthy machine is a no-op.
if ! guard_lib_instance_healthy hosts ||
! guard_lib_instance_healthy nsswitch ||
! guard_lib_instance_healthy resolved ||
! hosts_pacman_hooks_installed; then
note "Repairing guard-lib hosts/nsswitch/resolved instances..."
if [[ -f $GUARD_LIB_MIGRATE_SCRIPT ]]; then
run bash "$GUARD_LIB_MIGRATE_SCRIPT"
((FIXES_APPLIED++)) || true
else
err_missing_script "Hosts guard script not found: $HOSTS_GUARD_SCRIPT"
fi
fi
# Install pacman hooks if missing
if ! hosts_pacman_hooks_installed; then
note "Installing pacman hooks..."
if [[ -f $HOSTS_PACMAN_HOOKS_SCRIPT ]]; then
run bash "$HOSTS_PACMAN_HOOKS_SCRIPT"
((FIXES_APPLIED++)) || true
else
err_missing_script "Pacman hooks script not found: $HOSTS_PACMAN_HOOKS_SCRIPT"
err_missing_script "Guard-lib migration script not found: $GUARD_LIB_MIGRATE_SCRIPT"
fi
fi
# Re-verify after fixes
if [[ $DRY_RUN -eq 0 ]]; then
if systemctl is-enabled hosts-guard.path &>/dev/null &&
systemctl is-enabled nsswitch-guard.path &>/dev/null &&
systemctl is-enabled resolved-guard.path &>/dev/null &&
[[ -f /usr/local/sbin/enforce-hosts.sh ]] &&
[[ -f /usr/local/sbin/enforce-nsswitch.sh ]] &&
[[ -f /usr/local/sbin/enforce-resolved.sh ]] &&
[[ -f /usr/local/share/locked-hosts ]] &&
[[ -f /usr/local/share/locked-nsswitch.conf ]] &&
[[ -f /usr/local/share/locked-resolved.conf ]] &&
if guard_lib_instance_healthy hosts &&
guard_lib_instance_healthy nsswitch &&
guard_lib_instance_healthy resolved &&
hosts_pacman_hooks_installed; then
# Downgrade to warning if only minor issues remain (immutable attr, etc.)
status="ok"

View File

@ -1,33 +0,0 @@
# Hosts Guard Components
This directory contains templates for hardening /etc/hosts against impulsive tampering by adding friction, NOT providing absolute security against a determined root user.
Components:
1. enforce-hosts.sh – Idempotent script that: compares /etc/hosts with canonical copy at /usr/local/share/locked-hosts and restores if different; reapplies immutable attribute.
2. systemd units (to be installed under /etc/systemd/system):
- hosts-guard.service (oneshot enforcement)
- hosts-guard.path (triggers on PathChanged=/etc/hosts)
- hosts-bind-mount.service (bind mounts /etc/hosts read-only after boot)
3. psychological/ directory – scripts that add delay + journaling before allowing a maintenance/unlock operation.
4. pacman hooks – automatically unlock/re-lock /etc/hosts around package transactions so pacman never fails due to the read-only bind mount.
Install Flow (suggested):
1. After generating /etc/hosts via your existing hosts/install.sh, copy it to /usr/local/share/locked-hosts.
2. Install enforce-hosts.sh to /usr/local/sbin/ (chmod 755).
3. Place units and enable:
systemctl daemon-reload
systemctl enable --now hosts-guard.path
systemctl enable --now hosts-bind-mount.service
4. (Optional) Use psychological/unlock-hosts.sh as the ONLY sanctioned way to modify hosts (it removes protections temporarily, launches an editor after a delay, and re-enforces on close).
5. Make pacman automatic (recommended):
./install_pacman_hooks.sh
This installs hooks under /etc/pacman.d/hooks that:
- PreTransaction: temporarily disable guard and make /etc/hosts writable
- PostTransaction: re-run enforcement and re-enable guard (bind mount + path watcher)
Limitations:
- A root user can still disable units, remount, remove attributes.
- Purpose is to interrupt habit loops and create intentional friction.

View File

@ -1,221 +0,0 @@
# Hosts Guard System - LLM Reference Guide
> **For AI assistants**: This document explains how the hosts guard system works so you can make correct modifications.
## System Purpose
Prevent tampering with `/etc/hosts` to maintain website blocking (YouTube, social media, etc.) as part of a digital wellbeing system.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ PROTECTION LAYERS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: Immutable Attribute │
│ ───────────────────────────── │
│ /etc/hosts has chattr +i (cannot be modified even by root) │
│ │
│ Layer 2: Canonical Copy │
│ ─────────────────────── │
│ /usr/local/share/locked-hosts contains the "true" version │
│ If /etc/hosts differs, it gets overwritten from this copy │
│ │
│ Layer 3: Path Watcher (systemd) │
│ ────────────────────────────── │
│ hosts-guard.path watches /etc/hosts for ANY change │
│ hosts-guard.service runs enforce-hosts.sh when triggered │
│ │
│ Layer 4: Read-Only Bind Mount │
│ ──────────────────────────── │
│ hosts-bind-mount.service mounts /etc/hosts read-only │
│ Even if chattr is removed, write operations fail │
│ │
│ Layer 5: Custom Entries Protection │
│ ───────────────────────────────── │
│ /etc/hosts.custom-entries.state tracks blocked domains │
│ Prevents removal of domains from install.sh │
│ │
│ Layer 6: nsswitch.conf Protection (NEW) │
│ ─────────────────────────────────────── │
│ Prevents bypass via /etc/nsswitch.conf manipulation │
│ Ensures "files" always appears in hosts: line before "dns" │
│ nsswitch-guard.path watches for changes │
│ Canonical copy at /usr/local/share/locked-nsswitch.conf │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## File Locations
| File | Purpose | Protection |
| ---------------------------------------------- | ----------------------------- | ----------------------- |
| `/etc/hosts` | Active hosts file | chattr +i, bind mount |
| `/usr/local/share/locked-hosts` | Canonical source of truth | chattr +i |
| `/etc/hosts.custom-entries.state` | Tracks custom blocked domains | chattr +i |
| `/etc/hosts.stevenblack` | Cached upstream hosts file | None |
| `/etc/nsswitch.conf` | Name service switch config | chattr +i, path watcher |
| `/usr/local/share/locked-nsswitch.conf` | Canonical nsswitch copy | chattr +i |
| `/usr/local/sbin/enforce-hosts.sh` | Restoration script | File permissions |
| `/usr/local/sbin/enforce-nsswitch.sh` | nsswitch enforcement | File permissions |
| `/usr/local/sbin/unlock-hosts` | Psychological unlock script | File permissions |
| `/etc/systemd/system/hosts-guard.path` | Path watcher unit | systemd |
| `/etc/systemd/system/hosts-guard.service` | Enforcement service | systemd |
| `/etc/systemd/system/hosts-bind-mount.service` | RO bind mount | systemd |
| `/etc/systemd/system/nsswitch-guard.path` | nsswitch watcher | systemd |
| `/etc/systemd/system/nsswitch-guard.service` | nsswitch enforce | systemd |
## Key Scripts
### hosts/install.sh
- Downloads StevenBlack hosts list (cached at `/etc/hosts.stevenblack`)
- Adds custom blocking entries (YouTube, etc.)
- Comments out allowed sites (4chan, Facebook)
- Runs protection check for custom entries
- Sets up initial immutable attribute
### hosts/guard/setup_hosts_guard.sh
Installs all protection layers:
- Creates canonical snapshot
- Installs enforce-hosts.sh and unlock-hosts scripts
- Enables systemd path watcher
- Enables bind mount service
- Installs shell history suppression hooks
### hosts/guard/enforce-hosts.sh
Called when tampering detected:
```bash
# Compares /etc/hosts to canonical
# If different: restores from canonical, logs event
# Re-applies chattr +i
```
### hosts/guard/psychological/unlock-hosts.sh
Legitimate edit workflow:
1. Prompts for reason (logged)
2. Stops protection services
3. Waits 45 seconds (cooling off)
4. Opens editor
5. Updates canonical if changes made
6. Re-enables all protections
## Pacman Integration
The pacman wrapper calls these hooks during package transactions:
- `/usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh` - Before transaction
- `/usr/local/share/hosts-guard/pacman-post-relock-hosts.sh` - After transaction
These temporarily unlock hosts for package manager operations.
## Common Tasks
### Adding a New Blocked Domain
1. Edit `hosts/install.sh`
2. Find the heredoc section after `# Custom blocking entries`
3. Add line: `0.0.0.0 newdomain.com`
4. Run: `sudo ~/linux-configuration/hosts/install.sh`
```bash
# Example: Block example.com
# In hosts/install.sh, add to heredoc:
0.0.0.0 example.com
0.0.0.0 www.example.com
```
### Allowing a Previously Blocked Domain
**This is intentionally difficult.** You must:
1. Remove entry from install.sh heredoc
2. Remove protection: `sudo chattr -i /etc/hosts.custom-entries.state`
3. Edit state file to remove domain
4. Re-run install.sh
### Checking Protection Status
```bash
# Check immutable attribute
lsattr /etc/hosts
# Should show: ----i--------e-- /etc/hosts
# Check services
systemctl status hosts-guard.path hosts-guard.service hosts-bind-mount.service
# Check canonical exists
ls -la /usr/local/share/locked-hosts
```
### Legitimate Editing
```bash
sudo /usr/local/sbin/unlock-hosts
# Enter reason when prompted
# Wait 45 seconds
# Edit in your $EDITOR
# Changes auto-saved to canonical
```
## nsswitch.conf Protection (Layer 6)
**Why this matters:** A user could bypass ALL /etc/hosts protections by simply editing `/etc/nsswitch.conf` and removing `files` from the `hosts:` line. This protection layer prevents that.
### How it works:
- `nsswitch-guard.path` watches `/etc/nsswitch.conf` for changes
- `nsswitch-guard.service` runs `enforce-nsswitch.sh` when triggered
- Canonical copy stored at `/usr/local/share/locked-nsswitch.conf`
- Validates that `hosts:` line contains `files` before `dns`
- Auto-restores from canonical if tampered
### Check nsswitch protection status:
```bash
lsattr /etc/nsswitch.conf
systemctl status nsswitch-guard.path
```
## Troubleshooting
### "Cannot modify /etc/hosts"
This is expected! Use the unlock script:
```bash
sudo /usr/local/sbin/unlock-hosts
```
### Path watcher not running
```bash
sudo systemctl start hosts-guard.path
sudo systemctl enable hosts-guard.path
```
### Bind mount preventing access
```bash
# Temporarily disable (not recommended)
sudo systemctl stop hosts-bind-mount.service
```
### Custom entries protection blocking install
The protection mechanism detected you're trying to remove previously blocked domains. This is intentional. To proceed, manually edit the state file (see "Allowing a Previously Blocked Domain").
## DO NOT
1. ❌ Edit `/etc/nsswitch.conf` to bypass hosts (this defeats the purpose)
2. ❌ Stop `hosts-guard.path` without understanding consequences
3. ❌ Delete `/usr/local/share/locked-hosts` (breaks restoration)
4. ❌ Remove entries from install.sh without updating state file
5. ❌ Use `chattr -i` without going through unlock-hosts

View File

@ -1,32 +0,0 @@
#!/bin/bash
# Template guard script to enforce canonical /etc/hosts
# This will be installed into /usr/local/sbin/enforce-hosts.sh by a setup script.
set -euo pipefail
CANONICAL_SOURCE="/usr/local/share/locked-hosts"
TARGET="/etc/hosts"
LOG_FILE="/var/log/hosts-guard.log"
log() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2
}
if [[ ! -f $CANONICAL_SOURCE ]]; then
log "Canonical hosts not found at $CANONICAL_SOURCE; aborting enforcement"
exit 0
fi
if ! cmp -s "$CANONICAL_SOURCE" "$TARGET"; then
log "Difference detected – restoring $TARGET from canonical copy"
cp "$CANONICAL_SOURCE" "$TARGET"
chmod 644 "$TARGET"
else
log "No drift detected (contents identical)"
fi
# Re-apply protective attributes: immutable first, then read-only bind mount handled by separate unit
chattr -i -a "$TARGET" 2> /dev/null || true
chattr +i "$TARGET" || log "Failed to set immutable attribute"
log "Enforcement complete"

View File

@ -1,109 +0,0 @@
#!/bin/bash
# Template guard script to enforce canonical /etc/nsswitch.conf
# Ensures "hosts:" line always contains "files" before "dns"
# This prevents bypassing /etc/hosts by removing "files" from nsswitch.conf
# Installed to /usr/local/sbin/enforce-nsswitch.sh by setup_hosts_guard.sh
set -euo pipefail
CANONICAL_SOURCE="/usr/local/share/locked-nsswitch.conf"
TARGET="/etc/nsswitch.conf"
LOG_FILE="/var/log/nsswitch-guard.log"
log() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2
}
# Function to validate that "hosts:" line has correct format
# Must contain "files" before "dns" (or "dns" not present)
validate_hosts_line() {
local line="$1"
# Check if "files" is present
if ! echo "$line" | grep -qw "files"; then
return 1
fi
# If dns is present, files must come before it
if echo "$line" | grep -qw "dns"; then
local files_pos dns_pos
files_pos=$(echo "$line" | grep -bo '\bfiles\b' | head -1 | cut -d: -f1)
dns_pos=$(echo "$line" | grep -bo '\bdns\b' | head -1 | cut -d: -f1)
if [[ -n "$files_pos" && -n "$dns_pos" && "$files_pos" -gt "$dns_pos" ]]; then
return 1
fi
fi
return 0
}
# Check current nsswitch.conf hosts line
current_hosts_line=$(grep '^hosts:' "$TARGET" 2>/dev/null || echo "")
if [[ -z "$current_hosts_line" ]]; then
log "CRITICAL: No 'hosts:' line found in $TARGET - restoring from canonical"
if [[ -f "$CANONICAL_SOURCE" ]]; then
chattr -i "$TARGET" 2>/dev/null || true
cp "$CANONICAL_SOURCE" "$TARGET"
chmod 644 "$TARGET"
chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET"
log "Restored $TARGET from canonical copy"
else
log "ERROR: Canonical source not found at $CANONICAL_SOURCE"
exit 1
fi
exit 0
fi
if ! validate_hosts_line "$current_hosts_line"; then
log "TAMPERING DETECTED: 'hosts:' line is invalid or missing 'files' before 'dns'"
log "Current line: $current_hosts_line"
if [[ -f "$CANONICAL_SOURCE" ]]; then
chattr -i "$TARGET" 2>/dev/null || true
cp "$CANONICAL_SOURCE" "$TARGET"
chmod 644 "$TARGET"
chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET"
log "Restored $TARGET from canonical copy"
else
log "ERROR: Canonical source not found at $CANONICAL_SOURCE"
# Emergency fix: add "files" back to hosts line
chattr -i "$TARGET" 2>/dev/null || true
if grep -q '^hosts:.*dns' "$TARGET"; then
sed -i 's/^hosts:\(.*\)dns/hosts:\1files dns/' "$TARGET"
elif grep -q '^hosts:.*resolve' "$TARGET"; then
sed -i 's/^hosts:\(.*\)resolve/hosts: files\1resolve/' "$TARGET"
else
sed -i 's/^hosts:/hosts: files/' "$TARGET"
fi
chattr +i "$TARGET" 2>/dev/null || true
log "Emergency fix applied: added 'files' to hosts line"
fi
exit 0
fi
# If canonical exists, check for any drift
if [[ -f "$CANONICAL_SOURCE" ]]; then
if ! cmp -s "$CANONICAL_SOURCE" "$TARGET"; then
log "Drift detected in $TARGET (but hosts line valid) - restoring canonical"
chattr -i "$TARGET" 2>/dev/null || true
cp "$CANONICAL_SOURCE" "$TARGET"
chmod 644 "$TARGET"
chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute"
log "Restored $TARGET from canonical copy"
else
log "No drift detected in $TARGET"
fi
else
log "Creating initial canonical snapshot"
mkdir -p "$(dirname "$CANONICAL_SOURCE")"
cp "$TARGET" "$CANONICAL_SOURCE"
chmod 644 "$CANONICAL_SOURCE"
chattr +i "$CANONICAL_SOURCE" 2>/dev/null || log "Failed to protect canonical copy"
fi
# Ensure immutable attribute is set
chattr -i "$TARGET" 2>/dev/null || true
chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET"
log "nsswitch.conf enforcement complete"

View File

@ -1,147 +0,0 @@
#!/bin/bash
# Guard script to enforce canonical /etc/systemd/resolved.conf
# Ensures ReadEtcHosts=yes and prevents DNS-over-TLS bypass of /etc/hosts
# Installed to /usr/local/sbin/enforce-resolved.sh by setup_hosts_guard.sh
set -euo pipefail
CANONICAL_SOURCE="/usr/local/share/locked-resolved.conf"
TARGET="/etc/systemd/resolved.conf"
DROPIN_DIR="/etc/systemd/resolved.conf.d"
LOG_FILE="/var/log/resolved-guard.log"
log() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2
}
# Validate that resolved.conf has correct settings to honour /etc/hosts
# Critical settings:
# ReadEtcHosts=yes — must be present and set to yes
# DNSOverTLS=no — must NOT be opportunistic/yes (bypasses local hosts)
validate_resolved() {
local file="$1"
# ReadEtcHosts must be explicitly yes (not commented, not "no")
local read_hosts
read_hosts=$(grep -E '^\s*ReadEtcHosts\s*=' "$file" 2>/dev/null | tail -1 |
sed 's/.*=\s*//' | tr -d '[:space:]')
if [[ "$read_hosts" != "yes" ]]; then
log "INVALID: ReadEtcHosts='$read_hosts' (expected 'yes') in $file"
return 1
fi
# DNSOverTLS must not be set to yes or opportunistic
local dot
dot=$(grep -E '^\s*DNSOverTLS\s*=' "$file" 2>/dev/null | tail -1 |
sed 's/.*=\s*//' | tr -d '[:space:]')
if [[ -n "$dot" && "$dot" != "no" ]]; then
log "INVALID: DNSOverTLS='$dot' (must be 'no' or commented out) in $file"
return 1
fi
return 0
}
# Remove any drop-in overrides that could bypass protections
enforce_no_dropins() {
if [[ -d "$DROPIN_DIR" ]]; then
local count
count=$(find "$DROPIN_DIR" -name '*.conf' -type f 2>/dev/null | wc -l)
if [[ "$count" -gt 0 ]]; then
log "TAMPERING: Found $count drop-in config(s) in $DROPIN_DIR — removing"
find "$DROPIN_DIR" -name '*.conf' -type f -delete
log "Removed all drop-in overrides"
fi
# Lock the directory itself to prevent new drop-ins
chattr +i "$DROPIN_DIR" 2>/dev/null || log "Failed to lock $DROPIN_DIR"
else
# Create and lock the directory to prevent creation with overrides
mkdir -p "$DROPIN_DIR"
chattr +i "$DROPIN_DIR" 2>/dev/null || log "Failed to lock $DROPIN_DIR"
log "Created and locked empty $DROPIN_DIR"
fi
}
# Main enforcement logic
log "Starting resolved.conf enforcement"
# 1. Handle drop-in overrides first
enforce_no_dropins
# 2. Check current resolved.conf
if [[ ! -f "$TARGET" ]]; then
log "CRITICAL: $TARGET does not exist"
if [[ -f "$CANONICAL_SOURCE" ]]; then
chattr -i "$TARGET" 2>/dev/null || true
cp "$CANONICAL_SOURCE" "$TARGET"
chmod 644 "$TARGET"
chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable on $TARGET"
log "Restored $TARGET from canonical copy"
else
log "ERROR: No canonical source at $CANONICAL_SOURCE — cannot restore"
exit 1
fi
fi
if ! validate_resolved "$TARGET"; then
log "TAMPERING DETECTED in $TARGET"
if [[ -f "$CANONICAL_SOURCE" ]]; then
chattr -i "$TARGET" 2>/dev/null || true
cp "$CANONICAL_SOURCE" "$TARGET"
chmod 644 "$TARGET"
chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable on $TARGET"
log "Restored $TARGET from canonical copy"
else
log "No canonical source — applying emergency fix"
chattr -i "$TARGET" 2>/dev/null || true
# Fix ReadEtcHosts
if grep -qE '^\s*ReadEtcHosts\s*=' "$TARGET"; then
sed -i -E 's/^\s*ReadEtcHosts\s*=.*/ReadEtcHosts=yes/' "$TARGET"
elif grep -q '^\[Resolve\]' "$TARGET"; then
sed -i '/^\[Resolve\]/a ReadEtcHosts=yes' "$TARGET"
else
printf '\n[Resolve]\nReadEtcHosts=yes\n' >>"$TARGET"
fi
# Fix DNSOverTLS
if grep -qE '^\s*DNSOverTLS\s*=' "$TARGET"; then
sed -i -E 's/^\s*DNSOverTLS\s*=.*/#DNSOverTLS=no/' "$TARGET"
fi
chattr +i "$TARGET" 2>/dev/null || true
log "Emergency fix applied"
fi
# Restart resolved to pick up changes
systemctl restart systemd-resolved 2>/dev/null || log "Failed to restart systemd-resolved"
exit 0
fi
# 3. If canonical exists, check for any drift
if [[ -f "$CANONICAL_SOURCE" ]]; then
if ! cmp -s "$CANONICAL_SOURCE" "$TARGET"; then
log "Drift detected in $TARGET — restoring canonical"
chattr -i "$TARGET" 2>/dev/null || true
cp "$CANONICAL_SOURCE" "$TARGET"
chmod 644 "$TARGET"
chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable"
log "Restored $TARGET from canonical copy"
systemctl restart systemd-resolved 2>/dev/null || log "Failed to restart systemd-resolved"
else
log "No drift detected in $TARGET"
fi
else
log "Creating initial canonical snapshot"
mkdir -p "$(dirname "$CANONICAL_SOURCE")"
cp "$TARGET" "$CANONICAL_SOURCE"
chmod 644 "$CANONICAL_SOURCE"
chattr +i "$CANONICAL_SOURCE" 2>/dev/null || log "Failed to protect canonical copy"
fi
# 4. Ensure immutable attribute is set on live file
chattr -i "$TARGET" 2>/dev/null || true
chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable on $TARGET"
log "resolved.conf enforcement complete"

View File

@ -1,14 +0,0 @@
[Unit]
Description=Bind mount /etc/hosts over itself as read-only (friction layer)
After=local-fs.target
Before=network.target
[Service]
Type=oneshot
ExecStart=/bin/mount --bind /etc/hosts /etc/hosts
ExecStart=/bin/mount -o remount,ro,bind /etc/hosts
ExecStartPost=/usr/bin/logger -t hosts-bind-mount "Hosts file bind-mounted read-only"
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View File

@ -1,9 +0,0 @@
[Unit]
Description=Watch /etc/hosts and trigger enforcement
[Path]
PathChanged=/etc/hosts
Unit=hosts-guard.service
[Install]
WantedBy=multi-user.target

View File

@ -1,12 +0,0 @@
[Unit]
Description=Enforce canonical /etc/hosts contents
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/enforce-hosts.sh
Nice=10
IOSchedulingClass=idle
[Install]
WantedBy=multi-user.target

View File

@ -1,50 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi; }
require_root "$@"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOKS_DIR="/etc/pacman.d/hooks"
install -d -m 755 "$HOOKS_DIR"
# Pre-transaction hook
cat > "$HOOKS_DIR/10-unlock-etc-hosts.hook" << 'HOOK'
[Trigger]
Operation = Upgrade
Operation = Install
Operation = Remove
Type = Package
Target = *
[Action]
Description = Temporarily unlocking /etc/hosts for transaction
When = PreTransaction
Exec = /bin/bash /usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh
NeedsTargets
HOOK
# Post-transaction hook
cat > "$HOOKS_DIR/90-relock-etc-hosts.hook" << 'HOOK'
[Trigger]
Operation = Upgrade
Operation = Install
Operation = Remove
Type = Package
Target = *
[Action]
Description = Re-locking /etc/hosts after transaction
When = PostTransaction
Exec = /bin/bash /usr/local/share/hosts-guard/pacman-post-relock-hosts.sh
NeedsTargets
HOOK
# Place helper scripts into a shared location
install -d -m 755 /usr/local/share/hosts-guard
install -m 755 "$SCRIPT_DIR/pacman-hooks/hosts-guard-common.sh" /usr/local/share/hosts-guard/
install -m 755 "$SCRIPT_DIR/pacman-hooks/pacman-pre-unlock-hosts.sh" /usr/local/share/hosts-guard/
install -m 755 "$SCRIPT_DIR/pacman-hooks/pacman-post-relock-hosts.sh" /usr/local/share/hosts-guard/
echo "Pacman hooks installed into $HOOKS_DIR."

View File

@ -1,9 +0,0 @@
[Unit]
Description=Watch /etc/nsswitch.conf for tampering (hosts bypass protection)
[Path]
PathChanged=/etc/nsswitch.conf
Unit=nsswitch-guard.service
[Install]
WantedBy=multi-user.target

View File

@ -1,12 +0,0 @@
[Unit]
Description=Enforce canonical /etc/nsswitch.conf (prevents hosts bypass)
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/enforce-nsswitch.sh
Nice=10
IOSchedulingClass=idle
[Install]
WantedBy=multi-user.target

View File

@ -1,115 +0,0 @@
#!/usr/bin/env bash
# Shared functions for hosts-guard pacman hooks
# This file is sourced by pacman-pre-unlock-hosts.sh and pacman-post-relock-hosts.sh
TARGET=/etc/hosts
NSSWITCH=/etc/nsswitch.conf
RESOLVED_CONF=/etc/systemd/resolved.conf
RESOLVED_DROPIN=/etc/systemd/resolved.conf.d
LOGTAG=hosts-guard-hook
require_root() {
if [[ $EUID -ne 0 ]]; then
echo "hosts-guard pacman hook must run as root" >&2
exit 1
fi
}
# Check if target has a read-only mount
is_ro_mount() {
findmnt -no OPTIONS -T "$TARGET" 2>/dev/null | grep -qw ro
}
# Count mount layers for the target
mount_layers_count() {
awk '$5=="/etc/hosts"{c++} END{print c+0}' /proc/self/mountinfo 2>/dev/null || echo 0
}
# Collapse all bind mount layers
collapse_mounts() {
local i=0
if command -v mountpoint >/dev/null 2>&1; then
while mountpoint -q "$TARGET"; do
umount -l "$TARGET" >/dev/null 2>&1 || break
i=$((i + 1))
((i > 20)) && break
done
else
local cnt
cnt=$(mount_layers_count)
while ((cnt > 1)); do
umount -l "$TARGET" >/dev/null 2>&1 || break
i=$((i + 1))
((i > 20)) && break
cnt=$(mount_layers_count)
done
fi
}
# Stop systemd units related to hosts guard
stop_units_if_present() {
local units=(hosts-bind-mount.service hosts-guard.path nsswitch-guard.path resolved-guard.path)
for u in "${units[@]}"; do
if command -v systemctl >/dev/null 2>&1; then
if systemctl list-unit-files 2>/dev/null | grep -q "^$u"; then
systemctl stop "$u" >/dev/null 2>&1 || true
fi
fi
done
}
# Remove immutable/append-only attributes from a file
_remove_attrs_for() {
local f="$1"
if [[ -e "$f" ]] && command -v lsattr >/dev/null 2>&1; then
chattr -ia "$f" >/dev/null 2>&1 || true
fi
}
# Remove immutable/append-only attributes from all guarded files
remove_host_attrs() {
_remove_attrs_for "$TARGET"
}
remove_all_guard_attrs() {
_remove_attrs_for "$TARGET"
_remove_attrs_for "$NSSWITCH"
_remove_attrs_for "$RESOLVED_CONF"
_remove_attrs_for "$RESOLVED_DROPIN"
}
# Apply immutable attribute to all guarded files
apply_immutable() {
if command -v chattr >/dev/null 2>&1; then
chattr +i "$TARGET" >/dev/null 2>&1 || true
chattr +i "$NSSWITCH" >/dev/null 2>&1 || true
chattr +i "$RESOLVED_CONF" >/dev/null 2>&1 || true
# Lock drop-in dir to prevent creation of override files
if [[ -d "$RESOLVED_DROPIN" ]]; then
chattr +i "$RESOLVED_DROPIN" >/dev/null 2>&1 || true
fi
fi
}
# Apply a single read-only bind mount layer
apply_ro_bind_mount() {
mount --bind "$TARGET" "$TARGET" >/dev/null 2>&1 || true
mount -o remount,ro,bind "$TARGET" >/dev/null 2>&1 || true
}
# Start all path watcher services
start_path_watcher() {
if command -v systemctl >/dev/null 2>&1; then
systemctl start hosts-guard.path >/dev/null 2>&1 || true
systemctl start nsswitch-guard.path >/dev/null 2>&1 || true
systemctl start resolved-guard.path >/dev/null 2>&1 || true
fi
}
# Log to system logger and run log file
log_hook() {
local phase="$1"
local state="$2"
logger -t "$LOGTAG" "$phase: $state"
echo "$(date -Is) $phase-$state" >>/run/hosts-guard-hook.log 2>/dev/null || true
}

View File

@ -1,42 +0,0 @@
#!/usr/bin/env bash
# pacman-post-relock-hosts.sh - Re-apply all guard protections after pacman
# Re-locks: /etc/hosts, /etc/nsswitch.conf, /etc/systemd/resolved.conf
set -euo pipefail
# Source shared functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=hosts-guard-common.sh
source "$SCRIPT_DIR/hosts-guard-common.sh"
require_root
ENFORCE=/usr/local/sbin/enforce-hosts.sh
ENFORCE_NSSWITCH=/usr/local/sbin/enforce-nsswitch.sh
ENFORCE_RESOLVED=/usr/local/sbin/enforce-resolved.sh
log_hook "post" "relocking(start)"
# Collapse any stacked mounts first
collapse_mounts
# Run enforcement scripts if available
if [[ -x $ENFORCE ]]; then
"$ENFORCE" >/dev/null 2>&1 || true
fi
if [[ -x $ENFORCE_NSSWITCH ]]; then
"$ENFORCE_NSSWITCH" >/dev/null 2>&1 || true
fi
if [[ -x $ENFORCE_RESOLVED ]]; then
"$ENFORCE_RESOLVED" >/dev/null 2>&1 || true
fi
# Apply protections (immutable on all guarded files)
apply_immutable
apply_ro_bind_mount
# Start all path watchers
start_path_watcher
log_hook "post" "relocking(done)"
exit 0

View File

@ -1,31 +0,0 @@
#!/usr/bin/env bash
# pacman-pre-unlock-hosts.sh - Temporarily unlock guarded config files before pacman
# Unlocks: /etc/hosts, /etc/nsswitch.conf, /etc/systemd/resolved.conf
set -euo pipefail
# Source shared functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=hosts-guard-common.sh
source "$SCRIPT_DIR/hosts-guard-common.sh"
require_root
log_hook "pre" "unlocking(start)"
# Remove protective attributes from all guarded files
remove_all_guard_attrs
# Stop guard services (hosts, nsswitch, resolved watchers)
stop_units_if_present
# Collapse any existing mount layers
collapse_mounts
# Ensure writable by remounting if still read-only
if is_ro_mount; then
mount -o remount,rw "$TARGET" >/dev/null 2>&1 || collapse_mounts
fi
log_hook "pre" "unlocking(done)"
exit 0

View File

@ -1,70 +0,0 @@
#!/bin/bash
# Guided, delayed unlock procedure to intentionally slow down impulsive edits.
set -euo pipefail
TARGET=/etc/hosts
CANON=/usr/local/share/locked-hosts
LOG=/var/log/hosts-guard.log
SYSLOG_TAG=hosts-unlock
EDITOR_CMD=${EDITOR:-nano}
DELAY_SECONDS=45
log() { printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG" >&2; }
require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi; }
require_root "$@"
echo "Reason for editing /etc/hosts (will be logged):" >&2
read -r -p "Enter reason: " REASON
if [[ -z ${REASON// /} ]]; then
echo "Empty reason not allowed. Aborting." >&2
exit 1
fi
log "Requested intentional /etc/hosts modification session. Reason: $REASON"
logger -t "$SYSLOG_TAG" "session_start user=${SUDO_USER:-$USER} reason='$REASON'"
echo "This action is logged. A cooling-off delay of $DELAY_SECONDS seconds applies." >&2
for s in hosts-bind-mount.service hosts-guard.path; do
if systemctl is-active --quiet "$s"; then
log "Stopping $s"
systemctl stop "$s" || true
fi
if systemctl is-enabled --quiet "$s"; then
log "(Will re-enable later)"
fi
done
# Remove attributes to allow edit
chattr -i -a "$TARGET" 2>/dev/null || true
echo "Countdown:" >&2
for ((i = DELAY_SECONDS; i > 0; i--)); do
printf '\rEdit window opens in %2d seconds... Press Ctrl+C to abort.' "$i" >&2
sleep 1
done
echo >&2
# Launch editor
sha_before=$(sha256sum "$TARGET" | awk '{print $1}')
"$EDITOR_CMD" "$TARGET"
sha_after=$(sha256sum "$TARGET" | awk '{print $1}')
if [[ $sha_before == "$sha_after" ]]; then
log "No changes made to $TARGET. Reason: $REASON"
logger -t "$SYSLOG_TAG" "no_change user=${SUDO_USER:-$USER} reason='$REASON'"
else
log "Changes detected. Updating canonical copy and re-enforcing. Reason: $REASON"
logger -t "$SYSLOG_TAG" "modified user=${SUDO_USER:-$USER} reason='$REASON'"
cp "$TARGET" "$CANON"
fi
# Re-run enforcement
/usr/local/sbin/enforce-hosts.sh || log "Enforcement script returned non-zero"
# Restart watchers and bind mount
systemctl start hosts-guard.path || true
systemctl start hosts-bind-mount.service || true
log "Unlock session complete. Reason: $REASON"
logger -t "$SYSLOG_TAG" "session_end user=${SUDO_USER:-$USER} reason='$REASON'"
echo "Done." >&2

View File

@ -1,10 +0,0 @@
[Unit]
Description=Watch /etc/systemd/resolved.conf for tampering (hosts bypass protection)
[Path]
PathChanged=/etc/systemd/resolved.conf
PathChanged=/etc/systemd/resolved.conf.d
Unit=resolved-guard.service
[Install]
WantedBy=multi-user.target

View File

@ -1,12 +0,0 @@
[Unit]
Description=Enforce canonical /etc/systemd/resolved.conf (prevents hosts bypass)
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/enforce-resolved.sh
Nice=10
IOSchedulingClass=idle
[Install]
WantedBy=multi-user.target

View File

@ -1,576 +0,0 @@
#!/bin/bash
# One-shot installer for hosts guard + psychological friction + read-only bind mount
# Layers implemented:
# - Canonical snapshot of /etc/hosts at /usr/local/share/locked-hosts
# - Enforcement script (/usr/local/sbin/enforce-hosts.sh)
# - Systemd path-based auto-revert (hosts-guard.path + hosts-guard.service)
# - Read-only bind mount (hosts-bind-mount.service)
# - Delayed edit workflow (/usr/local/sbin/unlock-hosts)
#
# This script is idempotent. Re-running updates installed artifacts safely.
#
# Usage:
# sudo ./setup_hosts_guard.sh [options]
# Options:
# --force-snapshot Overwrite canonical snapshot even if it exists
# --no-snapshot Skip creating canonical snapshot (assume already present)
# --skip-bind Do not enable read-only bind mount service
# --skip-path-watch Do not enable path watch auto-revert
# --delay N Set unlock delay seconds (default 45)
# --dry-run Show actions without performing changes
# --uninstall Remove installed units/scripts (does NOT restore original hosts)
# -h|--help Show help
#
# Exit codes:
# 0 success, 1 generic failure, 2 argument error
set -euo pipefail
######################################################################
# Defaults / Config
######################################################################
FORCE_SNAPSHOT=0
DO_SNAPSHOT=1
ENABLE_BIND=1
ENABLE_PATH=1
ENABLE_NSSWITCH=1
ENABLE_RESOLVED=1
UNINSTALL=0
DELAY=45
DRY_RUN=0
INSTALL_SHELL_HOOKS=1
INSTALL_AUDIT_RULE=1
ADD_ALIAS_STUB=1
######################################################################
# Helpers
######################################################################
msg() { printf '\e[1;32m[+]\e[0m %s\n' "$*"; }
note() { printf '\e[1;34m[i]\e[0m %s\n' "$*"; }
warn() { printf '\e[1;33m[!]\e[0m %s\n' "$*"; }
err() { printf '\e[1;31m[x]\e[0m %s\n' "$*" >&2; }
run() {
if [[ $DRY_RUN -eq 1 ]]; then
printf 'DRY-RUN:'
if [ "$#" -gt 0 ]; then
printf ' %q' "$@"
fi
printf '\n'
else
"$@"
fi
}
require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi; }
usage() { sed -n '1,/^set -euo pipefail/p' "$0" | sed 's/^# \{0,1\}//'; }
######################################################################
# Parse args
######################################################################
while [[ $# -gt 0 ]]; do
case "$1" in
--force-snapshot)
FORCE_SNAPSHOT=1
shift
;;
--no-snapshot)
DO_SNAPSHOT=0
shift
;;
--skip-bind)
ENABLE_BIND=0
shift
;;
--skip-path-watch)
ENABLE_PATH=0
shift
;;
--skip-nsswitch)
ENABLE_NSSWITCH=0
shift
;;
--skip-resolved)
ENABLE_RESOLVED=0
shift
;;
--delay)
DELAY=${2:-}
[[ -z ${DELAY} ]] && {
err '--delay requires value'
exit 2
}
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
--no-shell-hooks)
INSTALL_SHELL_HOOKS=0
shift
;;
--shell-hooks)
INSTALL_SHELL_HOOKS=1
shift
;;
--no-audit)
INSTALL_AUDIT_RULE=0
shift
;;
--audit)
INSTALL_AUDIT_RULE=1
shift
;;
--no-alias-stub)
ADD_ALIAS_STUB=0
shift
;;
--alias-stub)
ADD_ALIAS_STUB=1
shift
;;
--uninstall)
UNINSTALL=1
shift
;;
-h | --help)
usage
exit 0
;;
*)
err "Unknown argument: $1"
usage
exit 2
;;
esac
done
require_root "$@"
######################################################################
# Paths
######################################################################
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TEMPLATE_ENFORCE="$SCRIPT_DIR/enforce-hosts.sh"
TEMPLATE_UNLOCK="$SCRIPT_DIR/psychological/unlock-hosts.sh"
UNIT_GUARD_SERVICE="$SCRIPT_DIR/hosts-guard.service"
UNIT_GUARD_PATH="$SCRIPT_DIR/hosts-guard.path"
UNIT_BIND_SERVICE="$SCRIPT_DIR/hosts-bind-mount.service"
TEMPLATE_ENFORCE_NSSWITCH="$SCRIPT_DIR/enforce-nsswitch.sh"
UNIT_NSSWITCH_SERVICE="$SCRIPT_DIR/nsswitch-guard.service"
UNIT_NSSWITCH_PATH="$SCRIPT_DIR/nsswitch-guard.path"
TEMPLATE_ENFORCE_RESOLVED="$SCRIPT_DIR/enforce-resolved.sh"
UNIT_RESOLVED_SERVICE="$SCRIPT_DIR/resolved-guard.service"
UNIT_RESOLVED_PATH="$SCRIPT_DIR/resolved-guard.path"
INSTALL_ENFORCE="/usr/local/sbin/enforce-hosts.sh"
INSTALL_UNLOCK="/usr/local/sbin/unlock-hosts"
INSTALL_ENFORCE_NSSWITCH="/usr/local/sbin/enforce-nsswitch.sh"
INSTALL_ENFORCE_RESOLVED="/usr/local/sbin/enforce-resolved.sh"
CANON="/usr/local/share/locked-hosts"
CANON_NSSWITCH="/usr/local/share/locked-nsswitch.conf"
CANON_RESOLVED="/usr/local/share/locked-resolved.conf"
HOSTS="/etc/hosts"
NSSWITCH="/etc/nsswitch.conf"
RESOLVED_CONF="/etc/systemd/resolved.conf"
RESOLVED_DROPIN="/etc/systemd/resolved.conf.d"
# Shell hook destinations (user agnostic system-wide skeleton + etc profile.d)
ZSH_FILTER_SNIPPET="/etc/zsh/hosts_guard_history_filter.zsh"
BASH_FILTER_SNIPPET="/etc/profile.d/hosts_guard_history_filter.sh"
SYSTEMD_DIR="/etc/systemd/system"
######################################################################
# Uninstall flow
######################################################################
if [[ $UNINSTALL -eq 1 ]]; then
note "Uninstalling hosts guard components ( protections removed )"
for u in hosts-guard.path hosts-guard.service hosts-bind-mount.service nsswitch-guard.path nsswitch-guard.service resolved-guard.path resolved-guard.service; do
if systemctl list-unit-files | grep -q "^$u"; then
run systemctl disable --now "$u" || true
fi
done
for f in \
"$INSTALL_ENFORCE" \
"$INSTALL_UNLOCK" \
"$INSTALL_ENFORCE_NSSWITCH" \
"$INSTALL_ENFORCE_RESOLVED" \
"$SYSTEMD_DIR/hosts-guard.service" \
"$SYSTEMD_DIR/hosts-guard.path" \
"$SYSTEMD_DIR/hosts-bind-mount.service" \
"$SYSTEMD_DIR/nsswitch-guard.service" \
"$SYSTEMD_DIR/nsswitch-guard.path" \
"$SYSTEMD_DIR/resolved-guard.service" \
"$SYSTEMD_DIR/resolved-guard.path" \
"$ZSH_FILTER_SNIPPET" \
"$BASH_FILTER_SNIPPET"; do
if [[ -e $f ]]; then run rm -f "$f"; fi
done
note "Leaving canonical snapshots at $CANON, $CANON_NSSWITCH and $CANON_RESOLVED (remove manually if undesired)."
if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi
msg "Uninstall complete"
exit 0
fi
######################################################################
# Pre-flight checks
######################################################################
note "Script directory: $SCRIPT_DIR"
note "Repository root: $REPO_ROOT"
for req in "$TEMPLATE_ENFORCE" "$TEMPLATE_UNLOCK" "$UNIT_GUARD_SERVICE"; do
[[ -f $req ]] || {
err "Missing template: $req"
exit 1
}
done
if [[ ! -f $HOSTS ]]; then
err "$HOSTS does not exist. Run your hosts/install.sh first."
exit 1
fi
######################################################################
# Snapshot
######################################################################
if [[ $DO_SNAPSHOT -eq 1 ]]; then
if [[ -f $CANON && $FORCE_SNAPSHOT -eq 0 ]]; then
note "Canonical snapshot exists (use --force-snapshot to overwrite)"
else
msg "Creating canonical snapshot at $CANON"
run install -m 644 -D "$HOSTS" "$CANON"
fi
else
note "Skipping snapshot creation (--no-snapshot)"
fi
######################################################################
# Install scripts
######################################################################
msg "Installing enforcement script -> $INSTALL_ENFORCE"
run install -m 755 "$TEMPLATE_ENFORCE" "$INSTALL_ENFORCE"
msg "Installing unlock script -> $INSTALL_UNLOCK"
run install -m 755 "$TEMPLATE_UNLOCK" "$INSTALL_UNLOCK"
# Adjust delay in unlock script if different from default
if [[ $DELAY -ne 45 ]]; then
msg "Adjusting unlock delay to $DELAY seconds"
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY-RUN: would patch $INSTALL_UNLOCK"
else
# Replace DELAY_SECONDS=... line
sed -i -E "s/^(DELAY_SECONDS=).*/\\1$DELAY/" "$INSTALL_UNLOCK" || warn "Failed to adjust delay"
fi
fi
######################################################################
# Install shell history filters (optional)
######################################################################
if [[ $INSTALL_SHELL_HOOKS -eq 1 ]]; then
msg "Installing shell history suppression hooks for unlock command"
# Pattern matches commands invoking unlock-hosts (with or without sudo) & setup script force snapshot
# Zsh: use zshaddhistory function
if command -v zsh >/dev/null 2>&1; then
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY-RUN: would create $ZSH_FILTER_SNIPPET"
else
cat >"$ZSH_FILTER_SNIPPET" <<'ZEOF'
# Added by hosts guard setup – suppress unlock-hosts commands from Zsh history
autoload -Uz add-zsh-hook 2>/dev/null || true
_hosts_guard_history_filter() {
emulate -L zsh
setopt extendedglob
local line="$1"
local _pattern='(^|;|&&|\|\|)\s*(sudo\s+)?(/usr/local/sbin/)?unlock-hosts(\s|;|$)'
if [[ $line =~ ${_pattern} ]]; then
return 1
fi
return 0
}
if typeset -f add-zsh-hook >/dev/null 2>&1; then
add-zsh-hook zshaddhistory _hosts_guard_history_filter 2>/dev/null || true
else
zshaddhistory() { _hosts_guard_history_filter "$1"; }
fi
ZEOF
chmod 644 "$ZSH_FILTER_SNIPPET"
fi
fi
# Bash: rely on HISTCONTROL and PROMPT_COMMAND filter
if command -v bash >/dev/null 2>&1; then
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY-RUN: would create $BASH_FILTER_SNIPPET"
else
cat >"$BASH_FILTER_SNIPPET" <<'BEOF'
# Added by hosts guard setup – suppress unlock-hosts commands from Bash history
export HISTCONTROL=ignoredups:erasedups
_hosts_guard_hist_filter() {
local last_cmd
local _pattern='(^|;|&&|\|\|)\s*(sudo\s+)?(/usr/local/sbin/)?unlock-hosts(\s|;|$)'
last_cmd=$(history 1 2>/dev/null | sed -E 's/^ *[0-9]+ +//')
if [[ -n $last_cmd && $last_cmd =~ ${_pattern} ]]; then
local id
id=$(history 1 2>/dev/null | awk '{print $1}')
if [[ -n $id ]]; then history -d $id 2>/dev/null || true; fi
history -w 2>/dev/null || true
history -c || true
history -r 2>/dev/null || true
fi
}
case :${PROMPT_COMMAND-}: in
*:_hosts_guard_hist_filter:* ) ;;
* ) PROMPT_COMMAND="_hosts_guard_hist_filter${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;;
esac
BEOF
chmod 644 "$BASH_FILTER_SNIPPET"
fi
fi
else
note "Skipping shell history hooks (--no-shell-hooks)"
fi
######################################################################
# Add alias stub to discourage raw invocation (shell-level friction)
######################################################################
if [[ $ADD_ALIAS_STUB -eq 1 ]]; then
PROFILE_STUB="/etc/profile.d/hosts_guard_alias_stub.sh"
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY-RUN: would create $PROFILE_STUB"
else
cat >"$PROFILE_STUB" <<'ASTUB'
# Added by hosts guard setup – discourages casual use of unlock-hosts name
if command -v unlock-hosts >/dev/null 2>&1; then
alias unlock-hosts='command_not_found_handle 2>/dev/null || echo "Use: sudo /usr/local/sbin/unlock-hosts (logged & delayed)"'
fi
ASTUB
chmod 644 "$PROFILE_STUB"
fi
fi
######################################################################
# Audit rule to record executions (requires auditd)
######################################################################
if [[ $INSTALL_AUDIT_RULE -eq 1 ]]; then
if command -v auditctl >/dev/null 2>&1; then
audit_rule_str="-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock"
audit_rule_args=(-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock)
if auditctl -l 2>/dev/null | grep -Fq "/usr/local/sbin/unlock-hosts"; then
note "Audit rule already present"
else
run auditctl "${audit_rule_args[@]}" || warn "Failed to add audit rule (runtime)"
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY-RUN: would create /etc/audit/rules.d/hosts_unlock.rules"
else
echo "$audit_rule_str" >/etc/audit/rules.d/hosts_unlock.rules
fi
fi
else
warn "auditctl not found; skipping audit rule (install auditd to enable)"
fi
fi
######################################################################
# Install systemd units
######################################################################
msg "Deploying systemd units"
run install -m 644 "$UNIT_GUARD_SERVICE" "$SYSTEMD_DIR/hosts-guard.service"
run install -m 644 "$UNIT_GUARD_PATH" "$SYSTEMD_DIR/hosts-guard.path"
run install -m 644 "$UNIT_BIND_SERVICE" "$SYSTEMD_DIR/hosts-bind-mount.service"
run install -m 644 "$UNIT_NSSWITCH_SERVICE" "$SYSTEMD_DIR/nsswitch-guard.service"
run install -m 644 "$UNIT_NSSWITCH_PATH" "$SYSTEMD_DIR/nsswitch-guard.path"
run install -m 644 "$UNIT_RESOLVED_SERVICE" "$SYSTEMD_DIR/resolved-guard.service"
run install -m 644 "$UNIT_RESOLVED_PATH" "$SYSTEMD_DIR/resolved-guard.path"
if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi
######################################################################
# Enable / Start
######################################################################
if [[ $ENABLE_PATH -eq 1 ]]; then
msg "Enabling path watch (auto-revert)"
run systemctl enable --now hosts-guard.path
else
note "Skipping path watch (--skip-path-watch)"
fi
if [[ $ENABLE_BIND -eq 1 ]]; then
msg "Enabling read-only bind mount"
run systemctl enable --now hosts-bind-mount.service
else
note "Skipping bind mount (--skip-bind)"
fi
if [[ $ENABLE_NSSWITCH -eq 1 ]]; then
msg "Enabling nsswitch.conf protection (hosts bypass prevention)"
msg "Installing nsswitch enforcement script -> $INSTALL_ENFORCE_NSSWITCH"
run install -m 755 "$TEMPLATE_ENFORCE_NSSWITCH" "$INSTALL_ENFORCE_NSSWITCH"
# Ensure 'files' is present in the hosts line before snapshotting
if [[ -f "$NSSWITCH" ]]; then
hosts_line=$(grep '^hosts:' "$NSSWITCH" 2>/dev/null || echo "")
if [[ -n "$hosts_line" ]] && ! echo "$hosts_line" | grep -qw 'files'; then
msg "Adding 'files' to nsswitch.conf hosts line (was: $hosts_line)"
if echo "$hosts_line" | grep -qw 'resolve'; then
run sed -i 's/^hosts:\(.*\)resolve/hosts: files\1resolve/' "$NSSWITCH"
elif echo "$hosts_line" | grep -qw 'dns'; then
run sed -i 's/^hosts:\(.*\)dns/hosts:\1files dns/' "$NSSWITCH"
else
run sed -i 's/^hosts:/hosts: files/' "$NSSWITCH"
fi
msg "nsswitch.conf hosts line fixed: $(grep '^hosts:' "$NSSWITCH")"
fi
fi
# Create nsswitch canonical snapshot if needed
if [[ -f "$NSSWITCH" ]]; then
if [[ ! -f "$CANON_NSSWITCH" ]]; then
msg "Creating canonical nsswitch.conf snapshot at $CANON_NSSWITCH"
run cp "$NSSWITCH" "$CANON_NSSWITCH"
run chmod 644 "$CANON_NSSWITCH"
chattr +i "$CANON_NSSWITCH" 2>/dev/null || warn "Failed to protect canonical nsswitch copy"
fi
fi
run systemctl enable --now nsswitch-guard.path
# Perform initial nsswitch enforcement
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY-RUN: would run $INSTALL_ENFORCE_NSSWITCH"
else
"$INSTALL_ENFORCE_NSSWITCH" || warn "nsswitch enforcement returned non-zero"
fi
else
note "Skipping nsswitch protection (--skip-nsswitch)"
fi
if [[ $ENABLE_RESOLVED -eq 1 ]]; then
msg "Enabling resolved.conf protection (hosts bypass prevention)"
msg "Installing resolved enforcement script -> $INSTALL_ENFORCE_RESOLVED"
run install -m 755 "$TEMPLATE_ENFORCE_RESOLVED" "$INSTALL_ENFORCE_RESOLVED"
# Ensure ReadEtcHosts=yes in resolved.conf before snapshotting
if [[ -f "$RESOLVED_CONF" ]]; then
# `|| true` is REQUIRED: grep exits 1 when the key is absent, and under
# `set -euo pipefail` that aborts the whole script — silently skipping the
# very fix below that exists to handle the absent case. Without it the
# "key missing" branch is unreachable. (check_and_enable_services.sh
# already guards its copy of this grep the same way.)
local_read_hosts=$(grep -E '^\s*ReadEtcHosts\s*=' "$RESOLVED_CONF" 2>/dev/null |
tail -1 | sed 's/.*=\s*//' | tr -d '[:space:]') || true
if [[ "$local_read_hosts" != "yes" ]]; then
msg "Fixing ReadEtcHosts in resolved.conf (was: '$local_read_hosts')"
chattr -i "$RESOLVED_CONF" 2>/dev/null || true
if grep -qE '^\s*ReadEtcHosts\s*=' "$RESOLVED_CONF"; then
run sed -i -E 's/^\s*ReadEtcHosts\s*=.*/ReadEtcHosts=yes/' "$RESOLVED_CONF"
elif grep -q '^\[Resolve\]' "$RESOLVED_CONF"; then
run sed -i '/^\[Resolve\]/a ReadEtcHosts=yes' "$RESOLVED_CONF"
else
printf '\n[Resolve]\nReadEtcHosts=yes\n' >>"$RESOLVED_CONF"
fi
msg "resolved.conf ReadEtcHosts fixed: $(grep 'ReadEtcHosts' "$RESOLVED_CONF")"
fi
# Ensure DNSOverTLS is not set to yes or opportunistic
# `|| true`: same trap as above. DNSOverTLS is absent from a default
# resolved.conf, so without this the script aborted here on every run —
# right before resolved-guard.path was enabled, which is why that guard
# was never installed.
local_dot=$(grep -E '^\s*DNSOverTLS\s*=' "$RESOLVED_CONF" 2>/dev/null |
tail -1 | sed 's/.*=\s*//' | tr -d '[:space:]') || true
if [[ -n "$local_dot" && "$local_dot" != "no" ]]; then
msg "Disabling DNSOverTLS in resolved.conf (was: '$local_dot')"
chattr -i "$RESOLVED_CONF" 2>/dev/null || true
run sed -i -E 's/^\s*DNSOverTLS\s*=.*/#DNSOverTLS=no/' "$RESOLVED_CONF"
fi
fi
# Lock drop-in directory to prevent override files
if [[ -d "$RESOLVED_DROPIN" ]]; then
# Remove any existing drop-in overrides
local_count=$(find "$RESOLVED_DROPIN" -name '*.conf' -type f 2>/dev/null | wc -l)
if [[ "$local_count" -gt 0 ]]; then
warn "Removing $local_count drop-in override(s) from $RESOLVED_DROPIN"
find "$RESOLVED_DROPIN" -name '*.conf' -type f -delete
fi
chattr +i "$RESOLVED_DROPIN" 2>/dev/null || warn "Failed to lock $RESOLVED_DROPIN"
else
run mkdir -p "$RESOLVED_DROPIN"
chattr +i "$RESOLVED_DROPIN" 2>/dev/null || warn "Failed to lock $RESOLVED_DROPIN"
fi
# Create resolved.conf canonical snapshot if needed
if [[ -f "$RESOLVED_CONF" ]]; then
if [[ ! -f "$CANON_RESOLVED" ]]; then
msg "Creating canonical resolved.conf snapshot at $CANON_RESOLVED"
run cp "$RESOLVED_CONF" "$CANON_RESOLVED"
run chmod 644 "$CANON_RESOLVED"
chattr +i "$CANON_RESOLVED" 2>/dev/null || warn "Failed to protect canonical resolved copy"
fi
fi
run systemctl enable --now resolved-guard.path
# Perform initial resolved enforcement
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY-RUN: would run $INSTALL_ENFORCE_RESOLVED"
else
"$INSTALL_ENFORCE_RESOLVED" || warn "resolved enforcement returned non-zero"
fi
# Restart resolved to pick up corrected config.
# systemd-resolved is not necessarily the resolver: on this host it is MASKED
# and dnsmasq serves DNS. Restarting a masked unit fails, and under `set -e`
# that aborted the script on its very last step — after everything was already
# configured, so the run reported failure despite having succeeded.
if systemctl is-enabled systemd-resolved.service &>/dev/null; then
run systemctl restart systemd-resolved || warn "Failed to restart systemd-resolved"
else
note "systemd-resolved is masked/disabled — skipping restart (DNS is served elsewhere, e.g. dnsmasq)"
fi
else
note "Skipping resolved.conf protection (--skip-resolved)"
fi
msg "Performing initial hosts enforcement"
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY-RUN: would run $INSTALL_ENFORCE"
else
"$INSTALL_ENFORCE" || warn "Enforcement returned non-zero"
fi
######################################################################
# Summary
######################################################################
echo
msg "Hosts guard setup complete"
echo "Canonical hosts copy: $CANON"
echo "Canonical nsswitch copy: $CANON_NSSWITCH"
echo "Canonical resolved copy: $CANON_RESOLVED"
echo "Enforce script: $INSTALL_ENFORCE"
echo "nsswitch enforce: $INSTALL_ENFORCE_NSSWITCH"
echo "resolved enforce: $INSTALL_ENFORCE_RESOLVED"
echo "Unlock command: sudo $INSTALL_UNLOCK"
echo "Delay (seconds): $DELAY"
echo "Auto-revert path watch: $([[ $ENABLE_PATH -eq 1 ]] && echo enabled || echo disabled)"
echo "Read-only bind mount: $([[ $ENABLE_BIND -eq 1 ]] && echo enabled || echo disabled)"
echo "nsswitch protection: $([[ $ENABLE_NSSWITCH -eq 1 ]] && echo enabled || echo disabled)"
echo "resolved protection: $([[ $ENABLE_RESOLVED -eq 1 ]] && echo enabled || echo disabled)"
echo "Shell history suppression: $([[ $INSTALL_SHELL_HOOKS -eq 1 ]] && echo enabled || echo disabled)"
echo "Audit rule: $([[ $INSTALL_AUDIT_RULE -eq 1 ]] && echo enabled || echo disabled)"
echo "Alias stub: $([[ $ADD_ALIAS_STUB -eq 1 ]] && echo enabled || echo disabled)"
echo
echo "Test flow:"
echo " sudo sed -i '1s/.*/# tamper test/' /etc/hosts # Should revert automatically"
echo " sudo $INSTALL_UNLOCK # Intentional edit workflow"
echo
echo "Uninstall:"
echo " sudo $0 --uninstall"
echo "(Optional) Skip shell history hooks: --no-shell-hooks"
echo
exit 0

View File

@ -905,8 +905,9 @@ fi
# reverts unrelated unstaged edits.
#
# Enforcement does not depend on these sources being immutable: /etc/hosts
# itself is chattr +i, hosts-guard.path watches and re-enforces it via
# enforce-hosts.sh against the /usr/local/share/locked-hosts canonical snapshot,
# and hosts-bind-mount.service pins it. Editing these sources changes nothing
# until install.sh is re-run as root, which regenerates the guarded artifacts.
# itself is chattr +i, guard-lib's "hosts" file-guard instance (guardctl
# file-guard status hosts) watches and re-enforces it against its canonical
# snapshot, and the same instance's bind mount pins it. Editing these sources
# changes nothing until install.sh is re-run as root, which regenerates the
# guarded artifacts.
# ============================================================================