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 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
|
||||||
|
|
|
||||||
15
bin/mdns
15
bin/mdns
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
try:
|
||||||
import yaml # type: ignore
|
return json.loads(p.read_text())
|
||||||
return yaml.safe_load(text) or {}
|
except Exception as e:
|
||||||
except ImportError:
|
print(f"warning: bad config {p}: {e}")
|
||||||
print("warning: PyYAML not installed, using minimal parser")
|
|
||||||
import json
|
|
||||||
try:
|
|
||||||
return json.loads(text)
|
|
||||||
except Exception:
|
|
||||||
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}")
|
||||||
|
|
|
||||||
|
|
@ -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