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.
- lockin_blocker-1.0.0/.github/workflows/ci.yml +29 -0
- lockin_blocker-1.0.0/.gitignore +32 -0
- lockin_blocker-1.0.0/AGENTS.md +45 -0
- lockin_blocker-1.0.0/CHANGELOG.md +5 -0
- lockin_blocker-1.0.0/LICENSE +21 -0
- lockin_blocker-1.0.0/PKG-INFO +136 -0
- lockin_blocker-1.0.0/README.md +106 -0
- lockin_blocker-1.0.0/install.sh +29 -0
- lockin_blocker-1.0.0/lockin/__init__.py +3 -0
- lockin_blocker-1.0.0/lockin/__main__.py +6 -0
- lockin_blocker-1.0.0/lockin/block.py +276 -0
- lockin_blocker-1.0.0/lockin/cli.py +142 -0
- lockin_blocker-1.0.0/lockin/data/rules.yaml +36 -0
- lockin_blocker-1.0.0/lockin/doctor.py +183 -0
- lockin_blocker-1.0.0/lockin/hosts.py +141 -0
- lockin_blocker-1.0.0/lockin/nat.py +94 -0
- lockin_blocker-1.0.0/lockin/scheduler.py +157 -0
- lockin_blocker-1.0.0/pyproject.toml +66 -0
- lockin_blocker-1.0.0/tests/.gitkeep +0 -0
- lockin_blocker-1.0.0/tests/__init__.py +1 -0
- lockin_blocker-1.0.0/tests/test_block.py +237 -0
- lockin_blocker-1.0.0/tests/test_cli.py +90 -0
- lockin_blocker-1.0.0/tests/test_hosts.py +200 -0
- lockin_blocker-1.0.0/tests/test_nat.py +107 -0
- lockin_blocker-1.0.0/tests/test_scheduler.py +104 -0
|
@@ -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,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
|
+
[](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
|
+
[](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,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)
|