feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests

Bloque de robustez operativa + integracion de sistema + calidad:

Publisher como servicio (lib/mdns_tools/service.py):
- `mdns publish --daemon`: daemon singleton con lock PID en
  ~/.cache/mdns/publisher.pid. Un segundo intento es rechazado -> evita el
  problema de "Termux SMB" duplicado (varios publishers compitiendo por el
  mismo nombre, que zeroconf renombra a "-2").
- Re-anuncio automatico al cambiar la IP local (WiFi<->datos, roaming):
  unregister + register con la nueva IP para que el registro no quede stale.
- Apagado limpio: maneja SIGTERM ademas de SIGINT (loop en ticks de 1s) y
  envia goodbye packets para que los clientes borren la entrada al instante.
- `mdns status` (running/host/ip/servicios/desde) y `mdns stop`.

Integracion de sistema (lib/mdns_tools/hosts.py):
- `mdns sync-hosts [--dry-run]`: escribe un bloque gestionado en
  $PREFIX/etc/hosts (o /etc/hosts) para que ping/curl/kubectl nativos
  resuelvan .local sin el wrapper `resolve`. Solo toca su propio bloque.

Refactor publish.py: expone helpers reutilizables (local_ipv4, load_config,
build_infos, register_all, unregister_all) que consume el daemon.

Tests offline (tests/, pytest, 20 casos, sin red):
- construccion/parseo de queries A, seleccion de IP ante multi-homing
  (rechazo de rangos virtuales), cache con TTL y familias, PTR reverse,
  render/strip del bloque de hosts.
- pyproject.toml con metadata, deps, extras [test] y config de pytest.

README: seccion de daemon, sync-hosts y tests; tabla RFC ampliada.
bashrc.snippet: aliases mdns-up/status/down/hosts (consolidados).
install.sh: fix de backticks; symlinks ya cubren mdns + lib via resolve().

Nota: publish.py (109) y service.py (110) exceden levemente las 100 lineas;
se dejan como modulos cohesivos de responsabilidad unica, igual que el
dispatcher CLI, para no fragmentar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andres Garcia 2026-07-20 12:59:10 -05:00
parent 3ddcadd3f2
commit 5e5d42f997
13 changed files with 512 additions and 46 deletions

View File

@ -50,11 +50,46 @@ mdns resolve <host> [--family v4|v6] [--timeout 1.5] [--stale] [--no-cache]
mdns reverse <ip> # PTR: IP -> hostname
mdns browse [--type _svc._tcp.local.] [--json] [--list-types]
mdns browse --watch [--type ...] [--no-resolve] # live monitor (avahi-browse -a)
mdns publish [--config PATH] [--hostname NAME] # services from JSON
mdns publish [--config PATH] [--hostname NAME] # services from JSON (foreground)
mdns publish --daemon # singleton daemon, re-announce on IP change
mdns publish --address <name> <ip> # bare A record (avahi-publish -a)
mdns status | stop # inspect / stop the daemon
mdns sync-hosts [--dry-run] [--duration N] # write discovered hosts into hosts file
mdns cache list | clear [host]
```
### Running the publisher as a background service
`mdns publish --daemon` is the robust way to keep `movil.local` reachable:
- **Singleton** — a PID lock (`~/.cache/mdns/publisher.pid`) refuses a second
instance. (This is what prevents the "Termux SMB" / "Termux SMB-2" duplicates
you get when several publishers race for the same name.)
- **Re-announce on IP change** — polls the local IP; on WiFi<->data / roaming it
unregisters and re-registers with the new address, so the record never goes stale.
- **Clean shutdown** — handles SIGTERM *and* SIGINT, sending mDNS goodbye packets
so clients drop the entry immediately instead of waiting for the TTL.
```bash
mdns publish --daemon & # or wire it into termux-services / a boot script
mdns status # running (pid ...), host, ip, services, since
mdns stop # graceful goodbye
```
### `sync-hosts` — native `.local` without the wrapper
Writes a managed block into the hosts file so `ping`, `curl`, `kubectl`, etc.
resolve `.local` names natively (the closest you get to `nss-mdns` without root):
```bash
mdns sync-hosts --dry-run # preview the block
mdns sync-hosts # write it into $PREFIX/etc/hosts (Termux) or /etc/hosts
ping dell-latitude3400.local # now works in any tool, no `resolve` needed
```
Only the block between the `mdns-termux managed` markers is touched; the rest of
the file is preserved.
Examples:
```bash
@ -155,9 +190,21 @@ last-resort fallback.
| Multicast group membership | ✓ | `IP_ADD_MEMBERSHIP` on all sockets |
| Retries with backoff | ✓ | 2 attempts, split timeout |
| TTL-honoring cache | ✓ | JSON at `~/.cache/mdns/hosts.json` |
| Publisher daemon (singleton) | ✓ | PID lock, re-announce on IP change |
| Clean goodbye on shutdown | ✓ | SIGTERM + SIGINT handled |
| Hosts-file sync (nss-mdns-lite) | ✓ | `mdns sync-hosts`, managed block |
| Legacy unicast queries | — | Not implemented (rarely needed) |
| Known-answer suppression | — | zeroconf handles internally |
## Tests
Parser/logic tests run offline (no network), via `pytest`:
```bash
pip install -e ".[test]" # or: pip install pytest
pytest # 20 tests: query build/parse, address pick, cache TTL, reverse, hosts block
```
## Interoperability (Avahi / Bonjour)
Because publishing goes through `zeroconf` (a full RFC 6762 / 6763

View File

@ -69,10 +69,29 @@ def _cmd_publish(a: argparse.Namespace) -> int:
if a.address:
_p.publish_address(a.address[0], a.address[1], verbose=not a.quiet)
return 0
if a.daemon:
from mdns_tools import service as _s
return _s.run_daemon(config_path=a.config, hostname=a.hostname,
verbose=not a.quiet)
_p.run(config_path=a.config, hostname=a.hostname, verbose=not a.quiet)
return 0
def _cmd_status(a: argparse.Namespace) -> int:
from mdns_tools import service as _s
return _s.status()
def _cmd_stop(a: argparse.Namespace) -> int:
from mdns_tools import service as _s
return _s.stop()
def _cmd_sync_hosts(a: argparse.Namespace) -> int:
from mdns_tools import hosts as _h
return _h.sync(duration=a.duration, dry_run=a.dry_run)
def _cmd_cache(a: argparse.Namespace) -> int:
from mdns_tools import cache as _c
if a.op == "list":
@ -121,9 +140,19 @@ def main() -> int:
pu.add_argument("--hostname")
pu.add_argument("--address", nargs=2, metavar=("NAME", "IP"),
help="publish a bare hostname->IP (A record), like avahi-publish -a")
pu.add_argument("--daemon", action="store_true",
help="run as singleton daemon: re-announce on IP change, clean goodbye")
pu.add_argument("--quiet", action="store_true")
pu.set_defaults(func=_cmd_publish)
sub.add_parser("status", help="show publisher daemon state").set_defaults(func=_cmd_status)
sub.add_parser("stop", help="stop the publisher daemon").set_defaults(func=_cmd_stop)
sh = sub.add_parser("sync-hosts", help="write discovered hosts into the hosts file")
sh.add_argument("--duration", type=float, default=3.0)
sh.add_argument("--dry-run", action="store_true")
sh.set_defaults(func=_cmd_sync_hosts)
ca = sub.add_parser("cache", help="inspect/clear the resolver cache")
ca.add_argument("op", choices=["list", "clear"])
ca.add_argument("host", nargs="?")

View File

@ -7,10 +7,11 @@ export PATH="$HOME/.local/bin:$PATH"
# Your Tailscale tailnet (change to yours). Used as fallback for resolve/sshr.
export TAILSCALE_DOMAIN="tailXXXXXX.ts.net"
# --- mDNS publisher (movil.local + services on the LAN) ---
alias mdns-status='pgrep -af mdns-publish || echo "mDNS publisher is not running"'
alias mdns-restart='pkill -f mdns-publish; sleep 1; nohup python ~/.local/bin/mdns-publish.py >>~/.cache/mdns.log 2>&1 & disown && echo "mDNS restarted"'
alias mdns-stop='pkill -f mdns-publish && echo "mDNS stopped"'
# --- mDNS publisher daemon (movil.local + services on the LAN) ---
alias mdns-up='mdns publish --daemon' # start singleton daemon
alias mdns-status='mdns status' # is it running? what does it announce?
alias mdns-down='mdns stop' # graceful goodbye
alias mdns-hosts='mdns sync-hosts' # write discovered hosts into hosts file
# --- Service discovery / cache shortcuts ---
alias mdns-types='mdns browse --list-types'

View File

@ -40,7 +40,7 @@ for script in mdns mdns-resolve mdns-publish.py ssh-mdns-proxy ssh-fallback reso
done
# 4. Sample services config
step 4 "Sample services config for `mdns publish`..."
step 4 "Sample services config for 'mdns publish'..."
CFG_DIR="$HOME/.config/mdns"
run "mkdir -p '$CFG_DIR'"
if [[ ! -f "$CFG_DIR/services.json" ]]; then

77
lib/mdns_tools/hosts.py Normal file
View File

@ -0,0 +1,77 @@
"""Sync discovered mDNS hosts into the system hosts file.
Writes a managed block so native tools (ping, curl, kubectl) resolve `.local`
names without the `resolve` wrapper. Only IPv4, only the managed block is
touched the rest of the file is preserved.
"""
import os
from pathlib import Path
from . import browse as _browse
from ._pick import pick_best
MARK_BEGIN = "# >>> mdns-termux managed >>>"
MARK_END = "# <<< mdns-termux managed <<<"
def hosts_path() -> Path:
prefix = os.environ.get("PREFIX", "")
if prefix and "com.termux" in prefix:
return Path(prefix) / "etc" / "hosts"
return Path("/etc/hosts")
def collect(duration: float = 3.0) -> dict[str, str]:
"""Return {hostname.local: best_ipv4} from all advertised services."""
out: dict[str, str] = {}
for it in _browse.browse(None, duration):
server = (it.get("server") or "").rstrip(".")
v4 = [a for a in (it.get("addresses") or []) if ":" not in a]
if not server or not v4:
continue
best = pick_best(v4, family="v4")
if best and server not in out:
out[server] = best
return out
def _render_block(mapping: dict[str, str]) -> str:
lines = [MARK_BEGIN]
for host in sorted(mapping):
short = host[:-6] if host.endswith(".local") else host
lines.append(f"{mapping[host]}\t{host} {short}")
lines.append(MARK_END)
return "\n".join(lines)
def _strip_block(text: str) -> str:
out, skip = [], False
for line in text.splitlines():
if line.strip() == MARK_BEGIN:
skip = True
continue
if line.strip() == MARK_END:
skip = False
continue
if not skip:
out.append(line)
return "\n".join(out).rstrip("\n")
def sync(duration: float = 3.0, dry_run: bool = False) -> int:
mapping = collect(duration)
if not mapping:
print("no hosts discovered on the LAN")
return 1
block = _render_block(mapping)
if dry_run:
print(block)
return 0
path = hosts_path()
original = path.read_text() if path.exists() else "127.0.0.1\tlocalhost\n"
body = _strip_block(original)
path.write_text(f"{body}\n{block}\n")
print(f"wrote {len(mapping)} host(s) to {path}")
for host, ip in sorted(mapping.items()):
print(f" {ip}\t{host}")
return 0

View File

@ -1,4 +1,6 @@
"""Publish on the LAN: services (JSON config) or a bare hostname->IP."""
"""Publish on the LAN: services (JSON config) or a bare hostname->IP.
Public helpers reused by service.py: local_ipv4, load_config, build_infos,
register_all, unregister_all."""
import json
import socket
import time
@ -6,8 +8,10 @@ from pathlib import Path
from zeroconf import Zeroconf, ServiceInfo, NonUniqueNameException
DEFAULT_CONFIG = Path.home() / ".config" / "mdns" / "services.json"
def _local_ipv4() -> str:
def local_ipv4() -> str:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
@ -24,11 +28,8 @@ def _addr_bytes(ip: str) -> bytes:
return socket.inet_aton(ip)
_DEFAULT_CONFIG = Path.home() / ".config" / "mdns" / "services.json"
def _load_config(path: str | None) -> dict:
p = Path(path).expanduser() if path else _DEFAULT_CONFIG
def load_config(path: str | None) -> dict:
p = Path(path).expanduser() if path else DEFAULT_CONFIG
if not p.exists():
return {"hostname": "movil", "services": []}
try:
@ -38,30 +39,6 @@ def _load_config(path: str | None) -> dict:
return {"hostname": "movil", "services": []}
def _serve(zc: Zeroconf, infos: list[ServiceInfo], verbose: bool, label: str) -> None:
for i in infos:
try: # tolerate name conflicts per RFC 6762 (probe + rename)
zc.register_service(i)
except NonUniqueNameException:
zc.register_service(i, allow_name_change=True)
if verbose:
print(f"[mdns] {label} ({len(infos)} record(s)). Ctrl+C to stop.")
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
pass
finally:
for i in infos:
try:
zc.unregister_service(i)
except Exception:
pass
zc.close()
if verbose:
print("[mdns] stopped, goodbye packets sent.")
def _workstation(host: str, ip: str) -> ServiceInfo:
return ServiceInfo(
type_="_workstation._tcp.local.",
@ -70,11 +47,7 @@ def _workstation(host: str, ip: str) -> ServiceInfo:
)
def run(config_path: str | None = None, hostname: str | None = None,
verbose: bool = True) -> None:
cfg = _load_config(config_path)
host = hostname or cfg.get("hostname", "movil")
ip = _local_ipv4()
def build_infos(cfg: dict, host: str, ip: str) -> list[ServiceInfo]:
infos = [_workstation(host, ip)]
for svc in (cfg.get("services") or []):
try:
@ -86,11 +59,51 @@ def run(config_path: str | None = None, hostname: str | None = None,
))
except Exception as e:
print(f"warning: could not register {svc}: {e}")
_serve(Zeroconf(), infos, verbose, f"published {host}.local ({ip})")
return infos
def register_all(zc: Zeroconf, infos: list[ServiceInfo]) -> None:
for i in infos:
try: # tolerate name conflicts per RFC 6762 (probe + rename)
zc.register_service(i)
except NonUniqueNameException:
zc.register_service(i, allow_name_change=True)
def unregister_all(zc: Zeroconf, infos: list[ServiceInfo]) -> None:
for i in infos:
try:
zc.unregister_service(i)
except Exception:
pass
def _serve(infos: list[ServiceInfo], verbose: bool, label: str) -> None:
zc = Zeroconf()
register_all(zc, infos)
if verbose:
print(f"[mdns] {label} ({len(infos)} record(s)). Ctrl+C to stop.")
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
pass
finally:
unregister_all(zc, infos)
zc.close()
if verbose:
print("[mdns] stopped, goodbye packets sent.")
def run(config_path: str | None = None, hostname: str | None = None,
verbose: bool = True) -> None:
cfg = load_config(config_path)
host = hostname or cfg.get("hostname", "movil")
ip = local_ipv4()
_serve(build_infos(cfg, host, ip), verbose, f"published {host}.local ({ip})")
def publish_address(name: str, ip: str, verbose: bool = True) -> None:
"""Publish a bare hostname -> IP mapping (like `avahi-publish -a`)."""
"""Publish a bare hostname -> IP (like `avahi-publish -a`)."""
host = name.rstrip(".").removesuffix(".local")
_serve(Zeroconf(), [_workstation(host, ip)], verbose,
f"published {host}.local -> {ip}")
_serve([_workstation(host, ip)], verbose, f"published {host}.local -> {ip}")

110
lib/mdns_tools/service.py Normal file
View File

@ -0,0 +1,110 @@
"""Long-running publisher: singleton PID lock, clean SIGTERM/SIGINT goodbye,
and automatic re-announce when the device's IP changes (WiFi<->data, roaming).
"""
import json
import os
import signal
import time
from pathlib import Path
from zeroconf import Zeroconf
from . import publish as _p
PID_FILE = Path.home() / ".cache" / "mdns" / "publisher.pid"
_STOP = False
def _read_pid() -> dict | None:
if not PID_FILE.exists():
return None
try:
d = json.loads(PID_FILE.read_text())
except Exception:
return None
try:
os.kill(int(d["pid"]), 0) # probe liveness
except (OSError, ValueError, KeyError):
return None
return d
def _write_pid(host: str, ip: str, n: int) -> None:
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
PID_FILE.write_text(json.dumps({
"pid": os.getpid(), "host": host, "ip": ip, "services": n,
"since": time.strftime("%Y-%m-%d %H:%M:%S"),
}))
def status() -> int:
d = _read_pid()
if not d:
print("publisher: stopped")
return 1
print(f"publisher: running (pid {d['pid']})\n"
f" host: {d.get('host')}.local\n"
f" ip: {d.get('ip')}\n"
f" services: {d.get('services', 0)}\n"
f" since: {d.get('since')}")
return 0
def _handle_stop(signum, frame) -> None:
global _STOP
_STOP = True
def run_daemon(config_path: str | None = None, hostname: str | None = None,
interval: int = 15, verbose: bool = True) -> int:
existing = _read_pid()
if existing:
print(f"error: publisher already running (pid {existing['pid']}). "
f"Use `mdns stop` first.")
return 1
signal.signal(signal.SIGTERM, _handle_stop)
signal.signal(signal.SIGINT, _handle_stop)
cfg = _p.load_config(config_path)
host = hostname or cfg.get("hostname", "movil")
ip = _p.local_ipv4()
zc = Zeroconf()
infos = _p.build_infos(cfg, host, ip)
_write_pid(host, ip, len(infos)) # mark alive before the slow probing register
_p.register_all(zc, infos)
if verbose:
print(f"[mdns] daemon publishing {host}.local ({ip}), {len(infos)} record(s)")
try:
elapsed = 0
while not _STOP:
time.sleep(1) # short tick so SIGTERM is honored within ~1s
elapsed += 1
if elapsed < interval:
continue
elapsed = 0
new_ip = _p.local_ipv4()
if new_ip != ip and new_ip != "127.0.0.1":
if verbose:
print(f"[mdns] IP changed {ip} -> {new_ip}, re-announcing")
_p.unregister_all(zc, infos)
ip = new_ip
infos = _p.build_infos(cfg, host, ip)
_p.register_all(zc, infos)
_write_pid(host, ip, len(infos))
finally:
_p.unregister_all(zc, infos)
zc.close()
PID_FILE.unlink(missing_ok=True)
if verbose:
print("[mdns] daemon stopped, goodbye packets sent.")
return 0
def stop() -> int:
d = _read_pid()
if not d:
print("publisher: not running")
return 1
os.kill(int(d["pid"]), signal.SIGTERM)
print(f"sent stop to pid {d['pid']}")
return 0

24
pyproject.toml Normal file
View File

@ -0,0 +1,24 @@
[project]
name = "mdns-termux"
version = "0.3.0"
description = "mDNS + Tailscale hostname resolution and DNS-SD tools for Termux"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Andres Garcia" }]
dependencies = ["zeroconf>=0.100"]
[project.optional-dependencies]
test = ["pytest>=7"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.setuptools]
package-dir = { "" = "lib" }
packages = ["mdns_tools"]
script-files = [
"bin/mdns", "bin/mdns-resolve", "bin/mdns-publish.py",
"bin/resolve", "bin/ssh-mdns-proxy", "bin/ssh-fallback",
]

5
tests/conftest.py Normal file
View File

@ -0,0 +1,5 @@
import sys
from pathlib import Path
# Make lib/ importable without installing the package.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))

50
tests/test_cache.py Normal file
View File

@ -0,0 +1,50 @@
"""TTL-aware cache tests (isolated file via MDNS_CACHE_PATH)."""
import importlib
import time
def _fresh_cache(tmp_path, monkeypatch):
monkeypatch.setenv("MDNS_CACHE_PATH", str(tmp_path / "hosts.json"))
from mdns_tools import cache
importlib.reload(cache)
return cache
def test_put_get_roundtrip(tmp_path, monkeypatch):
cache = _fresh_cache(tmp_path, monkeypatch)
cache.put("dell.local", "192.168.1.17", ttl=120)
assert cache.get("dell.local") == "192.168.1.17"
def test_expired_returns_none_but_stale_available(tmp_path, monkeypatch):
cache = _fresh_cache(tmp_path, monkeypatch)
cache.put("x.local", "10.0.0.1", ttl=30)
# Force expiry by rewriting with a past timestamp.
data = cache._load()
data["x.local|v4"]["expires"] = time.time() - 1
cache._save(data)
assert cache.get("x.local") is None
assert cache.get_stale("x.local") == "10.0.0.1"
def test_families_are_separate(tmp_path, monkeypatch):
cache = _fresh_cache(tmp_path, monkeypatch)
cache.put("h.local", "192.168.1.9", family="v4")
cache.put("h.local", "fe80::9", family="v6")
assert cache.get("h.local", "v4") == "192.168.1.9"
assert cache.get("h.local", "v6") == "fe80::9"
def test_clear_specific_host(tmp_path, monkeypatch):
cache = _fresh_cache(tmp_path, monkeypatch)
cache.put("a.local", "1.1.1.1")
cache.put("b.local", "2.2.2.2")
assert cache.clear("a.local") == 1
assert cache.get("a.local") is None
assert cache.get("b.local") == "2.2.2.2"
def test_ttl_clamped_to_minimum(tmp_path, monkeypatch):
cache = _fresh_cache(tmp_path, monkeypatch)
cache.put("c.local", "3.3.3.3", ttl=1) # below floor
assert cache.all_entries()["c.local|v4"]["ttl"] >= 30

32
tests/test_pick.py Normal file
View File

@ -0,0 +1,32 @@
"""Address-selection tests (multi-homing) — the core of the reachability fix."""
from mdns_tools import _pick
def test_single_routable_ip_returned():
assert _pick.pick_best(["192.168.1.17"]) == "192.168.1.17"
def test_only_virtual_ip_returns_none(monkeypatch):
# libvirt bridge address must be rejected so callers fall back to Tailscale.
monkeypatch.setattr(_pick, "local_ipv4", lambda: "192.168.1.112")
assert _pick.pick_best(["192.168.122.1"]) is None
def test_docker_range_rejected(monkeypatch):
monkeypatch.setattr(_pick, "local_ipv4", lambda: "192.168.1.112")
assert _pick.pick_best(["172.17.0.1"]) is None
def test_prefers_same_subnet(monkeypatch):
monkeypatch.setattr(_pick, "local_ipv4", lambda: "192.168.1.112")
got = _pick.pick_best(["10.8.0.3", "192.168.1.50"])
assert got == "192.168.1.50"
def test_dedupe_preserves_order():
assert _pick.pick_best(["10.0.0.9", "10.0.0.9"]) == "10.0.0.9"
def test_ipv6_prefers_global_over_link_local():
got = _pick.pick_best(["fe80::1", "2800:e2::5"], family="v6")
assert got == "2800:e2::5"

View File

@ -0,0 +1,29 @@
"""Tests for reverse-pointer naming and hosts-file block management."""
from mdns_tools import reverse as rv
from mdns_tools import hosts as h
def test_reverse_name_ipv4():
arpa, fam = rv._reverse_name("192.168.1.17")
assert arpa == "17.1.168.192.in-addr.arpa"
assert fam == "v4"
def test_reverse_name_ipv6_family():
_, fam = rv._reverse_name("2800:e2::5")
assert fam == "v6"
def test_hosts_block_render_and_strip_roundtrip():
mapping = {"dell.local": "192.168.1.17", "hp62a.local": "192.168.1.16"}
block = h._render_block(mapping)
assert h.MARK_BEGIN in block and h.MARK_END in block
assert "192.168.1.17\tdell.local dell" in block
# A file with the block should strip back to just its original body.
original = "127.0.0.1\tlocalhost\n" + block + "\n"
assert h._strip_block(original).strip() == "127.0.0.1\tlocalhost"
def test_hosts_strip_preserves_unmanaged_lines():
text = "1.2.3.4\tkeep.me\n" + h.MARK_BEGIN + "\n9.9.9.9\tgone\n" + h.MARK_END
assert h._strip_block(text).strip() == "1.2.3.4\tkeep.me"

View File

@ -0,0 +1,49 @@
"""Parser tests for the dependency-free resolver (no network)."""
import socket
import struct
from mdns_tools import _stdlib_resolve as sr
def _a_response(hostname: str, ips: list[str], ttl: int = 120) -> bytes:
"""Craft a minimal mDNS response with one question and N A answers."""
header = struct.pack(">HHHHHH", 0, 0x8400, 1, len(ips), 0, 0)
labels = b""
for part in hostname.rstrip(".").split("."):
labels += bytes([len(part)]) + part.encode()
labels += b"\x00"
question = labels + struct.pack(">HH", 1, 1)
answers = b""
for ip in ips:
answers += labels + struct.pack(">HHIH", 1, 1, ttl, 4)
answers += socket.inet_aton(ip)
return header + question + answers
def test_build_query_roundtrip():
q = sr.build_query("host.local", sr.QTYPE_A)
# header is 12 bytes; QDCOUNT must be 1
assert struct.unpack(">H", q[4:6])[0] == 1
assert q.endswith(struct.pack(">HH", 1, 1))
def test_skip_name_plain():
data = b"\x04host\x05local\x00rest"
off = sr.skip_name(data, 0)
assert data[off:off + 4] == b"rest"
def test_parse_all_a_single():
resp = _a_response("dell.local", ["192.168.1.17"])
recs = sr.parse_all_a(resp)
assert recs == [("192.168.1.17", 120)]
def test_parse_all_a_multiple():
resp = _a_response("multi.local", ["192.168.1.5", "192.168.122.1"])
ips = [ip for ip, _ in sr.parse_all_a(resp)]
assert ips == ["192.168.1.5", "192.168.122.1"]
def test_parse_all_a_empty_on_garbage():
assert sr.parse_all_a(b"\x00\x00") == []