From 5e5d42f997bb49f4ddeab4e8ec83065e474cdb49 Mon Sep 17 00:00:00 2001 From: Andres Garcia Date: Mon, 20 Jul 2026 12:59:10 -0500 Subject: [PATCH] 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) --- README.md | 49 +++++++++++++++- bin/mdns | 29 +++++++++ config/bashrc.snippet | 9 +-- install.sh | 2 +- lib/mdns_tools/hosts.py | 77 ++++++++++++++++++++++++ lib/mdns_tools/publish.py | 93 ++++++++++++++++------------- lib/mdns_tools/service.py | 110 +++++++++++++++++++++++++++++++++++ pyproject.toml | 24 ++++++++ tests/conftest.py | 5 ++ tests/test_cache.py | 50 ++++++++++++++++ tests/test_pick.py | 32 ++++++++++ tests/test_reverse_hosts.py | 29 +++++++++ tests/test_stdlib_resolve.py | 49 ++++++++++++++++ 13 files changed, 512 insertions(+), 46 deletions(-) create mode 100644 lib/mdns_tools/hosts.py create mode 100644 lib/mdns_tools/service.py create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_cache.py create mode 100644 tests/test_pick.py create mode 100644 tests/test_reverse_hosts.py create mode 100644 tests/test_stdlib_resolve.py diff --git a/README.md b/README.md index 228d29e..b7d952b 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,46 @@ mdns resolve [--family v4|v6] [--timeout 1.5] [--stale] [--no-cache] mdns reverse # 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 # 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 diff --git a/bin/mdns b/bin/mdns index 5c4d2e8..54e98fb 100755 --- a/bin/mdns +++ b/bin/mdns @@ -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="?") diff --git a/config/bashrc.snippet b/config/bashrc.snippet index 97216e6..fa2027d 100644 --- a/config/bashrc.snippet +++ b/config/bashrc.snippet @@ -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' diff --git a/install.sh b/install.sh index 65d110d..bae1f47 100644 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/lib/mdns_tools/hosts.py b/lib/mdns_tools/hosts.py new file mode 100644 index 0000000..c1bdf53 --- /dev/null +++ b/lib/mdns_tools/hosts.py @@ -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 diff --git a/lib/mdns_tools/publish.py b/lib/mdns_tools/publish.py index ba7ca39..8676da6 100644 --- a/lib/mdns_tools/publish.py +++ b/lib/mdns_tools/publish.py @@ -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}") diff --git a/lib/mdns_tools/service.py b/lib/mdns_tools/service.py new file mode 100644 index 0000000..f37bfa2 --- /dev/null +++ b/lib/mdns_tools/service.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f428cca --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1812630 --- /dev/null +++ b/tests/conftest.py @@ -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")) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..2a4ecc1 --- /dev/null +++ b/tests/test_cache.py @@ -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 diff --git a/tests/test_pick.py b/tests/test_pick.py new file mode 100644 index 0000000..76c4c95 --- /dev/null +++ b/tests/test_pick.py @@ -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" diff --git a/tests/test_reverse_hosts.py b/tests/test_reverse_hosts.py new file mode 100644 index 0000000..b086111 --- /dev/null +++ b/tests/test_reverse_hosts.py @@ -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" diff --git a/tests/test_stdlib_resolve.py b/tests/test_stdlib_resolve.py new file mode 100644 index 0000000..56b9bc8 --- /dev/null +++ b/tests/test_stdlib_resolve.py @@ -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") == []