diff --git a/backend/app/services/blocker_service.py b/backend/app/services/blocker_service.py index 8482acb..2633c8d 100644 --- a/backend/app/services/blocker_service.py +++ b/backend/app/services/blocker_service.py @@ -9,6 +9,8 @@ and a second machine running the desktop app obeys the same list. from __future__ import annotations +import re +from dataclasses import dataclass, field from datetime import date, datetime from fnmatch import fnmatch from typing import Any @@ -21,13 +23,63 @@ from app.services import pomodoro from app.settings_schema import AppSettings from app.timeutil import ensure_utc, in_window, local_date, local_time +# One label of a hostname: what may legally sit in front of `.example.com`. +_LABEL_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") + + +@dataclass(frozen=True) +class Expansion: + """What `subdomain_expansions` asked for, split three ways. + + * `labels` — real DNS labels, usable as hostnames (`web`, `m`). + * `wildcard` — a bare `*` was in the list, meaning *every* subdomain. That is + the entry people type when they mean "all of them", and it is now supported + rather than refused: a hosts file still cannot express it (that is what + [expand_entry] reports as unexpandable), but the desktop app routes the + suffix through a local resolver stub and the phone matches the mask in the + URL bar, so it works on both. + * `rejected` — anything that is neither a label nor `*` (`not a label`, `..`), + reported so the UI can say why it was ignored. + """ + + labels: list[str] = field(default_factory=list) + wildcard: bool = False + rejected: list[str] = field(default_factory=list) + + +def parse_expansions(raw: list[str]) -> Expansion: + """Split `subdomain_expansions` into labels, the `*` flag, and rejects. + + The trap this replaced is worth stating plainly: `*` used to slip through as a + subdomain label, so `*.telegram.org` expanded to the literal `*.telegram.org` — + not a hostname, which the desktop app's own filter dropped without a word. The + mask looked configured, `unexpandable` stayed empty, and `web.telegram.org` was + never blocked. Silent, and exactly the shape of "the blocker does not work". + """ + labels: list[str] = [] + rejected: list[str] = [] + wildcard = False + for entry in raw: + label = entry.strip().lower() + if not label: + continue + if label == "*": + wildcard = True + continue + if _LABEL_RE.match(label): + if label not in labels: + labels.append(label) + elif label not in rejected: + rejected.append(label) + return Expansion(labels=labels, wildcard=wildcard, rejected=rejected) + def is_allowed(host: str, allow_masks: list[str]) -> bool: """Whether an allow-list entry (plain or masked) covers this hostname.""" return any(fnmatch(host, mask) for mask in allow_masks) -def expand_entry(raw: str, subdomains: list[str]) -> tuple[list[str], str | None]: +def expand_entry(raw: str, expansion: Expansion) -> tuple[list[str], str | None]: """Turn one blocklist entry into concrete hostnames. A hosts file has no wildcards, so a mask has to be expanded into real names: @@ -35,7 +87,12 @@ def expand_entry(raw: str, subdomains: list[str]) -> tuple[list[str], str | None * `youtube.com` → `youtube.com`, `www.youtube.com`, `m.youtube.com` * `*.youtube.com` → the bare domain plus every configured subdomain * `*gram*` → nothing expandable; returned as a leftover pattern so the - UI can say so instead of silently dropping it + clients that *can* match masks (the phone's URL bar, and + the desktop's resolver stub) know to enforce it + + With `*` in `subdomain_expansions`, `*.youtube.com` means *every* subdomain. + The concrete names are still expanded — they block without any resolver help — + and the pattern comes back alongside them for the stub to catch the rest. Returns `(hostnames, unexpandable_pattern)`. """ @@ -47,7 +104,8 @@ def expand_entry(raw: str, subdomains: list[str]) -> tuple[list[str], str | None base = entry[2:] if not base or "*" in base: return [], entry - return [base, *[f"{prefix}.{base}" for prefix in subdomains]], None + hosts = [base, *[f"{label}.{base}" for label in expansion.labels]] + return hosts, (entry if expansion.wildcard else None) if "*" in entry: return [], entry @@ -67,11 +125,11 @@ def expand_domains(settings: AppSettings) -> list[str]: allow_masks = [ entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip() ] - subdomains = [part.strip().lower() for part in blocker.subdomain_expansions if part.strip()] + expansion = parse_expansions(blocker.subdomain_expansions) hosts: list[str] = [] for raw in blocker.blocked_domains: - expanded, _ = expand_entry(raw, subdomains) + expanded, _ = expand_entry(raw, expansion) for host in expanded: if host not in hosts and not is_allowed(host, allow_masks): hosts.append(host) @@ -79,11 +137,17 @@ def expand_domains(settings: AppSettings) -> list[str]: def unexpandable_masks(settings: AppSettings) -> list[str]: - """Blocklist entries a hosts file cannot express, so the UI can flag them.""" - subdomains = [part.strip().lower() for part in settings.blocker.subdomain_expansions if part.strip()] + """Blocklist entries a hosts file cannot express. + + These are what the desktop app's local resolver stub is handed: patterns with + a domain anchor (`*.youtube.com`, `*gram.com`) it can answer at DNS level, and + `*gram*`-style patterns it has to report as unenforceable on this machine + because no zone can be routed for them. + """ + expansion = parse_expansions(settings.blocker.subdomain_expansions) leftovers: list[str] = [] for raw in settings.blocker.blocked_domains: - _, pattern = expand_entry(raw, subdomains) + _, pattern = expand_entry(raw, expansion) if pattern and pattern not in leftovers: leftovers.append(pattern) return leftovers @@ -134,6 +198,12 @@ def policy(session: Session, settings: AppSettings) -> dict[str, Any]: "domains": expand_domains(settings) if active else [], "apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [], "unexpandable": unexpandable_masks(settings), + "invalid_expansions": parse_expansions(blocker.subdomain_expansions).rejected, + "allowed": [ + entry.strip().lower().lstrip(".") + for entry in blocker.allowed_domains + if entry.strip() + ], "kill_apps": blocker.kill_blocked_apps, "notify_on_block": blocker.notify_on_block, "poll_seconds": blocker.app_poll_seconds, diff --git a/backend/app/settings_schema.py b/backend/app/settings_schema.py index 96bbc4d..cdeed37 100644 --- a/backend/app/settings_schema.py +++ b/backend/app/settings_schema.py @@ -178,7 +178,9 @@ class BlockerSettings(BaseModel): `youtube.com` blocks the site (plus `www.` and `m.`); `*.youtube.com` blocks its subdomains too, expanded with `subdomain_expansions` because a hosts file - has no wildcards of its own. + has no wildcards of its own. Put `*` in that list and `*.youtube.com` means + every subdomain — the desktop app answers those at DNS level, the phone + matches them in the URL bar. """ allowed_domains: list[str] = Field(default_factory=list) @@ -187,7 +189,13 @@ class BlockerSettings(BaseModel): subdomain_expansions: list[str] = Field( default_factory=lambda: ["www", "m", "mobile", "app", "web", "i", "old", "new"] ) - """What `*.example.com` turns into. Add the subdomains you actually visit.""" + """What `*.example.com` turns into. Add the subdomains you actually visit. + + A bare `*` means *every* subdomain instead: the hosts file still only gets the + labels listed here, while the rest is caught by the phone's URL-bar matcher and + by the desktop app's local resolver stub. Anything else that is not a DNS label + is ignored and reported as `invalid_expansions`. + """ blocked_apps: list[str] = Field(default_factory=list) """Process names or masks: `steam`, `*discord*`, `telegram-*`.""" diff --git a/backend/tests/test_blocker.py b/backend/tests/test_blocker.py index 7e7eca1..9c398a6 100644 --- a/backend/tests/test_blocker.py +++ b/backend/tests/test_blocker.py @@ -95,3 +95,71 @@ def test_app_masks_are_passed_through_untouched(session, settings): settings_service.save_settings(session, settings) policy = blocker_service.policy(session, settings) assert policy["apps"] == ["steam*", "*discord*"] + + +def test_a_wildcard_means_every_subdomain(session, settings): + """`*` in the expansion list is the user asking for all of them. + + It used to be refused (it is not a DNS label), which left `*.telegram.org` + expanded to just the bare domain with a red banner beside it. Now the labels + still expand, the pattern is handed to the clients that can match masks, and + only entries that are neither a label nor `*` are refused. + """ + settings.blocker.blocked_domains = ["*.telegram.org"] + settings.blocker.subdomain_expansions = ["*", "web", "", "not a label"] + + expansion = blocker_service.parse_expansions(settings.blocker.subdomain_expansions) + assert expansion.labels == ["web"] + assert expansion.wildcard is True + assert expansion.rejected == ["not a label"] + + hosts = blocker_service.expand_domains(settings) + assert hosts == ["telegram.org", "web.telegram.org"] + assert not any("*" in host for host in hosts) + + # The pattern comes back too: a hosts file cannot say "every subdomain", so + # the desktop app's resolver stub and the phone's URL matcher take it. + assert blocker_service.unexpandable_masks(settings) == ["*.telegram.org"] + + +def test_without_a_wildcard_the_pattern_stays_in_the_expansion_list(session, settings): + """`*.x.com` alone means the configured labels, not a wildcard — nothing else.""" + settings.blocker.blocked_domains = ["*.google.com"] + settings.blocker.subdomain_expansions = ["www", "mail"] + + assert blocker_service.unexpandable_masks(settings) == [] + assert blocker_service.expand_domains(settings) == [ + "google.com", + "www.google.com", + "mail.google.com", + ] + + +def test_the_policy_reports_expansions_it_had_to_refuse(session, settings): + settings.blocker.enabled = True + settings.blocker.mode = "always" + settings.blocker.blocked_domains = ["*.telegram.org"] + settings.blocker.subdomain_expansions = ["*", ".."] + + result = blocker_service.policy(session, settings) + assert result["invalid_expansions"] == [".."] + assert result["domains"] == ["telegram.org"] + assert result["unexpandable"] == ["*.telegram.org"] + + +def test_the_policy_carries_the_allow_masks_for_the_stub(session, settings): + """The desktop app answers DNS for a whole zone, so it needs the exceptions. + + Without them the resolver stub would sinkhole `music.youtube.com` along with + everything else under a wildcarded domain — an exception that works over the + hosts file but not over DNS is an exception nobody will trust. + """ + settings.blocker.enabled = True + settings.blocker.mode = "always" + settings.blocker.blocked_domains = ["*.youtube.com"] + settings.blocker.allowed_domains = [" music.youtube.com ", "*.docs.google.com"] + + result = blocker_service.policy(session, settings) + assert result["allowed"] == ["music.youtube.com", "*.docs.google.com"] + assert "music.youtube.com" not in result["domains"] + assert "m.youtube.com" in result["all_domains"]