diff --git a/README.md b/README.md index 780c4e5..228d29e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ Then follow the printed reminders (append `bashrc.snippet`, set your 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 publish [--config ~/.config/mdns/services.json] [--hostname NAME] +mdns browse --watch [--type ...] [--no-resolve] # live monitor (avahi-browse -a) +mdns publish [--config PATH] [--hostname NAME] # services from JSON +mdns publish --address # bare A record (avahi-publish -a) mdns cache list | clear [host] ``` @@ -62,6 +64,8 @@ mdns resolve lenovo-ideapad # -> 192.168.1.106 (LAN, fast stdl mdns resolve lenovo-ideapad --family v6 # -> fe80::... (via zeroconf) mdns reverse 192.168.1.17 # -> dell-latitude3400.local mdns publish # advertise services from ~/.config/mdns/services.json +mdns publish --address printer 192.168.1.50 # publish printer.local -> that IP +mdns browse --watch # live +/=/- events, all types mdns cache list # inspect TTL-aware cache ``` @@ -144,13 +148,38 @@ last-resort fallback. | A / AAAA hostname resolution | ✓ | stdlib for A, zeroconf for AAAA | | PTR reverse lookup | ✓ | stdlib multicast, IPv4 + IPv6 | | DNS-SD service browsing | ✓ | `mdns browse`, all types or one | +| Live service monitor | ✓ | `mdns browse --watch` (like avahi-browse)| | Publisher (hostname + services) | ✓ | via `zeroconf`, with probing + goodbye | +| Bare hostname->IP publish | ✓ | `mdns publish --address` (avahi-publish -a)| +| Name-conflict handling | ✓ | RFC 6762 probe + auto-rename | | 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` | | Legacy unicast queries | — | Not implemented (rarely needed) | | Known-answer suppression | — | zeroconf handles internally | +## Interoperability (Avahi / Bonjour) + +Because publishing goes through `zeroconf` (a full RFC 6762 / 6763 +implementation), anything you advertise is visible to standard mDNS stacks — +**no special client needed on the other end**. + +Verified on a real LAN: + +- **Termux → Avahi (Linux):** with `mdns publish` running on the phone, a Linux + box with `nss-mdns` resolved `movil.local` and pinged it, with no extra config: + ``` + $ getent hosts movil.local + 192.168.1.112 movil.local + $ ping movil.local # 64 bytes ... time=3.84 ms + ``` +- **Avahi → Termux:** `mdns browse` on the phone sees services published by + Avahi/Bonjour hosts (SMB, SSH, `_workstation`, Chromecast, AirPlay, …). + +So a Mac (Bonjour), a Linux box (Avahi), or a Windows machine (Bonjour Print +Services) will all discover and resolve what this tool publishes, and vice +versa. + ## Gotchas found the hard way - **`getent` doesn't exist on Termux.** Scripts that shell out to it silently diff --git a/bin/mdns b/bin/mdns index 4cde795..5c4d2e8 100755 --- a/bin/mdns +++ b/bin/mdns @@ -45,6 +45,10 @@ def _cmd_reverse(a: argparse.Namespace) -> int: def _cmd_browse(a: argparse.Namespace) -> int: + if a.watch: + from mdns_tools import watch as _w + _w.watch(service_type=a.type, resolve=not a.no_resolve) + return 0 from mdns_tools import browse as _br # loads zeroconf if a.list_types: for t in _br.list_types(duration=a.duration): @@ -62,6 +66,9 @@ def _cmd_browse(a: argparse.Namespace) -> int: def _cmd_publish(a: argparse.Namespace) -> int: from mdns_tools import publish as _p # loads zeroconf + if a.address: + _p.publish_address(a.address[0], a.address[1], verbose=not a.quiet) + return 0 _p.run(config_path=a.config, hostname=a.hostname, verbose=not a.quiet) return 0 @@ -103,11 +110,17 @@ def main() -> int: b.add_argument("--duration", type=float, default=3.0) b.add_argument("--json", action="store_true") b.add_argument("--list-types", action="store_true") + b.add_argument("--watch", action="store_true", + help="live monitor (+/=/- events) until Ctrl+C") + b.add_argument("--no-resolve", action="store_true", + help="in --watch, don't resolve addresses") b.set_defaults(func=_cmd_browse) pu = sub.add_parser("publish", help="advertise this device + services") - pu.add_argument("--config", help="path to services YAML/JSON") + pu.add_argument("--config", help="path to services JSON") 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("--quiet", action="store_true") pu.set_defaults(func=_cmd_publish) diff --git a/config/bashrc.snippet b/config/bashrc.snippet index 68d3f24..97216e6 100644 --- a/config/bashrc.snippet +++ b/config/bashrc.snippet @@ -17,6 +17,7 @@ alias mdns-types='mdns browse --list-types' alias mdns-ssh='mdns browse --type _ssh._tcp.local.' alias mdns-smb='mdns browse --type _smb._tcp.local.' alias mdns-http='mdns browse --type _http._tcp.local.' +alias mdns-watch='mdns browse --watch' alias mdns-cache='mdns cache list' alias mdns-forget='mdns cache clear' diff --git a/lib/mdns_tools/publish.py b/lib/mdns_tools/publish.py index 93bc5c4..ba7ca39 100644 --- a/lib/mdns_tools/publish.py +++ b/lib/mdns_tools/publish.py @@ -1,16 +1,10 @@ -"""Publish this device on the LAN: hostname + optional services. - -Config example (config/services.example.yaml or JSON): - hostname: movil - services: - - {type: _ssh._tcp, port: 8022, name: "Termux SSH"} - - {type: _smb._tcp, port: 4450, name: "Termux SMB"} -""" +"""Publish on the LAN: services (JSON config) or a bare hostname->IP.""" +import json import socket import time from pathlib import Path -from zeroconf import Zeroconf, ServiceInfo +from zeroconf import Zeroconf, ServiceInfo, NonUniqueNameException def _local_ipv4() -> str: @@ -24,63 +18,34 @@ def _local_ipv4() -> str: s.close() +def _addr_bytes(ip: str) -> bytes: + if ":" in ip: + return socket.inet_pton(socket.AF_INET6, ip) + return socket.inet_aton(ip) + + +_DEFAULT_CONFIG = Path.home() / ".config" / "mdns" / "services.json" + + def _load_config(path: str | None) -> dict: - if not path: - return {"hostname": "movil", "services": []} - p = Path(path).expanduser() + p = Path(path).expanduser() if path else _DEFAULT_CONFIG if not p.exists(): return {"hostname": "movil", "services": []} - text = p.read_text() - if p.suffix in (".yml", ".yaml"): - try: - import yaml # type: ignore - return yaml.safe_load(text) or {} - except ImportError: - print("warning: PyYAML not installed, using minimal parser") - import json try: - return json.loads(text) - except Exception: + return json.loads(p.read_text()) + except Exception as e: + print(f"warning: bad config {p}: {e}") return {"hostname": "movil", "services": []} -def run(config_path: str | None = None, - hostname: str | None = None, - verbose: bool = True) -> None: - """Publish hostname + services and block forever.""" - cfg = _load_config(config_path) - host = hostname or cfg.get("hostname", "movil") - ip = _local_ipv4() - zc = Zeroconf() - infos: list[ServiceInfo] = [] - # Register hostname via _workstation._tcp so the device shows up in browsers - ws = ServiceInfo( - type_="_workstation._tcp.local.", - name=f"{host} [{ip.replace('.', '-')}]._workstation._tcp.local.", - addresses=[socket.inet_aton(ip)], - port=9, - server=f"{host}.local.", - ) - zc.register_service(ws) - infos.append(ws) - for svc in (cfg.get("services") or []): - try: - t = svc["type"].rstrip(".") + ".local." - n = f'{svc.get("name", host)}.{t}' - info = ServiceInfo( - type_=t, - name=n, - addresses=[socket.inet_aton(ip)], - port=int(svc["port"]), - server=f"{host}.local.", - properties=svc.get("txt", {}), - ) - zc.register_service(info) - infos.append(info) - except Exception as e: - print(f"warning: could not register {svc}: {e}") +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] published {host}.local ({ip}), {len(infos)} record(s). Ctrl+C to stop.") + print(f"[mdns] {label} ({len(infos)} record(s)). Ctrl+C to stop.") try: while True: time.sleep(60) @@ -95,3 +60,37 @@ def run(config_path: str | None = None, zc.close() if verbose: print("[mdns] stopped, goodbye packets sent.") + + +def _workstation(host: str, ip: str) -> ServiceInfo: + return ServiceInfo( + type_="_workstation._tcp.local.", + name=f"{host} [{ip.replace('.', '-')}]._workstation._tcp.local.", + addresses=[_addr_bytes(ip)], port=9, server=f"{host}.local.", + ) + + +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() + infos = [_workstation(host, ip)] + for svc in (cfg.get("services") or []): + try: + t = svc["type"].rstrip(".") + ".local." + infos.append(ServiceInfo( + type_=t, name=f'{svc.get("name", host)}.{t}', + addresses=[_addr_bytes(ip)], port=int(svc["port"]), + server=f"{host}.local.", properties=svc.get("txt", {}), + )) + except Exception as e: + print(f"warning: could not register {svc}: {e}") + _serve(Zeroconf(), infos, 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`).""" + host = name.rstrip(".").removesuffix(".local") + _serve(Zeroconf(), [_workstation(host, ip)], verbose, + f"published {host}.local -> {ip}") diff --git a/lib/mdns_tools/watch.py b/lib/mdns_tools/watch.py new file mode 100644 index 0000000..409abe7 --- /dev/null +++ b/lib/mdns_tools/watch.py @@ -0,0 +1,72 @@ +"""Live service monitor — like `avahi-browse` without -t. + +Prints events as they happen: + + service appeared + = service appeared and was resolved (address:port) + - service disappeared + +With no --type it browses the DNS-SD meta-service +(`_services._dns-sd._udp.local.`) to discover types dynamically and attaches a +browser to each — the equivalent of `avahi-browse -a`. +""" +import time + +from zeroconf import Zeroconf, ServiceBrowser, ServiceListener + + +class _EventListener(ServiceListener): + def __init__(self, resolve: bool = True) -> None: + self.resolve = resolve + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: # noqa: N802 + line = f"+ {type_:<30} {name}" + if self.resolve: + info = zc.get_service_info(type_, name, timeout=1500) + if info: + addrs = ", ".join(info.parsed_addresses() or []) or "-" + line = f"= {type_:<30} {name} {addrs}:{info.port}" + print(line, flush=True) + + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: # noqa: N802 + return + + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: # noqa: N802 + print(f"- {type_:<30} {name}", flush=True) + + +class _TypeSpawner(ServiceListener): + """On discovering a new service TYPE, attach a browser for it.""" + + def __init__(self, zc: Zeroconf, svc_listener: ServiceListener) -> None: + self.zc = zc + self.svc_listener = svc_listener + self.browsers: dict[str, ServiceBrowser] = {} + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: # noqa: N802 + # For the meta-service, `name` is the discovered type (e.g. _http._tcp.local.) + if name not in self.browsers: + self.browsers[name] = ServiceBrowser(zc, name, self.svc_listener) + + def update_service(self, *a) -> None: # noqa: N802 + return + + def remove_service(self, *a) -> None: # noqa: N802 + return + + +def watch(service_type: str | None = None, resolve: bool = True) -> None: + zc = Zeroconf() + svc = _EventListener(resolve=resolve) + try: + if service_type: + ServiceBrowser(zc, service_type, svc) + else: + ServiceBrowser(zc, "_services._dns-sd._udp.local.", + _TypeSpawner(zc, svc)) + print("[mdns] watching — Ctrl+C to stop", flush=True) + while True: + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + zc.close()