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:
parent
96dcd177ec
commit
3ddcadd3f2
31
README.md
31
README.md
|
|
@ -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 reverse <ip> # 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 <name> <ip> # 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
|
||||
|
|
|
|||
15
bin/mdns
15
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue