backend: a bare * in subdomain_expansions means every subdomain
An expansion list of labels guesses which subdomains exist; a '*' entry makes the guess unnecessary. parse_expansions now reports (labels, wildcard, rejected) instead of silently keeping junk, expand_entry keeps the base domain plus the configured labels and leaves '*.base' as a pattern when the wildcard is on, and the policy grows the 'allowed' masks the desktop resolver needs to serve exceptions.
This commit is contained in:
3 files changed
+156
-10
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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-*`."""
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user