Skip to content

Commit 2b4c7bf

Browse files
committed
feat(hosts): add shimkit hosts — /etc/hosts editor with atomic write + backups
Second of three new tools for v0.3.0 (ports → hosts → ssh per docs/plans/v0.3.0-new-tools.md). Reuses the atomic-write + timestamped-backup pattern from adguard.resolv.write_resolv_static. Surface: - `shimkit hosts show` — list entries (with --json). - `shimkit hosts add IP NAME` — append, idempotent, MODERATE prompt. Refuses non-IPv4/non-IPv6 with exit 1. - `shimkit hosts remove NAME` — remove all entries by hostname. - `shimkit hosts block / unblock DOMAIN` — convenience aliases. - `shimkit hosts apply-list SOURCE` — apply a StevenBlack-style list from a URL (https or http via stdlib urllib.request) or local file. SEVERE — `--confirm APPLY-LIST` required; capped at `tools.hosts.max_entries_per_apply` (default 5000). - `shimkit hosts rollback` — restore the latest `.bak-*`. Internals: - Pure parser in `editor.py` (text-in/model-out, no I/O) — round-trips blank lines, comments, and multi-name lines. - `HostsConfig` added to typed config (hosts_path, apply_list_severe_token, max_entries_per_apply, managed_block_marker). - Manager owns the CommandRunner shell-outs: `sudo install -m 0644 -o root <tmp> /etc/hosts` with a Python direct-write fallback for bind-mounted hosts files (containers). - `--path PATH` flag on every subcommand for testing + chroot edits. - URL fetch via stdlib urllib.request (scheme-gated to http/https; nosec B310 with a reason). Tests: 20 cases (272 total) — parser round-trip, IP validator, StevenBlack format, idempotent add, severe-token refusal + acceptance, max-entries cap, rollback restore, no-input refuse. Gates: ruff clean, mypy strict clean, bandit medium=0.
1 parent b43450c commit 2b4c7bf

11 files changed

Lines changed: 1307 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1818
uses `CommandRunner` only. Adds 19 tests (parser fixtures for
1919
both `lsof -F pcnuP` and `ss -tulnpH` output, plus CLI/manager
2020
coverage). [`docs/tools/ports.md`](docs/tools/ports.md).
21+
- `shimkit hosts``/etc/hosts` editor with atomic-write +
22+
timestamped backups. `show`, `add IP NAME`, `remove NAME`,
23+
`block DOMAIN` / `unblock DOMAIN` aliases, `apply-list SOURCE`
24+
(SEVERE — `--confirm APPLY-LIST`), `rollback`. Pure parser
25+
(`editor.py`) is text-in/model-out and unit-testable; manager
26+
follows the same `sudo install` → bind-mount-fallback pattern
27+
as `adguard.resolv.write_resolv_static`. URL fetch via stdlib
28+
`urllib.request` — no extra deps. Adds 20 tests (parser
29+
round-trip, IP validator, idempotent add, severe-token gate,
30+
cap enforcement, rollback restore).
31+
[`docs/tools/hosts.md`](docs/tools/hosts.md).
2132

2233
## [0.2.3] — 2026-05-14
2334

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ behaviour: [`docs/installation.md`](docs/installation.md).
4444
- **[`shimkit ports`](docs/tools/ports.md)** — list / kill the process
4545
holding a TCP or UDP port (macOS + Linux). `lsof` on macOS, `ss` on
4646
Linux. MODERATE prompt on `kill`; severe token for system-tier PIDs.
47+
- **[`shimkit hosts`](docs/tools/hosts.md)**`/etc/hosts` editor
48+
with atomic-write + timestamped backups. add / remove / block /
49+
unblock / apply-list (severe) / rollback. macOS + Linux.
4750

4851
Plus three utilities:
4952

docs/tools/hosts.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# shimkit hosts
2+
3+
`/etc/hosts` editor with atomic-write + timestamped backups. Same
4+
atomic-replace pattern as `adguard.resolv.write_resolv_static`: write
5+
to a temp file, then `sudo install -m 0644 -o root <tmp> /etc/hosts`.
6+
Bind-mounted hosts files (typical inside containers) fall through to a
7+
Python direct-write through the existing inode.
8+
9+
Pure parsing + mutation logic lives in
10+
`src/shimkit/tools/hosts/editor.py` (no I/O), so the model is
11+
unit-testable without touching the system hosts file.
12+
13+
## Commands
14+
15+
| Command | Purpose |
16+
|----------------------------------------|--------------------------------------------------------------------|
17+
| `shimkit hosts` | Interactive menu (list / rollback). |
18+
| `shimkit hosts show` | Print every entry. |
19+
| `shimkit hosts add IP NAME` | Append. Idempotent. MODERATE prompt. |
20+
| `shimkit hosts remove NAME` | Remove every entry whose hostname matches. MODERATE prompt. |
21+
| `shimkit hosts block DOMAIN` | Alias for `add 127.0.0.1 DOMAIN`. |
22+
| `shimkit hosts unblock DOMAIN` | Alias for `remove DOMAIN`. |
23+
| `shimkit hosts apply-list SOURCE` | Apply a StevenBlack-style list. **SEVERE** — token required. |
24+
| `shimkit hosts rollback` | Restore the most recent backup. |
25+
26+
`SOURCE` for `apply-list` is either `http(s)://...` (fetched via
27+
stdlib `urllib.request`, no extra deps) or a local file path.
28+
29+
Universal flags (`--quiet`, `--verbose`, `--log-file`, `--no-color`,
30+
`--color`, `--no-input`) go before the subcommand. Per-command flags
31+
(`--dry-run`, `--yes`, `--force`, `--confirm`, `--path`) go after.
32+
33+
## Safety + the SEVERE tier
34+
35+
`apply-list` is the only severe-tier command. It can write thousands
36+
of entries at once, so the default token is `APPLY-LIST`:
37+
38+
```bash
39+
shimkit hosts apply-list https://example.com/list.txt --confirm APPLY-LIST
40+
```
41+
42+
The size cap (`tools.hosts.max_entries_per_apply`, default `5000`)
43+
refuses lists bigger than the threshold. Raise it in
44+
`~/.config/shimkit/shimkit.json` if you really want the full
45+
StevenBlack-extended list.
46+
47+
`add` / `remove` / `block` / `unblock` are MODERATE-tier — they
48+
prompt `[y/N]` by default; `--yes` / `--force` skip; `--no-input`
49+
refuses with exit 1.
50+
51+
## Atomic-write + backup
52+
53+
Every mutator follows the same sequence:
54+
55+
1. Parse the current file.
56+
2. Compute the new content in-memory.
57+
3. `sudo cp -a /etc/hosts /etc/hosts.bak-YYYYMMDDHHMMSS`.
58+
4. Write to a temp file, then `sudo install -m 0644 -o root` over
59+
the target.
60+
5. If `install` fails (typical inside container bind-mounts), fall
61+
back to a Python direct-write through the existing inode —
62+
requires the process is already root.
63+
64+
`rollback` restores the latest `*.bak-*` file.
65+
66+
## JSON output
67+
68+
```bash
69+
$ shimkit hosts show --json
70+
{
71+
"ts": "...",
72+
"tool": "hosts",
73+
"step": "show",
74+
"status": "ok",
75+
"data": {
76+
"path": "/etc/hosts",
77+
"entries": [
78+
{"ip": "127.0.0.1", "name": "localhost", "comment": null},
79+
{"ip": "::1", "name": "localhost", "comment": null}
80+
]
81+
}
82+
}
83+
```
84+
85+
## Configuration
86+
87+
```json
88+
{
89+
"tools": {
90+
"hosts": {
91+
"hosts_path": "/etc/hosts",
92+
"apply_list_severe_token": "APPLY-LIST",
93+
"max_entries_per_apply": 5000,
94+
"managed_block_marker": "# === shimkit-managed ==="
95+
}
96+
}
97+
}
98+
```
99+
100+
`--path PATH` overrides `hosts_path` for one invocation — useful for
101+
testing or for editing a chroot's hosts file from outside.
102+
103+
## Exit codes
104+
105+
| Code | Meaning |
106+
|-----:|------------------------------------------------------|
107+
| 0 | success / no-op (entry already present / not present)|
108+
| 1 | generic failure (invalid IP, no backup, prompt cancelled, severe-token missing) |
109+
| 2 | Typer usage error |
110+
| 69 | EX_UNAVAILABLE — wrong platform or hosts file missing |
111+
| 130 | SIGINT |
112+
113+
## Platform support
114+
115+
| Platform | Status |
116+
|----------|--------|
117+
| macOS | ✓ — `/etc/hosts` lives in the same place; same atomic-replace path. |
118+
| Linux ||
119+
| WSL | ✓ (Linux path). |
120+
| Windows | ✗ — out of charter. |

src/shimkit/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from shimkit.tools.adguard.commands import adguard_app
1818
from shimkit.tools.dns.commands import dns_app
1919
from shimkit.tools.docker_clean.commands import docker_clean_app
20+
from shimkit.tools.hosts.commands import hosts_app
2021
from shimkit.tools.java.commands import java_app
2122
from shimkit.tools.ports.commands import ports_app
2223
from shimkit.tools.shell.commands import shell_app
@@ -36,6 +37,7 @@
3637
app.add_typer(adguard_app)
3738
app.add_typer(docker_clean_app)
3839
app.add_typer(ports_app)
40+
app.add_typer(hosts_app)
3941

4042

4143
# --- config -----------------------------------------------------------------

src/shimkit/config/defaults.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@
114114
"default_signal": "TERM",
115115
"init_pid_severe_token": "KILL-INIT",
116116
"system_pid_threshold": 100
117+
},
118+
"hosts": {
119+
"hosts_path": "/etc/hosts",
120+
"apply_list_severe_token": "APPLY-LIST",
121+
"max_entries_per_apply": 5000,
122+
"managed_block_marker": "# === shimkit-managed ==="
117123
}
118124
},
119125
"package_managers": {

src/shimkit/config/schema.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,28 @@ class PortsConfig(_StrictModel):
163163
system_pid_threshold: int = Field(default=100, ge=1, le=65535)
164164

165165

166+
class HostsConfig(_StrictModel):
167+
"""/etc/hosts editor — `shimkit hosts`."""
168+
169+
hosts_path: str = "/etc/hosts"
170+
apply_list_severe_token: str = "APPLY-LIST"
171+
# Cap the size of a single `apply-list` call so a bad URL doesn't
172+
# explode /etc/hosts. Tunable for power users who DO want huge
173+
# ad-block lists; default is conservative.
174+
max_entries_per_apply: int = Field(default=5000, ge=1, le=1_000_000)
175+
# Marker we insert above shimkit-managed entries so future runs can
176+
# find and update them without disturbing user-authored lines.
177+
managed_block_marker: str = "# === shimkit-managed ==="
178+
179+
166180
class ToolsConfig(_StrictModel):
167181
java: JavaConfig
168182
shell: ShellToolConfig
169183
dns: DnsConfig = Field(default_factory=DnsConfig)
170184
adguard: AdGuardConfig = Field(default_factory=AdGuardConfig)
171185
docker_clean: DockerCleanConfig = Field(default_factory=DockerCleanConfig)
172186
ports: PortsConfig = Field(default_factory=PortsConfig)
187+
hosts: HostsConfig = Field(default_factory=HostsConfig)
173188

174189

175190
class PackageManagerEntry(_StrictModel):
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""``/etc/hosts`` editor with atomic-write + backups — ``shimkit hosts``.
2+
3+
Add / remove / block / unblock individual entries, or apply a
4+
StevenBlack-style block list. Every mutator writes through a temp
5+
file (``install -m 644``) and creates a timestamped backup so
6+
``shimkit hosts rollback`` restores cleanly.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from .editor import Entry, HostsFile
12+
from .manager import HostsManager
13+
14+
__all__ = ["Entry", "HostsFile", "HostsManager"]

0 commit comments

Comments
 (0)