2026-08-13 16:23:18 +04:00

349 lines
12 KiB
Python

"""
Advertise the dashboard on the LAN under its own name, e.g. `agibot.local`.
Why this exists
---------------
The link people type should be about the robot, not about whatever the PC
happens to be called. Renaming the computer would do it, but that needs
administrator rights and a reboot and changes the machine's identity for
everything else. Publishing an extra name over mDNS is additive, reversible and
needs neither: the computer keeps its own name, and the advertised name exists
only while the dashboard is running.
Why stdlib and not the `zeroconf` package
-----------------------------------------
Two measured constraints on this machine, either of which would have made a
separate helper process silently useless:
* Windows' Public firewall profile is BlockInbound and every inbound rule for
UDP 5353 is *program*-scoped, not port-scoped. The built-in "mDNS (UDP-In)"
rule is scoped to svchost.exe/dnscache and grants a Python process nothing.
The only interpreter with a blanket inbound allow is the base Python that
already serves the dashboard - so the responder has to live *inside* that
process to be reachable from another device.
* A same-host HTTP fetch to the PC's own LAN address is routed via loopback
and skips inbound firewall filtering entirely, so a helper running under the
wrong interpreter passes every local test while remaining invisible to a
phone. Running in-process removes that whole failure mode.
Being stdlib-only also means no extra dependency to install in the interpreter
that matters.
Scope
-----
Only the `<name>.local` form is published. A bare single-label `http://agibot:`
appears to work on Windows because Windows appends `.local` to single-label
names, but it is unreliable (its negative DNS cache can poison the name for
minutes) and no iPhone, Android, Mac or Linux client resolves it. Publishing it
would be advertising something that only works on one machine.
"""
from __future__ import annotations
import logging
import socket
import struct
import threading
import time
log = logging.getLogger("x2.announce")
MDNS_ADDR = "224.0.0.251"
MDNS_PORT = 5353
TTL = 120 # seconds a client may cache the record
QTYPE_A = 1
QTYPE_ANY = 255
CLASS_IN = 1
CACHE_FLUSH = 0x8000 # tells clients this is the authoritative answer
UNICAST_RESPONSE = 0x8000 # the "QU" bit on a question's class
def _encode_name(name: str) -> bytes:
out = bytearray()
for label in name.rstrip(".").split("."):
raw = label.encode("utf-8")
out.append(len(raw))
out += raw
out.append(0)
return bytes(out)
def _decode_name(data: bytes, offset: int) -> tuple[str, int]:
"""Read a DNS name, following compression pointers. Returns (name, next_offset)."""
labels: list[str] = []
jumped = False
end = offset
hops = 0
while True:
if offset >= len(data) or hops > 20:
break
length = data[offset]
if length & 0xC0 == 0xC0: # compression pointer
if offset + 1 >= len(data):
break
pointer = struct.unpack_from("!H", data, offset)[0] & 0x3FFF
if not jumped:
end = offset + 2
offset = pointer
jumped = True
hops += 1
continue
offset += 1
if length == 0:
if not jumped:
end = offset
break
labels.append(data[offset:offset + length].decode("utf-8", "replace"))
offset += length
return ".".join(labels), end
def _build_response(name: str, ip: str) -> bytes:
header = struct.pack(
"!HHHHHH",
0, # ID - always 0 in mDNS
0x8400, # QR=1 (response), AA=1 (authoritative)
0, # no questions echoed
1, # one answer
0, 0,
)
answer = (
_encode_name(name)
+ struct.pack("!HHIH", QTYPE_A, CLASS_IN | CACHE_FLUSH, TTL, 4)
+ socket.inet_aton(ip)
)
return header + answer
def _build_query(name: str) -> bytes:
header = struct.pack("!HHHHHH", 0, 0x0000, 1, 0, 0, 0)
return header + _encode_name(name) + struct.pack("!HH", QTYPE_A, CLASS_IN)
class Announcer:
"""Answers mDNS A queries for one name, for as long as it runs."""
def __init__(self, name: str, port: int, address_provider):
self.name = f"{name.strip().strip('.').lower()}.local"
self.port = port
self._address_of = address_provider
self._sock: socket.socket | None = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self.ip: str | None = None
self.answered = 0
self.error = ""
self.conflict = False
# -- lifecycle ----------------------------------------------------------
def start(self) -> bool:
self.ip = self._address_of()
if not self.ip:
self.error = "No LAN address to advertise"
log.warning("mDNS: %s", self.error)
return False
try:
self._sock = self._open_socket()
except OSError as exc:
self.error = f"Could not open UDP {MDNS_PORT}: {exc}"
log.warning("mDNS: %s", self.error)
return False
# RFC 6762 probing: if something else already owns the name, do not
# fight it - two hosts answering for one name gives whichever reply
# arrives first, which is worse than not advertising at all.
if self._name_taken():
self.conflict = True
self.error = f"{self.name} is already claimed by another device on this network"
log.warning("mDNS: %s", self.error)
self._close()
return False
self._stop.clear()
self._thread = threading.Thread(target=self._serve, name="mdns-announce", daemon=True)
self._thread.start()
log.info("mDNS: advertising %s -> %s:%s", self.name, self.ip, self.port)
return True
def stop(self) -> None:
self._stop.set()
self._close()
if self._thread:
self._thread.join(timeout=3.0)
self._thread = None
def _close(self) -> None:
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
# -- socket -------------------------------------------------------------
def _open_socket(self) -> socket.socket:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Several responders normally share 5353 (Windows' own, Bonjour, and
# anything Chrome is doing); SO_REUSEPORT where available lets us join
# them rather than fail.
if hasattr(socket, "SO_REUSEPORT"):
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except OSError:
pass
sock.bind(("", MDNS_PORT))
membership = socket.inet_aton(MDNS_ADDR) + socket.inet_aton(self.ip)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(self.ip))
sock.settimeout(1.0)
return sock
def _name_taken(self) -> bool:
"""Ask whether anyone else already answers for our name."""
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
probe.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
probe.settimeout(0.4)
probe.sendto(_build_query(self.name), (MDNS_ADDR, MDNS_PORT))
deadline = time.time() + 0.9
while time.time() < deadline:
try:
data, addr = probe.recvfrom(4096)
except socket.timeout:
continue
except OSError:
break
if addr[0] == self.ip:
continue # our own machine, ignore
if self._is_answer_for_us(data):
log.warning("mDNS: %s already answered by %s", self.name, addr[0])
return True
except OSError:
pass
finally:
probe.close()
return False
def _is_answer_for_us(self, data: bytes) -> bool:
try:
_, flags, qd, an, _, _ = struct.unpack_from("!HHHHHH", data, 0)
except struct.error:
return False
if not (flags & 0x8000) or an == 0:
return False
offset = 12
for _ in range(qd):
_, offset = _decode_name(data, offset)
offset += 4
for _ in range(an):
name, offset = _decode_name(data, offset)
try:
rtype, _, _, rdlen = struct.unpack_from("!HHIH", data, offset)
except struct.error:
return False
offset += 10 + rdlen
if rtype == QTYPE_A and name.lower() == self.name:
return True
return False
# -- serving ------------------------------------------------------------
def _serve(self) -> None:
last_ip_check = time.time()
while not self._stop.is_set():
sock = self._sock
if sock is None:
return
try:
data, addr = sock.recvfrom(4096)
except socket.timeout:
data = None
except OSError:
if not self._stop.is_set():
log.debug("mDNS: socket closed")
return
# DHCP leases change; an advertiser pinned to a stale address will
# confidently point the name at nothing.
if time.time() - last_ip_check > 30:
last_ip_check = time.time()
current = self._address_of()
if current and current != self.ip:
log.info("mDNS: address changed %s -> %s, re-advertising", self.ip, current)
self.ip = current
if not data:
continue
try:
self._handle(data, addr)
except Exception as exc: # never let one packet stop us
log.debug("mDNS: bad packet from %s: %s", addr, exc)
def _handle(self, data: bytes, addr) -> None:
try:
_, flags, qdcount, _, _, _ = struct.unpack_from("!HHHHHH", data, 0)
except struct.error:
return
if flags & 0x8000 or qdcount == 0: # a response, not a question
return
offset = 12
for _ in range(qdcount):
name, offset = _decode_name(data, offset)
try:
qtype, qclass = struct.unpack_from("!HH", data, offset)
except struct.error:
return
offset += 4
if name.lower() != self.name:
continue
if qtype not in (QTYPE_A, QTYPE_ANY):
continue
response = _build_response(self.name, self.ip)
wants_unicast = bool(qclass & UNICAST_RESPONSE)
sock = self._sock
if sock is None:
return
try:
if wants_unicast:
sock.sendto(response, addr)
else:
# Multicast so every listener refreshes its cache, and
# unicast too because some stacks only accept the direct
# reply.
sock.sendto(response, (MDNS_ADDR, MDNS_PORT))
sock.sendto(response, addr)
self.answered += 1
except OSError:
pass
# -- introspection ------------------------------------------------------
def status(self) -> dict:
return {
"name": self.name,
"url": f"http://{self.name}:{self.port}",
"ip": self.ip,
"running": bool(self._thread and self._thread.is_alive()),
"answered": self.answered,
"conflict": self.conflict,
"error": self.error,
}