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
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,