lockin-blocker 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12", "3.13"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v5
21
+
22
+ - name: Install dependencies
23
+ run: uv sync --extra dev
24
+
25
+ - name: Lint
26
+ run: uv run ruff check .
27
+
28
+ - name: Test
29
+ run: uv run pytest -v
@@ -0,0 +1,32 @@
1
+ # Byte-compiled
2
+ __pycache__/
3
+ *.py[cod]
4
+
5
+ # Virtual environment
6
+ .venv/
7
+ venv/
8
+
9
+ # Build artifacts
10
+ dist/
11
+ *.egg-info/
12
+ build/
13
+
14
+ # Test / coverage
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .coverage
18
+ htmlcov/
19
+
20
+ # IDE / OS
21
+ .vscode/
22
+ .idea/
23
+ .DS_Store
24
+ Thumbs.db
25
+ *.swp
26
+ *.swo
27
+
28
+ # Project state (local, per-user)
29
+ state.json
30
+
31
+ # uv
32
+ uv.lock
@@ -0,0 +1,45 @@
1
+ # AGENTS.md — lockin
2
+
3
+ > For AI coding agents working on this project.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ uv sync --extra dev
9
+ uv run lockin --help
10
+ ```
11
+
12
+ ## Rules
13
+
14
+ 1. **TDD always.** Write the test first, watch it fail, then implement.
15
+ 2. **Conventional commits:** `feat(scope):`, `fix(scope):`, `test(scope):`, `refactor(scope):`, `chore:`.
16
+ 3. **Don't break the host.** Never run `nft delete table` or `sudo tee /etc/hosts` without explicit approval.
17
+ 4. **Verify before claiming done.** Run `uv run ruff check . && uv run pytest` after every task.
18
+
19
+ ## Commands
20
+
21
+ ```bash
22
+ uv run ruff check . # lint
23
+ uv run ruff check --fix . # auto-fix
24
+ uv run pytest # all tests
25
+ uv run pytest -v -k "name" # specific test
26
+ uv run lockin --help # CLI help
27
+ ```
28
+
29
+ ## Architecture
30
+
31
+ lockin uses `/etc/hosts` + nftables for system-level domain blocking. No proxy, no certs, no browser extensions.
32
+
33
+ | Module | Responsibility |
34
+ |--------|---------------|
35
+ | `lockin/cli.py` | Click commands, arg parsing, output formatting |
36
+ | `lockin/block.py` | Orchestration: start/stop/status via /etc/hosts + nftables |
37
+ | `lockin/hosts.py` | /etc/hosts manager — adds/removes blocked domain entries |
38
+ | `lockin/nat.py` | nftables/iptables QUIC sinkhole (UDP 443 drop) |
39
+ | `lockin/scheduler.py` | systemd/at/cron/thread unblock scheduling |
40
+
41
+ ## Dependencies
42
+
43
+ - `click` — CLI framework
44
+ - `pyyaml` — rules file parsing
45
+ - `ruff`, `pytest` — dev only
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-05-24
4
+
5
+ Initial release.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Madhusudan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockin-blocker
3
+ Version: 1.0.0
4
+ Summary: Block distracting websites on Linux — system-level blocking via /etc/hosts and nftables
5
+ Project-URL: Homepage, https://github.com/madhusudan-kulkarni/lockin
6
+ Project-URL: Repository, https://github.com/madhusudan-kulkarni/lockin
7
+ Project-URL: Issues, https://github.com/madhusudan-kulkarni/lockin/issues
8
+ Author-email: Madhusudan Kulkarni <hello@madhusudan.dev>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,focus,linux,nftables,productivity,self-control,website-blocker
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Internet
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: click>=8.1
25
+ Requires-Dist: pyyaml>=6.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.9; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # lockin
32
+
33
+ [![CI](https://github.com/madhusudan-kulkarni/lockin/actions/workflows/ci.yml/badge.svg)](https://github.com/madhusudan-kulkarni/lockin/actions)
34
+
35
+ Block distracting websites at the system level. No proxy, no certs, no extensions.
36
+
37
+ lockin writes blocked domains to `/etc/hosts` and blocks QUIC/HTTP3 via nftables. Every app on your system is affected — browsers, terminals, email clients — until the timer expires.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ curl -fsSL https://raw.githubusercontent.com/madhusudan-kulkarni/lockin/main/install.sh | bash
43
+ ```
44
+
45
+ Or with uv:
46
+
47
+ ```bash
48
+ uv tool install git+https://github.com/madhusudan-kulkarni/lockin.git
49
+ ```
50
+
51
+ Requires Python ≥3.11, uv, and nftables (or iptables). lockin uses `sudo` for firewall rules and `/etc/hosts` writes.
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ # Block social media for 2 hours
57
+ lockin start --rule social --for 2h
58
+
59
+ # Only allow coding sites
60
+ lockin start --rule coding --for 1h30m
61
+
62
+ # Block until 5 PM
63
+ lockin start --rule social --until 17:00
64
+
65
+ # Check remaining time
66
+ lockin status
67
+
68
+ # End early
69
+ lockin stop
70
+ ```
71
+
72
+ | Command | Purpose |
73
+ |---------|---------|
74
+ | `lockin start --rule <name> --for 30m` | Start a block |
75
+ | `lockin start --rule <name> --until 17:00` | Block until a specific time |
76
+ | `lockin stop` | End the current block |
77
+ | `lockin status` | Show active block and time remaining |
78
+ | `lockin list` | List all rules |
79
+ | `lockin cleanup` | Remove leftover entries from crashes |
80
+ | `lockin doctor` | Check installation health |
81
+
82
+ Durations: `30m`, `2h`, `1h30m`. Times: `17:00`, `9:30pm`.
83
+
84
+ ## How it works
85
+
86
+ `lockin start --rule social --for 2h` does three things:
87
+
88
+ 1. **Adds domains to `/etc/hosts`** — blocked domains resolve to `127.0.0.2` and `::1`. All apps see the block.
89
+ 2. **Blocks QUIC/HTTP3** via nftables — rejects UDP 443 so browsers fall back to TCP, where the hosts file takes effect.
90
+ 3. **Schedules cleanup** via systemd timer (falls back to at → cron → in-process). When time's up, hosts entries are removed and nftables rules are flushed.
91
+
92
+ Blocked sites show **connection refused**. This is intentional — no cert warnings, no browser errors, nothing to maintain.
93
+
94
+ ## Rules
95
+
96
+ Rules live in `~/.config/lockin/rules.yaml`. A default file is copied there on first run.
97
+
98
+ ```bash
99
+ $EDITOR ~/.config/lockin/rules.yaml
100
+ ```
101
+
102
+ ```yaml
103
+ whitelists:
104
+ coding:
105
+ - github.com
106
+ - stackoverflow.com
107
+ - docs.python.org
108
+
109
+ blacklists:
110
+ social:
111
+ - twitter.com
112
+ - facebook.com
113
+ - reddit.com
114
+ - youtube.com
115
+ ```
116
+
117
+ Whitelist mode blocks everything except the listed domains. Blacklist mode allows everything except the listed domains.
118
+
119
+ ## Troubleshooting
120
+
121
+ **Sites still blocked after `lockin stop`** — run `lockin cleanup`. The background timer may have failed without a TTY for sudo. `lockin status` auto-detects and cleans stale entries.
122
+
123
+ **QUIC bypassing the block** — restart your browser after starting a block. The nftables REJECT rule forces TCP fallback, but browsers cache QUIC capability.
124
+
125
+ **Permission denied** — your user needs sudo access for nftables and `/etc/hosts`.
126
+
127
+ ## Uninstall
128
+
129
+ ```bash
130
+ lockin cleanup
131
+ uv tool uninstall lockin
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,106 @@
1
+ # lockin
2
+
3
+ [![CI](https://github.com/madhusudan-kulkarni/lockin/actions/workflows/ci.yml/badge.svg)](https://github.com/madhusudan-kulkarni/lockin/actions)
4
+
5
+ Block distracting websites at the system level. No proxy, no certs, no extensions.
6
+
7
+ lockin writes blocked domains to `/etc/hosts` and blocks QUIC/HTTP3 via nftables. Every app on your system is affected — browsers, terminals, email clients — until the timer expires.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ curl -fsSL https://raw.githubusercontent.com/madhusudan-kulkarni/lockin/main/install.sh | bash
13
+ ```
14
+
15
+ Or with uv:
16
+
17
+ ```bash
18
+ uv tool install git+https://github.com/madhusudan-kulkarni/lockin.git
19
+ ```
20
+
21
+ Requires Python ≥3.11, uv, and nftables (or iptables). lockin uses `sudo` for firewall rules and `/etc/hosts` writes.
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ # Block social media for 2 hours
27
+ lockin start --rule social --for 2h
28
+
29
+ # Only allow coding sites
30
+ lockin start --rule coding --for 1h30m
31
+
32
+ # Block until 5 PM
33
+ lockin start --rule social --until 17:00
34
+
35
+ # Check remaining time
36
+ lockin status
37
+
38
+ # End early
39
+ lockin stop
40
+ ```
41
+
42
+ | Command | Purpose |
43
+ |---------|---------|
44
+ | `lockin start --rule <name> --for 30m` | Start a block |
45
+ | `lockin start --rule <name> --until 17:00` | Block until a specific time |
46
+ | `lockin stop` | End the current block |
47
+ | `lockin status` | Show active block and time remaining |
48
+ | `lockin list` | List all rules |
49
+ | `lockin cleanup` | Remove leftover entries from crashes |
50
+ | `lockin doctor` | Check installation health |
51
+
52
+ Durations: `30m`, `2h`, `1h30m`. Times: `17:00`, `9:30pm`.
53
+
54
+ ## How it works
55
+
56
+ `lockin start --rule social --for 2h` does three things:
57
+
58
+ 1. **Adds domains to `/etc/hosts`** — blocked domains resolve to `127.0.0.2` and `::1`. All apps see the block.
59
+ 2. **Blocks QUIC/HTTP3** via nftables — rejects UDP 443 so browsers fall back to TCP, where the hosts file takes effect.
60
+ 3. **Schedules cleanup** via systemd timer (falls back to at → cron → in-process). When time's up, hosts entries are removed and nftables rules are flushed.
61
+
62
+ Blocked sites show **connection refused**. This is intentional — no cert warnings, no browser errors, nothing to maintain.
63
+
64
+ ## Rules
65
+
66
+ Rules live in `~/.config/lockin/rules.yaml`. A default file is copied there on first run.
67
+
68
+ ```bash
69
+ $EDITOR ~/.config/lockin/rules.yaml
70
+ ```
71
+
72
+ ```yaml
73
+ whitelists:
74
+ coding:
75
+ - github.com
76
+ - stackoverflow.com
77
+ - docs.python.org
78
+
79
+ blacklists:
80
+ social:
81
+ - twitter.com
82
+ - facebook.com
83
+ - reddit.com
84
+ - youtube.com
85
+ ```
86
+
87
+ Whitelist mode blocks everything except the listed domains. Blacklist mode allows everything except the listed domains.
88
+
89
+ ## Troubleshooting
90
+
91
+ **Sites still blocked after `lockin stop`** — run `lockin cleanup`. The background timer may have failed without a TTY for sudo. `lockin status` auto-detects and cleans stale entries.
92
+
93
+ **QUIC bypassing the block** — restart your browser after starting a block. The nftables REJECT rule forces TCP fallback, but browsers cache QUIC capability.
94
+
95
+ **Permission denied** — your user needs sudo access for nftables and `/etc/hosts`.
96
+
97
+ ## Uninstall
98
+
99
+ ```bash
100
+ lockin cleanup
101
+ uv tool uninstall lockin
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ bold() { echo -e "\033[1m$1\033[0m"; }
5
+ green() { echo -e "\033[32m$1\033[0m"; }
6
+ red() { echo -e "\033[31m$1\033[0m"; }
7
+
8
+ need() {
9
+ if ! command -v "$1" &>/dev/null; then
10
+ red "Missing: $1 — $2"
11
+ exit 1
12
+ fi
13
+ }
14
+
15
+ need python3 "python >= 3.11"
16
+ need uv "curl -LsSf https://astral.sh/uv/install.sh | sh"
17
+ need nft "sudo dnf install nftables # Fedora\n sudo apt install nftables # Debian/Ubuntu"
18
+ echo ""
19
+
20
+ bold "Installing lockin..."
21
+ uv tool install --force git+https://github.com/madhusudan-kulkarni/lockin.git
22
+
23
+ green "✓ lockin installed."
24
+ echo ""
25
+ bold "Quick start:"
26
+ echo " lockin list"
27
+ echo " lockin start --rule coding --for 2h"
28
+ echo " lockin start --rule social --until 17:00"
29
+ echo " \$EDITOR ~/.config/lockin/rules.yaml # customize"
@@ -0,0 +1,3 @@
1
+ """lockin — Block distracting websites so you can focus."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m lockin."""
2
+
3
+ from lockin.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,276 @@
1
+ """Core orchestration: start/stop/status of blocking sessions.
2
+
3
+ lockin v2 uses /etc/hosts for domain blocking (replaces mitmproxy).
4
+ No proxy, no certs, no MITM. System-level blocking that catches all
5
+ applications, not just browsers.
6
+ """
7
+
8
+ import contextlib
9
+ import datetime
10
+ import json
11
+ import re
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ from lockin import nat, scheduler
18
+ from lockin.hosts import add_entries, get_entries, remove_entries
19
+
20
+ STATE_DIR = Path.home() / ".config" / "lockin"
21
+ STATE_FILE = STATE_DIR / "state.json"
22
+ DEFAULT_RULES_PATH = Path(__file__).parent / "data" / "rules.yaml"
23
+ USER_RULES_PATH = STATE_DIR / "rules.yaml"
24
+
25
+
26
+ def parse_duration(duration_str: str) -> int:
27
+ """Parse a human duration string into total minutes.
28
+
29
+ Examples: "30m" → 30, "2h" → 120, "1h30m" → 90.
30
+ """
31
+ pattern = r"^(?:(\d+)h)?(?:(\d+)m)?$"
32
+ match = re.match(pattern, duration_str)
33
+ if not match or (not match.group(1) and not match.group(2)):
34
+ raise ValueError(
35
+ f"Invalid duration: '{duration_str}'. "
36
+ "Use format like '30m', '2h', or '1h30m'."
37
+ )
38
+ hours = int(match.group(1) or 0)
39
+ minutes = int(match.group(2) or 0)
40
+ total = hours * 60 + minutes
41
+ if total <= 0:
42
+ raise ValueError(f"Duration must be positive: '{duration_str}'.")
43
+ return total
44
+
45
+
46
+ def load_rules(path: Path) -> dict:
47
+ """Load rules from a YAML file."""
48
+ with open(path) as f:
49
+ data = yaml.safe_load(f)
50
+ if data is None:
51
+ return {"whitelists": {}, "blacklists": {}}
52
+ return data
53
+
54
+
55
+ def get_rules_path() -> Path:
56
+ """Get the rules file path, preferring user config over default."""
57
+ if USER_RULES_PATH.exists():
58
+ return USER_RULES_PATH
59
+ if DEFAULT_RULES_PATH.exists():
60
+ return DEFAULT_RULES_PATH
61
+ raise FileNotFoundError("No rules file found.")
62
+
63
+
64
+ def ensure_user_rules() -> Path:
65
+ """Copy default rules to user config if not present."""
66
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
67
+ if not USER_RULES_PATH.exists() and DEFAULT_RULES_PATH.exists():
68
+ USER_RULES_PATH.write_text(DEFAULT_RULES_PATH.read_text())
69
+ return get_rules_path()
70
+
71
+
72
+ def find_rule(rule_name: str, rules: dict) -> tuple[str, str, list[str]]:
73
+ """Find a rule by name. Returns (name, block_type, addresses).
74
+
75
+ Raises ValueError if rule not found.
76
+ """
77
+ for section, block_type in [
78
+ ("whitelists", "whitelist"),
79
+ ("blacklists", "blacklist"),
80
+ ]:
81
+ section_rules = rules.get(section, {})
82
+ if rule_name in section_rules:
83
+ return rule_name, block_type, section_rules[rule_name]
84
+ available = []
85
+ for section in ("whitelists", "blacklists"):
86
+ available.extend(rules.get(section, {}).keys())
87
+ raise ValueError(
88
+ f"Rule '{rule_name}' not found. "
89
+ f"Available: {', '.join(sorted(available)) or 'none'}"
90
+ )
91
+
92
+
93
+ def list_rules(rules_path: Path) -> list[tuple[str, str, int]]:
94
+ """Return all rules as (name, block_type, address_count) tuples."""
95
+ rules = load_rules(rules_path)
96
+ result = []
97
+ for section, block_type in [
98
+ ("whitelists", "whitelist"),
99
+ ("blacklists", "blacklist"),
100
+ ]:
101
+ for name, addresses in rules.get(section, {}).items():
102
+ result.append((name, block_type, len(addresses)))
103
+ return sorted(result)
104
+
105
+
106
+ def get_state(path: Path | None = None) -> dict | None:
107
+ """Read the current block state, or None if no block is active."""
108
+ if path is None:
109
+ path = STATE_FILE
110
+ if not path.exists():
111
+ return None
112
+ with open(path) as f:
113
+ return json.load(f)
114
+
115
+
116
+ def save_state(state: dict, path: Path | None = None) -> None:
117
+ """Write block state to disk."""
118
+ if path is None:
119
+ path = STATE_FILE
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ with open(path, "w") as f:
122
+ json.dump(state, f, indent=2)
123
+
124
+
125
+ def clear_state(path: Path | None = None) -> None:
126
+ """Remove the state file."""
127
+ if path is None:
128
+ path = STATE_FILE
129
+ path.unlink(missing_ok=True)
130
+
131
+
132
+ def is_block_active(state: dict | None) -> bool:
133
+ """Check if a saved block is still in effect."""
134
+ if state is None:
135
+ return False
136
+ end = datetime.datetime.fromisoformat(state["end"])
137
+ return datetime.datetime.now() < end
138
+
139
+
140
+ def start_block(
141
+ rule_name: str,
142
+ duration_minutes: int | None = None,
143
+ until_time: str | None = None,
144
+ ) -> dict:
145
+ """Start a new block. Returns the state dict.
146
+
147
+ Raises:
148
+ ValueError: rule not found or invalid duration
149
+ RuntimeError: block already active
150
+ """
151
+ # Check for existing block
152
+ existing = get_state()
153
+ if is_block_active(existing):
154
+ assert existing is not None
155
+ end = datetime.datetime.fromisoformat(existing["end"])
156
+ raise RuntimeError(
157
+ f"Block '{existing['rule_name']}' already active "
158
+ f"until {end.strftime('%H:%M')}."
159
+ )
160
+
161
+ # Clean up stale state from a crash
162
+ if existing:
163
+ _cleanup_stale(existing)
164
+
165
+ # Compute block time
166
+ now = datetime.datetime.now().replace(second=0, microsecond=0)
167
+ if until_time:
168
+ end = datetime.datetime.strptime(until_time, "%H:%M").replace(
169
+ year=now.year, month=now.month, day=now.day
170
+ )
171
+ if end <= now:
172
+ end += datetime.timedelta(days=1)
173
+ else:
174
+ assert duration_minutes is not None
175
+ end = now + datetime.timedelta(minutes=duration_minutes)
176
+
177
+ # Load and find rule
178
+ rules_path = get_rules_path()
179
+ rules = load_rules(rules_path)
180
+ name, block_type, addresses = find_rule(rule_name, rules)
181
+
182
+ # 1. Write /etc/hosts entries (blocks at DNS level, all apps)
183
+ add_entries(addresses, name)
184
+
185
+ # 2. Drop QUIC/UDP 443 (prevents HTTP3 bypass)
186
+ nat.setup()
187
+
188
+ # 3. Schedule automatic cleanup — use absolute path so the
189
+ # systemd timer can find lockin when running as root.
190
+ lockin_bin = shutil.which("lockin")
191
+ if not lockin_bin:
192
+ lockin_bin = str(Path.home() / ".local" / "bin" / "lockin")
193
+ reset_cmd = f"{lockin_bin} reset"
194
+ method, job_id = scheduler.schedule(end, reset_cmd)
195
+
196
+ # 4. Save state
197
+ state = {
198
+ "rule_name": name,
199
+ "block_type": block_type,
200
+ "domains": addresses,
201
+ "start": now.isoformat(),
202
+ "end": end.isoformat(),
203
+ "schedule_method": method,
204
+ "schedule_id": job_id,
205
+ }
206
+ save_state(state)
207
+ return state
208
+
209
+
210
+ def stop_block() -> dict | None:
211
+ """Stop the active block. Returns the cleared state or None."""
212
+ state = get_state()
213
+ if state is None:
214
+ return None
215
+
216
+ _cleanup_stale(state)
217
+ clear_state()
218
+ return state
219
+
220
+
221
+ def _cleanup_stale(state: dict) -> None:
222
+ """Clean up all resources from a block — each step independent
223
+ so a failure in one doesn't prevent the others from running."""
224
+ with contextlib.suppress(Exception):
225
+ remove_entries()
226
+ with contextlib.suppress(Exception):
227
+ nat.reset()
228
+ with contextlib.suppress(Exception):
229
+ scheduler.cancel(
230
+ state.get("schedule_method", ""),
231
+ state.get("schedule_id", ""),
232
+ )
233
+
234
+
235
+ def get_status() -> str:
236
+ """Get a human-readable status of the current block."""
237
+ state = get_state()
238
+ if state is None or not is_block_active(state):
239
+ if state is not None:
240
+ _cleanup_stale(state)
241
+ clear_state()
242
+ # Auto-cleanup: if scheduler failed to clean hosts, do it now
243
+ if get_entries():
244
+ remove_entries()
245
+ nat.reset()
246
+ return "No active block."
247
+
248
+ end = datetime.datetime.fromisoformat(state["end"])
249
+ remaining = end - datetime.datetime.now()
250
+ if remaining.total_seconds() < 0:
251
+ remaining = datetime.timedelta(0)
252
+
253
+ hours, remainder = divmod(int(remaining.total_seconds()), 3600)
254
+ minutes = remainder // 60
255
+ time_str = f"{hours}h {minutes}m" if hours else f"{minutes}m"
256
+
257
+ # Verify /etc/hosts entries and nftables
258
+ hosts_entries = get_entries()
259
+ hosts_status = (
260
+ "active" if hosts_entries else "MISSING"
261
+ )
262
+ nat_status = "active" # We can't easily check nftables without sudo
263
+
264
+ lines = [
265
+ f"Rule: {state['rule_name']} ({state['block_type']})",
266
+ f"Remaining: {time_str}",
267
+ f"Ends at: {end.strftime('%H:%M:%S')}",
268
+ f"Hosts: {hosts_status} ({len(hosts_entries)} domains)",
269
+ f"Firewall: {nat_status} (QUIC blocked)",
270
+ ]
271
+ if hosts_status == "MISSING":
272
+ lines.append("")
273
+ lines.append(
274
+ "Hosts entries are missing. Run 'lockin stop' to clean up."
275
+ )
276
+ return "\n".join(lines)