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:
timka123 committed 2026-10-19 18:45:20 +03:00
1 parent 632dee1f12
commit a75a011edb
3 files changed
+156 -10

No files matched your search

+78 -8
View File
@@ -9,6 +9,8 @@ and a second machine running the desktop app obeys the same list.
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date, datetime from datetime import date, datetime
from fnmatch import fnmatch from fnmatch import fnmatch
from typing import Any from typing import Any
@@ -21,13 +23,63 @@ from app.services import pomodoro
from app.settings_schema import AppSettings from app.settings_schema import AppSettings
from app.timeutil import ensure_utc, in_window, local_date, local_time 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: def is_allowed(host: str, allow_masks: list[str]) -> bool:
"""Whether an allow-list entry (plain or masked) covers this hostname.""" """Whether an allow-list entry (plain or masked) covers this hostname."""
return any(fnmatch(host, mask) for mask in allow_masks) 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. """Turn one blocklist entry into concrete hostnames.
A hosts file has no wildcards, so a mask has to be expanded into real names: 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` → `youtube.com`, `www.youtube.com`, `m.youtube.com`
* `*.youtube.com` → the bare domain plus every configured subdomain * `*.youtube.com` → the bare domain plus every configured subdomain
* `*gram*` → nothing expandable; returned as a leftover pattern so the * `*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)`. Returns `(hostnames, unexpandable_pattern)`.
""" """
@@ -47,7 +104,8 @@ def expand_entry(raw: str, subdomains: list[str]) -> tuple[list[str], str | None
base = entry[2:] base = entry[2:]
if not base or "*" in base: if not base or "*" in base:
return [], entry 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: if "*" in entry:
return [], entry return [], entry
@@ -67,11 +125,11 @@ def expand_domains(settings: AppSettings) -> list[str]:
allow_masks = [ allow_masks = [
entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip() 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] = [] hosts: list[str] = []
for raw in blocker.blocked_domains: for raw in blocker.blocked_domains:
expanded, _ = expand_entry(raw, subdomains) expanded, _ = expand_entry(raw, expansion)
for host in expanded: for host in expanded:
if host not in hosts and not is_allowed(host, allow_masks): if host not in hosts and not is_allowed(host, allow_masks):
hosts.append(host) hosts.append(host)
@@ -79,11 +137,17 @@ def expand_domains(settings: AppSettings) -> list[str]:
def unexpandable_masks(settings: AppSettings) -> list[str]: def unexpandable_masks(settings: AppSettings) -> list[str]:
"""Blocklist entries a hosts file cannot express, so the UI can flag them.""" """Blocklist entries a hosts file cannot express.
subdomains = [part.strip().lower() for part in settings.blocker.subdomain_expansions if part.strip()]
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] = [] leftovers: list[str] = []
for raw in settings.blocker.blocked_domains: for raw in settings.blocker.blocked_domains:
_, pattern = expand_entry(raw, subdomains) _, pattern = expand_entry(raw, expansion)
if pattern and pattern not in leftovers: if pattern and pattern not in leftovers:
leftovers.append(pattern) leftovers.append(pattern)
return leftovers return leftovers
@@ -134,6 +198,12 @@ def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
"domains": expand_domains(settings) if active else [], "domains": expand_domains(settings) if active else [],
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [], "apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
"unexpandable": unexpandable_masks(settings), "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, "kill_apps": blocker.kill_blocked_apps,
"notify_on_block": blocker.notify_on_block, "notify_on_block": blocker.notify_on_block,
"poll_seconds": blocker.app_poll_seconds, "poll_seconds": blocker.app_poll_seconds,
+10 -2
View File
@@ -178,7 +178,9 @@ class BlockerSettings(BaseModel):
`youtube.com` blocks the site (plus `www.` and `m.`); `*.youtube.com` blocks `youtube.com` blocks the site (plus `www.` and `m.`); `*.youtube.com` blocks
its subdomains too, expanded with `subdomain_expansions` because a hosts file 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) allowed_domains: list[str] = Field(default_factory=list)
@@ -187,7 +189,13 @@ class BlockerSettings(BaseModel):
subdomain_expansions: list[str] = Field( subdomain_expansions: list[str] = Field(
default_factory=lambda: ["www", "m", "mobile", "app", "web", "i", "old", "new"] 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) blocked_apps: list[str] = Field(default_factory=list)
"""Process names or masks: `steam`, `*discord*`, `telegram-*`.""" """Process names or masks: `steam`, `*discord*`, `telegram-*`."""
+68
View File
@@ -95,3 +95,71 @@ def test_app_masks_are_passed_through_untouched(session, settings):
settings_service.save_settings(session, settings) settings_service.save_settings(session, settings)
policy = blocker_service.policy(session, settings) policy = blocker_service.policy(session, settings)
assert policy["apps"] == ["steam*", "*discord*"] 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"]