Files
kadence/desktop/kadence_desktop/blocking/resolver.py
T
timka123andClaude Opus 5 5facf41f10 blocker: an exception reads like a block, so make it behave like one
`youtube.com` on the block list catches `music.youtube.com` — a plain entry
covers its subtree. `youtube.com` on the *allow* list caught only itself,
because exceptions were glob-matched whatever they looked like and a plain name
has no metacharacters. The same text in two boxes of the same form meant a
subtree in one and a single hostname in the other, with nothing in the UI
saying so.

A plain exception now covers its subdomains; a masked one is glob-matched
exactly as before, which is still how you spare something narrower than a whole
subtree (`docs.*`, `*.docs.google.com`).

Three enforcers decide this and all three had to move together, or an exception
would work in the browser and not at the resolver: `blocker_service.is_allowed`,
`:core`'s `isAllowed`, and the desktop DNS stub's own allow check, which was
reusing the generic glob matcher. Parity tests on both sides, plus the two
sentences in the web UI and the README that promised the old behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:24:17 +03:00

792 lines
29 KiB
Python

"""Website blocking at DNS level, for the patterns a hosts file cannot express.
`/etc/hosts` can only answer with a fixed list of hostnames, so a mask like
`*.youtube.com` — *every* subdomain — cannot be written into it. The server
expands the labels the user listed and hands the rest over in the policy's
`unexpandable` field; this module is what makes those patterns real here:
* [StubResolver] is a small DNS server bound to `127.0.0.1` on a high port. It
needs no privileges, it is only reachable from this machine, and it is not a
resolver of its own: anything it does not block is forwarded verbatim to the
machine's real upstreams, in parallel, and the first answer is passed back.
The forwarding matters — an allow-listed subdomain of a blocked zone
(`music.youtube.com` under `*.youtube.com`) has to keep resolving.
* [ResolvedRoute] tells `systemd-resolved` to send the affected zones here, via
a drop-in file the privileged helper writes. `systemd-resolved` routes a name
to the *most specific* matching route-only domain, so a `~youtube.com` route
beats a VPN's catch-all `~.` for that zone — and because the route is the
whole scope, those queries go to this stub and nowhere else.
Two invariants keep this from taking the machine's DNS down, and both are the
reason `BlockerManager` starts and stops this pair in a fixed order:
* the stub is listening *before* the drop-in that points zones at it exists;
* the drop-in is gone *before* the stub stops.
A crash between those steps leaves zones pointing at a dead port until the app
runs again — the honest failure mode, and the reason the stub is started at
startup whenever a drop-in is found lying around.
"""
from __future__ import annotations
import concurrent.futures
import fnmatch
import ipaddress
import logging
import os
import re
import shutil
import socket
import struct
import subprocess
import threading
import time
from dataclasses import dataclass, field
log = logging.getLogger(__name__)
# The stub binds the first of these ports that is free. 5353 is not just some
# high port: it is mDNS's, hence "the obvious one" — but it can be taken, so the
# fallbacks are tried in turn and the *actual* port is what gets routed.
STUB_ADDRESS = "127.0.0.1"
STUB_PORTS = (5353, 5453, 5553, 5354)
# systemd-resolved's own listener, and the extra one it may listen on. Sending a
# query there would come straight back to this stub (resolved asks us, we ask
# resolved, …), so they are never used as upstreams.
RESOLVED_LISTENERS = {"127.0.0.53", "127.0.0.54"}
# The file listing the uplink DNS servers resolved actually uses. In stub mode
# (the normal one) it holds the real upstreams, not 127.0.0.53.
UPSTREAM_PATH = "/run/systemd/resolve/resolv.conf"
DROPIN_PATH = "/etc/systemd/resolved.conf.d/kadence.conf"
# Short on purpose: an answer must not outlive the interval that produced it by
# much, or a site stays unreachable from a cache after the block lifts.
ANSWER_TTL = 60
TYPE_A, TYPE_AAAA, CLASS_IN = 1, 28, 1
ROOT_TIMEOUT = 2.5
# ==========================================================================
# Which patterns *can* be routed at DNS level
# ==========================================================================
def _zone_of(pattern: str) -> str | None:
"""The zone a mask anchors on, or None when no zone can be routed for it.
`*.youtube.com` anchors on `youtube.com`: every name under it matches the
mask, so the whole zone can be sent to the stub. `*gram.com` anchors on
`gram.com` the same way — the suffix is literal, only the front is not.
`*gram*` has nothing after the last `*` — no anchor, no route, and it is
reported as unenforceable rather than silently ignored. A zone of a single
label (`*.site`) is refused too: routing a whole top-level domain is not what
the user meant.
"""
zone = pattern.rsplit("*", 1)[-1].strip(".").lower()
return zone if len(zone.split(".")) >= 2 else None
def split_routable(patterns: list[str]) -> tuple[list[str], list[str], list[str]]:
"""Sort `unexpandable` masks into (routable, unroutable, zones to route).
The three lists answer three different questions: what the stub answers,
what no machine can answer (the phone's URL matcher still can), and what
goes into the resolved drop-in.
"""
routable: list[str] = []
unroutable: list[str] = []
zones: list[str] = []
for pattern in patterns:
zone = _zone_of(pattern)
if zone is None:
unroutable.append(pattern)
continue
routable.append(pattern)
if zone not in zones:
zones.append(zone)
return routable, unroutable, zones
def _matches(name: str, patterns: list[str]) -> bool:
return any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns)
def _allowed(name: str, entries: list[str]) -> bool:
"""Whether an exception covers this name.
Not [_matches]: a plain exception covers its subdomains, the same way a plain
block-list entry does. Parity with `blocker_service.is_allowed` and `:core`'s
`isAllowed` — the stub is the third place that decides this, and an exception
that works in the browser but not at the resolver is worse than neither.
"""
for entry in entries:
if any(char in entry for char in "*?["):
if fnmatch.fnmatchcase(name, entry):
return True
elif name == entry or name.endswith(f".{entry}"):
return True
return False
# ==========================================================================
# The DNS wire format, as little of it as a blocker needs
# ==========================================================================
_HEADER = struct.Struct("!HHHHHH")
_FLAG_QR, _FLAG_RD, _FLAG_RA = 0x8000, 0x0100, 0x0080
_RCODE_SERVFAIL = 2
@dataclass(frozen=True)
class Question:
qid: int
flags: int
name: str
qtype: int
qclass: int
end: int # offset just past the question section
def _read_name(packet: bytes, offset: int) -> tuple[str, int] | None:
"""Read a possibly-compressed domain name; `(lowercased name, next offset)`.
Compression pointers are followed with a small hop budget, because a query
is attacker-controlled data like any other packet on a socket.
"""
labels: list[str] = []
end = offset
jumped = False
hops = 0
while True:
if offset >= len(packet):
return None
length = packet[offset]
if length == 0:
offset += 1
if not jumped:
end = offset
return ".".join(labels).lower(), end
if length & 0xC0 == 0xC0:
if offset + 1 >= len(packet):
return None
if not jumped:
end = offset + 2
offset = ((length & 0x3F) << 8) | packet[offset + 1]
jumped = True
hops += 1
if hops > 10:
return None
continue
if length & 0xC0:
return None
offset += 1
labels.append(packet[offset : offset + length].decode("ascii", errors="replace"))
offset += length
if not jumped:
end = offset
def parse_question(packet: bytes) -> Question | None:
"""The single question of a standard query, or None for anything else."""
if len(packet) < _HEADER.size:
return None
qid, flags, qdcount, _, _, _ = _HEADER.unpack_from(packet)
if flags & _FLAG_QR or qdcount != 1 or flags & 0x7800: # a reply, or not a query
return None
read = _read_name(packet, _HEADER.size)
if read is None:
return None
name, end = read
if end + 4 > len(packet):
return None
qtype, qclass = struct.unpack_from("!HH", packet, end)
return Question(qid, flags, name, qtype, qclass, end + 4)
def _build_response(
request: bytes,
question: Question,
*,
rdata: bytes | None = None,
record_type: int = TYPE_A,
rcode: int = 0,
) -> bytes:
"""Answer `question`: one record when `rdata` is given, NODATA otherwise."""
flags = _FLAG_QR | (question.flags & _FLAG_RD) | _FLAG_RA | rcode
answers = 1 if rdata is not None else 0
header = _HEADER.pack(question.qid, flags, 1, answers, 0, 0)
body = request[_HEADER.size : question.end]
if rdata is None:
return header + body
answer = b"\xc0\x0c" + struct.pack("!HHIH", record_type, CLASS_IN, ANSWER_TTL, len(rdata)) + rdata
return header + body + answer
# ==========================================================================
# The stub itself
# ==========================================================================
@dataclass(frozen=True)
class _Rules:
"""What the stub is currently doing. Swapped whole, under the GIL."""
active: bool = False
patterns: tuple[str, ...] = ()
allow: tuple[str, ...] = ()
redirect: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None
def blocks(self, name: str) -> bool:
return (
self.active
and bool(self.patterns)
and not _allowed(name, list(self.allow))
and _matches(name, list(self.patterns))
)
class StubResolver:
"""The local DNS server the routed zones are pointed at.
Deliberately small: it answers the address queries for names it blocks and
forwards everything else, including the whole allow list. It has no cache of
its own — caching here would mean answers that outlive the interval — so the
upstreams and `systemd-resolved` stay the source of truth.
"""
def __init__(
self,
*,
address: str = STUB_ADDRESS,
ports: tuple[int, ...] = STUB_PORTS,
upstream_path: str = UPSTREAM_PATH,
timeout: float = ROOT_TIMEOUT,
) -> None:
self.address = address
self.ports = ports
self.upstream_path = upstream_path
self.timeout = timeout
self._rules = _Rules()
self._lock = threading.Lock()
self._running = False
self._port: int | None = None
self._error: str | None = None
self._udp: socket.socket | None = None
self._tcp: socket.socket | None = None
self._threads: list[threading.Thread] = []
self._handlers: concurrent.futures.ThreadPoolExecutor | None = None
self._forwards: concurrent.futures.ThreadPoolExecutor | None = None
self._upstream_cache: tuple[float, list[tuple[str, int]]] | None = None
# ---- lifecycle ---------------------------------------------------------
@property
def running(self) -> bool:
return self._running
@property
def port(self) -> int | None:
"""The port actually bound — what the drop-in has to name."""
return self._port
@property
def error(self) -> str | None:
return self._error
def start(self) -> bool:
"""Bind the first free port. True when the stub is listening."""
if self._running:
return True
for port in self.ports:
bound = self._bind(port)
if bound is not None:
return True
self._error = (
f"the local DNS stub could not bind {self.address} on any of "
f"{', '.join(str(p) for p in self.ports)} ({self._error})"
)
log.warning(self._error)
return False
def _bind(self, port: int) -> socket.socket | None:
udp = tcp = None
try:
# TCP first: with port 0 it picks a free port, and UDP then takes the
# same one — the two protocols have separate namespaces, so this is
# how both end up on one number even when the kernel chooses.
tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tcp.bind((self.address, port))
tcp.listen(16)
bound = tcp.getsockname()[1]
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
udp.bind((self.address, bound))
# A timeout on the blocking calls below, so `stop()` can actually
# interrupt them: closing a socket from another thread does not wake
# a recvfrom() that is already parked on it.
udp.settimeout(0.5)
tcp.settimeout(0.5)
except OSError as exc:
self._error = f"{self.address}:{port}: {exc}"
for sock in (udp, tcp):
if sock is not None:
sock.close()
return None
self._udp, self._tcp = udp, tcp
self._port = udp.getsockname()[1]
self._handlers = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="kadence-dns")
self._forwards = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="kadence-fwd")
self._running = True
self._error = None
self._threads = [
threading.Thread(target=self._serve_udp, name="kadence-dns-udp", daemon=True),
threading.Thread(target=self._serve_tcp, name="kadence-dns-tcp", daemon=True),
]
for thread in self._threads:
thread.start()
log.info("DNS stub listening on %s:%s", self.address, self._port)
return udp
def stop(self) -> None:
"""Stop answering and give the port back. Only safe once unrouted."""
if not self._running:
return
self._running = False
for sock in (self._udp, self._tcp):
if sock is None:
continue
try:
sock.close() # the blocking recv/accept raises, ending the loop
except OSError:
pass
for thread in self._threads:
thread.join(timeout=1)
for pool in (self._handlers, self._forwards):
if pool is not None:
pool.shutdown(wait=False)
self._handlers = self._forwards = None
self._udp = self._tcp = None
self._threads = []
self._port = None
log.info("DNS stub stopped")
def set_rules(
self,
patterns: list[str],
allow: list[str],
redirect_ip: str,
*,
active: bool,
) -> None:
"""What to block until told otherwise. Never touches the sockets."""
try:
redirect = ipaddress.ip_address(redirect_ip)
except ValueError:
log.warning("redirect_ip %r is not an IP address; falling back to 127.0.0.1", redirect_ip)
redirect = ipaddress.ip_address("127.0.0.1")
rules = _Rules(
active=bool(active),
patterns=tuple(p.strip().lower() for p in patterns if p.strip()),
allow=tuple(a.strip().lower() for a in allow if a.strip()),
redirect=redirect,
)
with self._lock:
self._rules = rules
# ---- serving -----------------------------------------------------------
def _serve_udp(self) -> None:
while self._running and self._udp is not None:
try:
data, address = self._udp.recvfrom(4096)
except socket.timeout:
continue
except OSError:
break
handlers = self._handlers
if handlers is None:
break
try:
handlers.submit(self._answer_udp, data, address)
except RuntimeError:
break
def _answer_udp(self, data: bytes, address: tuple[str, int]) -> None:
try:
response = self._resolve(data, tcp=False)
if response and self._udp is not None:
self._udp.sendto(response, address)
except OSError:
pass
def _serve_tcp(self) -> None:
while self._running and self._tcp is not None:
try:
connection, _ = self._tcp.accept()
except socket.timeout:
continue
except OSError:
break
handlers = self._handlers
if handlers is None:
break
try:
handlers.submit(self._answer_tcp, connection)
except RuntimeError:
break
def _answer_tcp(self, connection: socket.socket) -> None:
try:
with connection:
connection.settimeout(5)
header = _recv_exact(connection, 2)
if header is None:
return
(length,) = struct.unpack("!H", header)
data = _recv_exact(connection, length)
if data is None:
return
response = self._resolve(data, tcp=True)
if response is None:
response = _servfail(data)
connection.sendall(struct.pack("!H", len(response)) + response)
except OSError:
pass
def _resolve(self, data: bytes, *, tcp: bool) -> bytes | None:
question = parse_question(data)
if question is not None and question.qclass == CLASS_IN:
rules = self._rules
if rules.blocks(question.name):
return self._blocked_answer(data, question, rules)
return self._forward(data, tcp=tcp)
def _blocked_answer(self, data: bytes, question: Question, rules: _Rules) -> bytes:
"""Redirect the address family we have an address for, drop the rest.
Dropping the others is the point: an HTTPS/SVCB record carries address
hints, and answering those would hand the browser a real address to
connect to without ever asking the A record.
"""
redirect = rules.redirect
if question.qtype == TYPE_A and redirect is not None and redirect.version == 4:
return _build_response(data, question, rdata=redirect.packed, record_type=TYPE_A)
if question.qtype == TYPE_AAAA and redirect is not None and redirect.version == 6:
return _build_response(data, question, rdata=redirect.packed, record_type=TYPE_AAAA)
return _build_response(data, question)
# ---- forwarding --------------------------------------------------------
def _upstreams(self) -> list[tuple[str, int]]:
"""The machine's real DNS servers, read from resolved's uplink file.
Cached until the file changes: resolved rewrites it whenever the network
does, and this is on the path of every query.
"""
try:
stat = os.stat(self.upstream_path)
stamp = stat.st_mtime
except OSError:
stamp = 0.0
cached = self._upstream_cache
if cached is not None and cached[0] == stamp and stamp != 0.0:
return cached[1]
servers = _parse_resolv_conf(self.upstream_path)
if not servers:
servers = _parse_resolv_conf("/etc/resolv.conf")
servers = [
server
for server in servers
if server[0] not in RESOLVED_LISTENERS and server != (self.address, self._port)
]
if cached is None or cached[1] != servers:
log.info("forwarding DNS to %s", ", ".join(f"{host}:{port}" for host, port in servers) or "nowhere")
self._upstream_cache = (stamp, servers)
return servers
def _forward(self, query: bytes, *, tcp: bool) -> bytes | None:
upstreams = self._upstreams()
pool = self._forwards
if not upstreams or pool is None:
return _servfail(query)
deadline = time.monotonic() + self.timeout
pending = {
pool.submit(self._ask_tcp if tcp else self._ask_udp, server, query, self.timeout): server
for server in upstreams
}
try:
while pending:
done, pending = concurrent.futures.wait(
pending, timeout=max(0.05, deadline - time.monotonic()),
return_when=concurrent.futures.FIRST_COMPLETED,
)
if not done:
break
for future in done:
try:
response = future.result()
except (OSError, concurrent.futures.CancelledError):
continue
if response:
return response
if time.monotonic() >= deadline:
break
finally:
for future in pending:
future.cancel()
# No upstream spoke: SERVFAIL, so the client retries rather than caching
# a lie about a domain we did not mean to block.
return _servfail(query)
def _ask_udp(self, server: tuple[str, int], query: bytes, timeout: float) -> bytes | None:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(timeout)
try:
sock.sendto(query, server)
response, _ = sock.recvfrom(4096)
except OSError:
return None
return _plausible(query, response)
def _ask_tcp(self, server: tuple[str, int], query: bytes, timeout: float) -> bytes | None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
try:
sock.connect(server)
sock.sendall(struct.pack("!H", len(query)) + query)
header = _recv_exact(sock, 2)
if header is None:
return None
(length,) = struct.unpack("!H", header)
response = _recv_exact(sock, length)
except OSError:
return None
return _plausible(query, response)
def _recv_exact(sock: socket.socket, count: int) -> bytes | None:
chunks = b""
while len(chunks) < count:
try:
chunk = sock.recv(count - len(chunks))
except OSError:
return None
if not chunk:
return None
chunks += chunk
return chunks
def _plausible(query: bytes, response: bytes | None) -> bytes | None:
"""A response is only worth passing on if it answers *this* query."""
if not response or len(response) < _HEADER.size:
return None
if response[:2] != query[:2]: # the transaction id must come back intact
return None
return response
_NAMESERVER_RE = re.compile(r"^nameserver\s+(?P<token>\S+)")
def _parse_nameserver(token: str) -> tuple[str, int] | None:
"""`nameserver` line → `(address, port)`, tolerating the odd shapes.
resolv.conf itself never carries a port, but systemd's generated file can,
and so can hand-written ones: `1.2.3.4:5353`, `[::1]:5353`, a bare IPv6
address, or an address with a comment bolted on.
"""
token = token.split("#", 1)[0].strip()
port = 53
if token.startswith("["):
host, bracket, rest = token[1:].partition("]")
if not bracket:
return None
if rest.startswith(":") and rest[1:].isdigit():
port = int(rest[1:])
elif rest:
return None
elif token.count(":") == 1:
host, text = token.split(":")
if not text.isdigit():
return None
port = int(text)
else:
host = token
try:
ipaddress.ip_address(host)
except ValueError:
return None
return (host, port) if 1 <= port <= 65535 else None
def _parse_resolv_conf(path: str) -> list[tuple[str, int]]:
servers: list[tuple[str, int]] = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
lines = handle.read().splitlines()
except OSError:
return servers
for line in lines:
match = _NAMESERVER_RE.match(line.strip())
if match is None:
continue
entry = _parse_nameserver(match.group("token"))
if entry is not None and entry not in servers:
servers.append(entry)
return servers
def _servfail(query: bytes) -> bytes:
"""A response that says "try again", for a query we cannot answer at all."""
if len(query) < _HEADER.size:
return b""
qid = struct.unpack_from("!H", query)[0]
flags = _FLAG_QR | (struct.unpack_from("!H", query, 2)[0] & _FLAG_RD) | _FLAG_RA | _RCODE_SERVFAIL
question = parse_question(query)
body = query[_HEADER.size : question.end] if question is not None else b""
return _HEADER.pack(qid, flags, 1, 0, 0, 0) + body
# ==========================================================================
# Pointing systemd-resolved at the stub
# ==========================================================================
@dataclass
class ResolvedRoute:
"""The resolved drop-in, kept in step through the privileged helper.
Like the hosts blocker, this diffs against what it last applied and treats a
no-op as a no-op — a restart of `systemd-resolved` drops the whole machine's
DNS cache for a moment, so it should only happen when the routed zones
genuinely changed. The applied state is re-read from the drop-in on first
use, so starting the app against an unchanged file does not restart
anything.
"""
helper_path: str = "/usr/local/bin/kadence-helper"
dropin_path: str = DROPIN_PATH
_applied: tuple[tuple[str, ...], int] | None = None
_error: str | None = None
_loaded: bool = field(default=False, repr=False)
@property
def helper_installed(self) -> bool:
return os.path.isfile(self.helper_path) and os.access(self.helper_path, os.X_OK)
@property
def last_error(self) -> str | None:
return self._error
@property
def applied(self) -> tuple[tuple[str, ...], int] | None:
self._load()
return self._applied
def _load(self) -> None:
if self._loaded:
return
self._loaded = True
if self._applied is not None:
return
self._applied = self._read_dropin()
def _read_dropin(self) -> tuple[tuple[str, ...], int] | None:
"""What the drop-in currently says, if it is one of ours."""
try:
with open(self.dropin_path, "r", encoding="utf-8") as handle:
text = handle.read()
except OSError:
return None
port: int | None = None
zones: tuple[str, ...] = ()
for line in text.splitlines():
line = line.strip()
if line.startswith("DNS="):
for address in line[4:].split():
if address.startswith(f"{STUB_ADDRESS}:"):
try:
port = int(address.rsplit(":", 1)[1])
except ValueError:
port = None
elif line.startswith("Domains="):
zones = tuple(
sorted(part[1:].lower() for part in line[8:].split() if part.startswith("~") and len(part) > 1)
)
if port is None:
return None
return zones, port
def on(self, zones: list[str], port: int) -> bool:
"""Route `zones` to a stub on `127.0.0.1:port`. True when it is so."""
self._load()
wanted = tuple(sorted({zone.strip().lower().strip(".") for zone in zones if zone.strip()}))
if not wanted:
return self.off()
if self._applied == (wanted, port):
return True
runner = self._runner()
if runner is None:
return False
command = runner + ["resolved-on", "--port", str(port), "--domains", ",".join(wanted)]
if not self._run(command, f"route {len(wanted)} zone(s) through the local stub"):
return False
self._applied = (wanted, port)
return True
def off(self) -> bool:
"""Stop routing anything to the stub. True when nothing is routed."""
self._load()
if self._applied is None and not os.path.exists(self.dropin_path):
return True
runner = self._runner()
if runner is None:
return False
if not self._run(runner + ["resolved-off"], "stop routing DNS through the local stub"):
return False
self._applied = None
return True
def _runner(self) -> list[str] | None:
if not self.helper_installed:
self._error = (
f"The privileged helper is not installed at {self.helper_path}. "
"Run desktop/helper/install.sh once."
)
return None
if shutil.which("pkexec") and os.environ.get("DISPLAY", os.environ.get("WAYLAND_DISPLAY")):
return ["pkexec", self.helper_path]
if shutil.which("sudo"):
return ["sudo", "-n", self.helper_path]
self._error = "Neither pkexec nor sudo is available to run the helper."
return None
def _run(self, command: list[str], what: str) -> bool:
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=90, check=False)
except (OSError, subprocess.SubprocessError) as exc:
self._error = f"Could not {what}: {exc}"
log.warning(self._error)
return False
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()[:300]
if result.returncode in (126, 127):
detail = detail or "the authorisation prompt was dismissed"
self._error = f"Could not {what}: {detail}"
log.warning(self._error)
return False
self._error = None
log.info("helper: %s", result.stdout.strip() or what)
return True