feat: monitor en vivo (--watch) y publish --address; interop validada

Añade las dos funciones que Avahi/Bonjour tenían y faltaban:

- `mdns browse --watch`: monitor DNS-SD en vivo (eventos +/=/-), equivalente
  a `avahi-browse -a`. Sin --type descubre tipos dinámicamente via el
  meta-servicio _services._dns-sd._udp.local. y adjunta un browser por tipo.
  Nuevo modulo lib/mdns_tools/watch.py.
- `mdns publish --address <nombre> <ip>`: publica un A record puro
  hostname->IP, equivalente a `avahi-publish -a`.

Robustez del publisher:
- Maneja NonUniqueNameException (probing RFC 6762): si el nombre ya esta
  tomado, deja que zeroconf renombre en vez de crashear.
- `mdns publish` sin --config auto-carga ~/.config/mdns/services.json.

Interoperabilidad validada en LAN real (documentada en README):
- Termux -> Avahi: con `mdns publish` activo, un Linux con nss-mdns resolvio
  `movil.local` y le hizo ping sin configuracion extra (getent + ping OK).
- Avahi -> Termux: `mdns browse` ve los servicios que publican equipos
  Avahi/Bonjour (SMB, SSH, _workstation, Chromecast, AirPlay...).
  Al usar zeroconf (RFC 6762/6763), Mac/Linux/Windows descubren y resuelven
  lo publicado y viceversa, sin cliente especial.

README: seccion de interoperabilidad, tabla RFC ampliada, ejemplos --watch
y --address. bashrc.snippet: alias mdns-watch. publish.py recortado a <100.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andres Garcia 2026-07-20 08:00:07 -05:00
parent 96dcd177ec
commit 3ddcadd3f2
5 changed files with 174 additions and 60 deletions

View File

@ -49,7 +49,9 @@ Then follow the printed reminders (append `bashrc.snippet`, set your
mdns resolve <host> [--family v4|v6] [--timeout 1.5] [--stale] [--no-cache] mdns resolve <host> [--family v4|v6] [--timeout 1.5] [--stale] [--no-cache]
mdns reverse <ip> # PTR: IP -> hostname mdns reverse <ip> # PTR: IP -> hostname
mdns browse [--type _svc._tcp.local.] [--json] [--list-types] 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 <name> <ip> # bare A record (avahi-publish -a)
mdns cache list | clear [host] 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 resolve lenovo-ideapad --family v6 # -> fe80::... (via zeroconf)
mdns reverse 192.168.1.17 # -> dell-latitude3400.local mdns reverse 192.168.1.17 # -> dell-latitude3400.local
mdns publish # advertise services from ~/.config/mdns/services.json 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 mdns cache list # inspect TTL-aware cache
``` ```
@ -144,13 +148,38 @@ last-resort fallback.
| A / AAAA hostname resolution | ✓ | stdlib for A, zeroconf for AAAA | | A / AAAA hostname resolution | ✓ | stdlib for A, zeroconf for AAAA |
| PTR reverse lookup | ✓ | stdlib multicast, IPv4 + IPv6 | | PTR reverse lookup | ✓ | stdlib multicast, IPv4 + IPv6 |
| DNS-SD service browsing | ✓ | `mdns browse`, all types or one | | 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 | | 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 | | Multicast group membership | ✓ | `IP_ADD_MEMBERSHIP` on all sockets |
| Retries with backoff | ✓ | 2 attempts, split timeout | | Retries with backoff | ✓ | 2 attempts, split timeout |
| TTL-honoring cache | ✓ | JSON at `~/.cache/mdns/hosts.json` | | TTL-honoring cache | ✓ | JSON at `~/.cache/mdns/hosts.json` |
| Legacy unicast queries | — | Not implemented (rarely needed) | | Legacy unicast queries | — | Not implemented (rarely needed) |
| Known-answer suppression | — | zeroconf handles internally | | 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 ## Gotchas found the hard way
- **`getent` doesn't exist on Termux.** Scripts that shell out to it silently - **`getent` doesn't exist on Termux.** Scripts that shell out to it silently

View File

@ -45,6 +45,10 @@ def _cmd_reverse(a: argparse.Namespace) -> int:
def _cmd_browse(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 from mdns_tools import browse as _br # loads zeroconf
if a.list_types: if a.list_types:
for t in _br.list_types(duration=a.duration): 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: def _cmd_publish(a: argparse.Namespace) -> int:
from mdns_tools import publish as _p # loads zeroconf 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) _p.run(config_path=a.config, hostname=a.hostname, verbose=not a.quiet)
return 0 return 0
@ -103,11 +110,17 @@ def main() -> int:
b.add_argument("--duration", type=float, default=3.0) b.add_argument("--duration", type=float, default=3.0)
b.add_argument("--json", action="store_true") b.add_argument("--json", action="store_true")
b.add_argument("--list-types", 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) b.set_defaults(func=_cmd_browse)
pu = sub.add_parser("publish", help="advertise this device + services") 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("--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.add_argument("--quiet", action="store_true")
pu.set_defaults(func=_cmd_publish) pu.set_defaults(func=_cmd_publish)

View File

@ -17,6 +17,7 @@ alias mdns-types='mdns browse --list-types'
alias mdns-ssh='mdns browse --type _ssh._tcp.local.' alias mdns-ssh='mdns browse --type _ssh._tcp.local.'
alias mdns-smb='mdns browse --type _smb._tcp.local.' alias mdns-smb='mdns browse --type _smb._tcp.local.'
alias mdns-http='mdns browse --type _http._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-cache='mdns cache list'
alias mdns-forget='mdns cache clear' alias mdns-forget='mdns cache clear'

View File

@ -1,16 +1,10 @@
"""Publish this device on the LAN: hostname + optional services. """Publish on the LAN: services (JSON config) or a bare hostname->IP."""
import json
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"}
"""
import socket import socket
import time import time
from pathlib import Path from pathlib import Path
from zeroconf import Zeroconf, ServiceInfo from zeroconf import Zeroconf, ServiceInfo, NonUniqueNameException
def _local_ipv4() -> str: def _local_ipv4() -> str:
@ -24,63 +18,34 @@ def _local_ipv4() -> str:
s.close() 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: def _load_config(path: str | None) -> dict:
if not path: p = Path(path).expanduser() if path else _DEFAULT_CONFIG
return {"hostname": "movil", "services": []}
p = Path(path).expanduser()
if not p.exists(): if not p.exists():
return {"hostname": "movil", "services": []} 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: try:
return json.loads(text) return json.loads(p.read_text())
except Exception: except Exception as e:
print(f"warning: bad config {p}: {e}")
return {"hostname": "movil", "services": []} return {"hostname": "movil", "services": []}
def run(config_path: str | None = None, def _serve(zc: Zeroconf, infos: list[ServiceInfo], verbose: bool, label: str) -> None:
hostname: str | None = None, for i in infos:
verbose: bool = True) -> None: try: # tolerate name conflicts per RFC 6762 (probe + rename)
"""Publish hostname + services and block forever.""" zc.register_service(i)
cfg = _load_config(config_path) except NonUniqueNameException:
host = hostname or cfg.get("hostname", "movil") zc.register_service(i, allow_name_change=True)
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}")
if verbose: 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: try:
while True: while True:
time.sleep(60) time.sleep(60)
@ -95,3 +60,37 @@ def run(config_path: str | None = None,
zc.close() zc.close()
if verbose: if verbose:
print("[mdns] stopped, goodbye packets sent.") 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}")

72
lib/mdns_tools/watch.py Normal file
View File

@ -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()