macblock 0.2.1__py3-none-any.whl

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.
macblock/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("macblock")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
7
+
8
+ __all__ = ["__version__"]
macblock/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from macblock.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
macblock/blocklists.py ADDED
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import urllib.request
6
+ from pathlib import Path
7
+
8
+ from macblock.constants import (
9
+ APP_LABEL,
10
+ DEFAULT_BLOCKLIST_SOURCE,
11
+ SYSTEM_BLACKLIST_FILE,
12
+ SYSTEM_BLOCKLIST_FILE,
13
+ SYSTEM_RAW_BLOCKLIST_FILE,
14
+ SYSTEM_STATE_FILE,
15
+ SYSTEM_WHITELIST_FILE,
16
+ VAR_DB_DNSMASQ_PID,
17
+ BLOCKLIST_SOURCES,
18
+ )
19
+ from macblock.launchd import kickstart, service_exists
20
+ from macblock.errors import MacblockError
21
+ from macblock.exec import run
22
+ from macblock.fs import atomic_write_text
23
+ from macblock.state import load_state, replace_state, save_state_atomic
24
+ from macblock.ui import (
25
+ dim,
26
+ green,
27
+ result_success,
28
+ Spinner,
29
+ step_done,
30
+ SYMBOL_ACTIVE,
31
+ )
32
+
33
+
34
+ _domain_re = re.compile(
35
+ r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$",
36
+ re.IGNORECASE,
37
+ )
38
+
39
+
40
+ def normalize_domain(domain: str) -> str:
41
+ d = domain.strip().lower().strip(".")
42
+ if not d:
43
+ raise MacblockError("invalid domain")
44
+ if not _domain_re.match(d):
45
+ raise MacblockError("invalid domain")
46
+ return d
47
+
48
+
49
+ def _read_lines(path: Path) -> list[str]:
50
+ if not path.exists():
51
+ return []
52
+ out: list[str] = []
53
+ for line in path.read_text(encoding="utf-8").splitlines():
54
+ s = line.strip()
55
+ if not s or s.startswith("#"):
56
+ continue
57
+ out.append(s)
58
+ return out
59
+
60
+
61
+ def _parse_hosts_domains(text: str) -> set[str]:
62
+ domains: set[str] = set()
63
+
64
+ for raw_line in text.splitlines():
65
+ line = raw_line.strip()
66
+ if not line or line.startswith("#"):
67
+ continue
68
+ if "#" in line:
69
+ line = line.split("#", 1)[0].strip()
70
+ parts = line.split()
71
+ if len(parts) < 2:
72
+ continue
73
+
74
+ for token in parts[1:]:
75
+ try:
76
+ d = normalize_domain(token)
77
+ except MacblockError:
78
+ continue
79
+ if d in {"localhost", "localhost.localdomain"}:
80
+ continue
81
+ domains.add(d)
82
+
83
+ return domains
84
+
85
+
86
+ def compile_blocklist(
87
+ raw_path: Path, whitelist_path: Path, blacklist_path: Path, out_path: Path
88
+ ) -> int:
89
+ raw = raw_path.read_text(encoding="utf-8") if raw_path.exists() else ""
90
+ base = _parse_hosts_domains(raw)
91
+
92
+ allow = {normalize_domain(x) for x in _read_lines(whitelist_path)}
93
+ deny = {normalize_domain(x) for x in _read_lines(blacklist_path)}
94
+
95
+ final = (base - allow) | deny
96
+
97
+ lines = [f"server=/{d}/" for d in sorted(final)]
98
+ atomic_write_text(out_path, "\n".join(lines) + ("\n" if lines else ""), mode=0o644)
99
+
100
+ return len(final)
101
+
102
+
103
+ _MAX_BLOCKLIST_BYTES = 20 * 1024 * 1024
104
+
105
+
106
+ def _download(url: str, *, expected_sha256: str | None = None) -> str:
107
+ req = urllib.request.Request(
108
+ url,
109
+ headers={"User-Agent": "macblock/0.0.0"},
110
+ )
111
+
112
+ hasher = hashlib.sha256()
113
+ chunks: list[bytes] = []
114
+ total = 0
115
+
116
+ with urllib.request.urlopen(req, timeout=30) as resp:
117
+ while True:
118
+ chunk = resp.read(64 * 1024)
119
+ if not chunk:
120
+ break
121
+ total += len(chunk)
122
+ if total > _MAX_BLOCKLIST_BYTES:
123
+ raise MacblockError(
124
+ f"blocklist too large (>{_MAX_BLOCKLIST_BYTES} bytes)"
125
+ )
126
+ hasher.update(chunk)
127
+ chunks.append(chunk)
128
+
129
+ if expected_sha256 is not None:
130
+ expected = expected_sha256.strip().lower()
131
+ actual = hasher.hexdigest()
132
+ if expected != actual:
133
+ raise MacblockError(f"sha256 mismatch: expected {expected}, got {actual}")
134
+
135
+ return b"".join(chunks).decode("utf-8", errors="replace")
136
+
137
+
138
+ def reload_dnsmasq() -> None:
139
+ label = f"{APP_LABEL}.dnsmasq"
140
+ if service_exists(label):
141
+ kickstart(label)
142
+ return
143
+
144
+ if VAR_DB_DNSMASQ_PID.exists():
145
+ try:
146
+ pid = int(VAR_DB_DNSMASQ_PID.read_text(encoding="utf-8").strip())
147
+ except Exception:
148
+ pid = 0
149
+ if pid > 1:
150
+ run(["/bin/kill", "-HUP", str(pid)])
151
+
152
+
153
+ def list_blocklist_sources() -> int:
154
+ st = load_state(SYSTEM_STATE_FILE)
155
+ current = st.blocklist_source or DEFAULT_BLOCKLIST_SOURCE
156
+
157
+ print()
158
+ for key in sorted(BLOCKLIST_SOURCES.keys()):
159
+ if key == current:
160
+ print(
161
+ f" {green(SYMBOL_ACTIVE)} {green(key)} - {BLOCKLIST_SOURCES[key]['name']}"
162
+ )
163
+ else:
164
+ print(f" {key} - {dim(str(BLOCKLIST_SOURCES[key]['name']))}")
165
+ print()
166
+
167
+ return 0
168
+
169
+
170
+ def set_blocklist_source(source: str) -> int:
171
+ src = source.strip()
172
+
173
+ if not src:
174
+ raise MacblockError("source is required")
175
+
176
+ if not (src.startswith("https://") or src in BLOCKLIST_SOURCES):
177
+ raise MacblockError("unknown source")
178
+
179
+ st = load_state(SYSTEM_STATE_FILE)
180
+ save_state_atomic(
181
+ SYSTEM_STATE_FILE,
182
+ replace_state(st, blocklist_source=src),
183
+ )
184
+
185
+ step_done(f"Blocklist source set to {src}")
186
+ print()
187
+ return 0
188
+
189
+
190
+ def update_blocklist(source: str | None = None, sha256: str | None = None) -> int:
191
+ st = load_state(SYSTEM_STATE_FILE)
192
+ chosen = source or st.blocklist_source or DEFAULT_BLOCKLIST_SOURCE
193
+
194
+ if chosen.startswith("https://"):
195
+ url = chosen
196
+ source_name = "custom URL"
197
+ elif chosen in BLOCKLIST_SOURCES:
198
+ url = str(BLOCKLIST_SOURCES[chosen]["url"])
199
+ source_name = chosen
200
+ else:
201
+ raise MacblockError("unknown source")
202
+
203
+ print()
204
+
205
+ # Download with spinner
206
+ with Spinner(f"Downloading blocklist ({source_name})") as spinner:
207
+ try:
208
+ raw = _download(url, expected_sha256=sha256)
209
+ except Exception as e:
210
+ spinner.fail(f"Download failed: {e}")
211
+ raise
212
+
213
+ if not raw.strip():
214
+ spinner.fail("Downloaded blocklist is empty")
215
+ raise MacblockError("downloaded blocklist is empty")
216
+
217
+ head = raw.lstrip()[:200].lower()
218
+ if (
219
+ head.startswith("<!doctype html")
220
+ or head.startswith("<html")
221
+ or "<html" in head
222
+ ):
223
+ spinner.fail("Downloaded file looks like HTML, not a blocklist")
224
+ raise MacblockError("downloaded blocklist looks like HTML")
225
+
226
+ spinner.succeed("Downloaded blocklist")
227
+
228
+ # Parse and compile
229
+ count = 0
230
+ with Spinner("Compiling blocklist") as spinner:
231
+ count_raw = len(_parse_hosts_domains(raw))
232
+ if count_raw < 1000:
233
+ spinner.warn(f"Blocklist looks small ({count_raw} domains)")
234
+ else:
235
+ atomic_write_text(SYSTEM_RAW_BLOCKLIST_FILE, raw, mode=0o644)
236
+ count = compile_blocklist(
237
+ SYSTEM_RAW_BLOCKLIST_FILE,
238
+ SYSTEM_WHITELIST_FILE,
239
+ SYSTEM_BLACKLIST_FILE,
240
+ SYSTEM_BLOCKLIST_FILE,
241
+ )
242
+ spinner.succeed(f"Compiled {count:,} domains")
243
+
244
+ # Save state
245
+ save_state_atomic(
246
+ SYSTEM_STATE_FILE,
247
+ replace_state(st, blocklist_source=chosen),
248
+ )
249
+
250
+ # Reload dnsmasq
251
+ with Spinner("Reloading dnsmasq") as spinner:
252
+ try:
253
+ reload_dnsmasq()
254
+ spinner.succeed("Reloaded dnsmasq")
255
+ except Exception:
256
+ spinner.warn("Could not reload dnsmasq (may need restart)")
257
+
258
+ result_success(f"Blocklist updated: {count:,} domains blocked")
259
+ print()
260
+ return 0
macblock/cli.py ADDED
@@ -0,0 +1,323 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import os
5
+ import shutil
6
+ import sys
7
+
8
+ from macblock import __version__
9
+ from macblock.errors import MacblockError, PrivilegeError, UnsupportedPlatformError
10
+ from macblock.install import do_install, do_uninstall
11
+ from macblock.blocklists import (
12
+ list_blocklist_sources,
13
+ set_blocklist_source,
14
+ update_blocklist,
15
+ )
16
+ from macblock.control import do_disable, do_enable, do_pause, do_resume
17
+ from macblock.doctor import run_diagnostics
18
+ from macblock.dns_test import test_domain
19
+ from macblock.help import show_main_help, show_command_help
20
+ from macblock.lists import (
21
+ add_blacklist,
22
+ add_whitelist,
23
+ list_blacklist,
24
+ list_whitelist,
25
+ remove_blacklist,
26
+ remove_whitelist,
27
+ )
28
+ from macblock.platform import is_root, require_macos
29
+ from macblock.status import show_status
30
+
31
+
32
+ def _has_help_flag(args: list[str]) -> bool:
33
+ """Check if args contain help flags."""
34
+ return "-h" in args or "--help" in args
35
+
36
+
37
+ def _remove_help_flags(args: list[str]) -> list[str]:
38
+ """Remove help flags from args."""
39
+ return [a for a in args if a not in ("-h", "--help")]
40
+
41
+
42
+ def _get_help_context(args: list[str]) -> str | None:
43
+ """Extract command context for help display.
44
+
45
+ Returns the command (and subcommand if applicable) that help was requested for.
46
+ """
47
+ clean = _remove_help_flags(args)
48
+ if not clean:
49
+ return None
50
+
51
+ cmd = clean[0]
52
+ # Handle subcommands for sources, allow, deny
53
+ if cmd in ("sources", "allow", "deny") and len(clean) >= 2:
54
+ subcmd = clean[1]
55
+ if subcmd not in ("-h", "--help"):
56
+ return f"{cmd} {subcmd}"
57
+
58
+ return cmd
59
+
60
+
61
+ def _needs_root(cmd: str, args: dict) -> bool:
62
+ if cmd in {
63
+ "install",
64
+ "uninstall",
65
+ "enable",
66
+ "disable",
67
+ "pause",
68
+ "resume",
69
+ "update",
70
+ }:
71
+ return True
72
+
73
+ if cmd == "sources":
74
+ return args.get("sources_cmd") == "set"
75
+
76
+ if cmd == "allow":
77
+ return args.get("allow_cmd") in ("add", "remove")
78
+
79
+ if cmd == "deny":
80
+ return args.get("deny_cmd") in ("add", "remove")
81
+
82
+ return False
83
+
84
+
85
+ def _exec_sudo(argv: list[str]) -> None:
86
+ sudo = shutil.which("sudo")
87
+ if sudo is None:
88
+ raise PrivilegeError("sudo not found")
89
+
90
+ if os.environ.get("MACBLOCK_ELEVATED") == "1":
91
+ raise PrivilegeError("failed to elevate privileges")
92
+
93
+ env = dict(os.environ)
94
+ env["MACBLOCK_ELEVATED"] = "1"
95
+
96
+ exe = shutil.which(sys.argv[0])
97
+ if exe:
98
+ os.execve(sudo, [sudo, "-E", exe, *argv], env)
99
+
100
+ os.execve(sudo, [sudo, "-E", sys.executable, "-m", "macblock", *argv], env)
101
+
102
+
103
+ def _parse_args(argv: list[str]) -> tuple[str | None, dict]:
104
+ """Simple argument parser.
105
+
106
+ Returns (command, args_dict). If help was requested, args will contain
107
+ {"_help": True, "_help_context": "command"} and command will be "_help".
108
+ """
109
+ args: dict = {}
110
+
111
+ if not argv:
112
+ return "status", args
113
+
114
+ # Handle --version first (can appear anywhere)
115
+ if "--version" in argv or "-V" in argv:
116
+ print(f"macblock {__version__}")
117
+ sys.exit(0)
118
+
119
+ # Check for help flag anywhere in args
120
+ if _has_help_flag(argv):
121
+ help_context = _get_help_context(argv)
122
+ args["_help"] = True
123
+ args["_help_context"] = help_context
124
+ return "_help", args
125
+
126
+ # Handle explicit "help" command
127
+ if argv[0] == "help":
128
+ rest = argv[1:]
129
+ if rest:
130
+ # "help sources set" -> "sources set"
131
+ if len(rest) >= 2 and rest[0] in ("sources", "allow", "deny"):
132
+ args["_help_context"] = f"{rest[0]} {rest[1]}"
133
+ else:
134
+ args["_help_context"] = rest[0]
135
+ else:
136
+ args["_help_context"] = None
137
+ args["_help"] = True
138
+ return "_help", args
139
+
140
+ cmd = argv[0]
141
+ rest = argv[1:]
142
+
143
+ # Handle subcommands
144
+ if cmd == "logs":
145
+ args["component"] = "daemon"
146
+ args["lines"] = 200
147
+ args["follow"] = False
148
+ args["stderr"] = False
149
+ i = 0
150
+ while i < len(rest):
151
+ if rest[i] == "--component" and i + 1 < len(rest):
152
+ args["component"] = rest[i + 1]
153
+ i += 2
154
+ elif rest[i] == "--lines" and i + 1 < len(rest):
155
+ args["lines"] = int(rest[i + 1])
156
+ i += 2
157
+ elif rest[i] == "--follow":
158
+ args["follow"] = True
159
+ i += 1
160
+ elif rest[i] == "--stderr":
161
+ args["stderr"] = True
162
+ i += 1
163
+ else:
164
+ i += 1
165
+
166
+ elif cmd == "install":
167
+ args["force"] = "--force" in rest
168
+ args["skip_update"] = "--skip-update" in rest
169
+
170
+ elif cmd == "uninstall":
171
+ args["force"] = "--force" in rest
172
+
173
+ elif cmd == "pause":
174
+ if rest:
175
+ args["duration"] = rest[0]
176
+ else:
177
+ raise MacblockError("pause requires a duration (e.g., 10m, 2h, 1d)")
178
+
179
+ elif cmd == "test":
180
+ if rest:
181
+ args["domain"] = rest[0]
182
+ else:
183
+ raise MacblockError("test requires a domain")
184
+
185
+ elif cmd == "update":
186
+ args["source"] = None
187
+ args["sha256"] = None
188
+ i = 0
189
+ while i < len(rest):
190
+ if rest[i] == "--source" and i + 1 < len(rest):
191
+ args["source"] = rest[i + 1]
192
+ i += 2
193
+ elif rest[i] == "--sha256" and i + 1 < len(rest):
194
+ args["sha256"] = rest[i + 1]
195
+ i += 2
196
+ else:
197
+ i += 1
198
+
199
+ elif cmd == "sources":
200
+ if not rest:
201
+ raise MacblockError("sources requires a subcommand: list or set")
202
+ args["sources_cmd"] = rest[0]
203
+ if rest[0] == "set":
204
+ if len(rest) < 2:
205
+ raise MacblockError("sources set requires a source name")
206
+ args["source"] = rest[1]
207
+
208
+ elif cmd == "allow":
209
+ if not rest:
210
+ raise MacblockError("allow requires a subcommand: add, remove, or list")
211
+ args["allow_cmd"] = rest[0]
212
+ if rest[0] in ("add", "remove"):
213
+ if len(rest) < 2:
214
+ raise MacblockError(f"allow {rest[0]} requires a domain")
215
+ args["domain"] = rest[1]
216
+
217
+ elif cmd == "deny":
218
+ if not rest:
219
+ raise MacblockError("deny requires a subcommand: add, remove, or list")
220
+ args["deny_cmd"] = rest[0]
221
+ if rest[0] in ("add", "remove"):
222
+ if len(rest) < 2:
223
+ raise MacblockError(f"deny {rest[0]} requires a domain")
224
+ args["domain"] = rest[1]
225
+
226
+ return cmd, args
227
+
228
+
229
+ def main(argv: list[str] | None = None) -> int:
230
+ argv = list(sys.argv[1:] if argv is None else argv)
231
+
232
+ try:
233
+ require_macos()
234
+
235
+ cmd, args = _parse_args(argv)
236
+
237
+ # Show help if no command provided
238
+ if cmd is None:
239
+ show_main_help()
240
+ return 0
241
+
242
+ # Handle help requests
243
+ if cmd == "_help":
244
+ help_context = args.get("_help_context")
245
+ if help_context:
246
+ show_command_help(help_context)
247
+ else:
248
+ show_main_help()
249
+ return 0
250
+
251
+ # Check if we need root
252
+ if _needs_root(cmd, args) and not is_root():
253
+ _exec_sudo(argv)
254
+
255
+ if cmd == "status":
256
+ return show_status()
257
+ if cmd == "doctor":
258
+ return run_diagnostics()
259
+ if cmd == "daemon":
260
+ from macblock.daemon import run_daemon
261
+
262
+ return run_daemon()
263
+ if cmd == "logs":
264
+ show_logs = importlib.import_module("macblock.logs").show_logs
265
+ return show_logs(
266
+ component=str(args.get("component", "daemon")),
267
+ lines=int(args.get("lines", 200)),
268
+ follow=bool(args.get("follow", False)),
269
+ stderr=bool(args.get("stderr", False)),
270
+ )
271
+ if cmd == "install":
272
+ return do_install(
273
+ force=bool(args.get("force")), skip_update=bool(args.get("skip_update"))
274
+ )
275
+ if cmd == "uninstall":
276
+ return do_uninstall(force=bool(args.get("force")))
277
+ if cmd == "enable":
278
+ return do_enable()
279
+ if cmd == "disable":
280
+ return do_disable()
281
+ if cmd == "pause":
282
+ return do_pause(args["duration"])
283
+ if cmd == "resume":
284
+ return do_resume()
285
+ if cmd == "test":
286
+ return test_domain(args["domain"])
287
+ if cmd == "update":
288
+ return update_blocklist(
289
+ source=args.get("source"), sha256=args.get("sha256")
290
+ )
291
+ if cmd == "sources":
292
+ if args.get("sources_cmd") == "list":
293
+ return list_blocklist_sources()
294
+ return set_blocklist_source(args["source"])
295
+ if cmd == "allow":
296
+ if args.get("allow_cmd") == "add":
297
+ return add_whitelist(args["domain"])
298
+ if args.get("allow_cmd") == "remove":
299
+ return remove_whitelist(args["domain"])
300
+ return list_whitelist()
301
+ if cmd == "deny":
302
+ if args.get("deny_cmd") == "add":
303
+ return add_blacklist(args["domain"])
304
+ if args.get("deny_cmd") == "remove":
305
+ return remove_blacklist(args["domain"])
306
+ return list_blacklist()
307
+
308
+ print(f"error: unknown command: {cmd}", file=sys.stderr)
309
+ print("Run 'macblock --help' for usage.", file=sys.stderr)
310
+ return 2
311
+
312
+ except UnsupportedPlatformError as e:
313
+ print(str(e), file=sys.stderr)
314
+ return 2
315
+ except PrivilegeError as e:
316
+ print(str(e), file=sys.stderr)
317
+ return 2
318
+ except MacblockError as e:
319
+ print(f"error: {e}", file=sys.stderr)
320
+ return 1
321
+ except Exception as e:
322
+ print(f"error: {e}", file=sys.stderr)
323
+ return 1
macblock/colors.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+
6
+ class Colors:
7
+ RED = "\033[91m"
8
+ GREEN = "\033[92m"
9
+ YELLOW = "\033[93m"
10
+ BLUE = "\033[94m"
11
+ MAGENTA = "\033[95m"
12
+ CYAN = "\033[96m"
13
+ WHITE = "\033[97m"
14
+ BOLD = "\033[1m"
15
+ DIM = "\033[2m"
16
+ RESET = "\033[0m"
17
+
18
+
19
+ def color(text: str, *styles: str) -> str:
20
+ if not sys.stdout.isatty():
21
+ return text
22
+ return "".join(styles) + text + Colors.RESET
23
+
24
+
25
+ def success(text: str) -> str:
26
+ return color(text, Colors.GREEN)
27
+
28
+
29
+ def error(text: str) -> str:
30
+ return color(text, Colors.RED)
31
+
32
+
33
+ def warning(text: str) -> str:
34
+ return color(text, Colors.YELLOW)
35
+
36
+
37
+ def info(text: str) -> str:
38
+ return color(text, Colors.CYAN)
39
+
40
+
41
+ def bold(text: str) -> str:
42
+ return color(text, Colors.BOLD)
43
+
44
+
45
+ def dim(text: str) -> str:
46
+ return color(text, Colors.DIM)
47
+
48
+
49
+ def print_success(text: str) -> None:
50
+ print(success(text))
51
+
52
+
53
+ def print_warning(text: str) -> None:
54
+ print(warning(text))
55
+
56
+
57
+ def print_info(text: str) -> None:
58
+ print(info(text))
59
+
60
+
61
+ def print_error(text: str) -> None:
62
+ print(error(text), file=sys.stderr)