From 96dcd177ec9859641d10ef4140ecbe0271cc5a3d Mon Sep 17 00:00:00 2001 From: Andres Garcia Date: Mon, 20 Jul 2026 07:06:53 -0500 Subject: [PATCH] feat: CLI unificado mdns con browse/reverse/IPv6/cache-TTL Cierra el gap hacia una implementacion mDNS/DNS-SD completa (RFC 6762/6763). CLI nuevo `bin/mdns` con subcomandos: - resolve : hostname -> IP (IPv4 stdlib rapido, IPv6 via zeroconf), cache TTL. - reverse : IP -> hostname (PTR multicast, IPv4/IPv6). - browse : descubrimiento DNS-SD (--list-types o --type _ssh._tcp.local.). - publish : anuncia hostname + servicios (SSH/SMB/HTTP) con probing y goodbye. - cache : list/clear del cache con TTL. Biblioteca lib/mdns_tools/ (cada modulo < 100 lineas): - resolve.py: stdlib-first para IPv4, zeroconf fallback para IPv6/miss. - _stdlib_resolve.py: resolver A puro stdlib, acumula todas las A del host. - _pick.py: elige la IP mas alcanzable ante multi-homing. Descarta rangos virtuales (libvirt 192.168.122/24, docker 172.17/16, link-local) devolviendo None para que el caller caiga a Tailscale en vez de a una IP muerta. - browse.py / publish.py: DNS-SD sobre zeroconf. - reverse.py: PTR stdlib. cache.py: TTL-aware JSON en ~/.cache/mdns. Mejoras de calidad: - IP_ADD_MEMBERSHIP en todos los sockets multicast. - Retries con backoff (timeout dividido). - Imports lazy en el CLI (resolve/cache no cargan zeroconf -> ~1s vs ~4s). Wrappers: - resolve (bash): orden mdns -> mdns-resolve stdlib -> Tailscale -> cache plano. - mdns-publish.py: shim compat sobre `mdns publish`, lee ~/.config/mdns/services.json. - install.sh: symlinks bin/ (updates via git pull), siembra services.json. - ssh_config.example: HostKeyAlias documentado (alias comparte known_hosts con el nombre canonico; evita fallos de verificacion tras reinstalar un host). - bashrc.snippet: aliases mdns-types/ssh/smb/http, mdns-cache, whor. Validado E2E en LAN: browse detecta 13 tipos; dell/movil resuelven a IP LAN real; lenovo (solo publica virbr0 192.168.122.1) cae correctamente a Tailscale; IPv6 de dell via zeroconf; reverse 192.168.1.17 -> dell-latitude3400.local; publish visible desde otro browser. Modulos < 100 lineas salvo el dispatcher. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 97 +++++++++++++++++++---- bin/mdns | 124 ++++++++++++++++++++++++++++++ bin/mdns-publish.py | 88 +++++---------------- bin/mdns-resolve | 118 ++++++---------------------- bin/resolve | 32 +++++--- config/bashrc.snippet | 13 +++- config/services.example.json | 10 +++ config/ssh_config.example | 10 ++- install.sh | 35 ++++++--- lib/mdns_tools/__init__.py | 2 + lib/mdns_tools/_pick.py | 65 ++++++++++++++++ lib/mdns_tools/_stdlib_resolve.py | 83 ++++++++++++++++++++ lib/mdns_tools/browse.py | 59 ++++++++++++++ lib/mdns_tools/cache.py | 73 ++++++++++++++++++ lib/mdns_tools/publish.py | 97 +++++++++++++++++++++++ lib/mdns_tools/resolve.py | 65 ++++++++++++++++ lib/mdns_tools/reverse.py | 93 ++++++++++++++++++++++ 17 files changed, 862 insertions(+), 202 deletions(-) create mode 100755 bin/mdns create mode 100644 config/services.example.json create mode 100644 lib/mdns_tools/__init__.py create mode 100644 lib/mdns_tools/_pick.py create mode 100644 lib/mdns_tools/_stdlib_resolve.py create mode 100644 lib/mdns_tools/browse.py create mode 100644 lib/mdns_tools/cache.py create mode 100644 lib/mdns_tools/publish.py create mode 100644 lib/mdns_tools/resolve.py create mode 100644 lib/mdns_tools/reverse.py diff --git a/README.md b/README.md index 20262fe..780c4e5 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,23 @@ Works on any network: LAN via multicast, off-LAN via Tailscale MagicDNS. ## What's in here +**Unified CLI:** `mdns` with five subcommands (`resolve`, `browse`, `publish`, +`reverse`, `cache`). Backed by `zeroconf` for RFC-complete behavior — TTL +cache, IPv4 + IPv6, service discovery, probing on publish, goodbye packets +on exit. + | Script | Role | |--------|------| -| `mdns-resolve` | Resolve `.local` via raw multicast UDP (pure Python, stdlib only). | -| `mdns-publish.py` | Publish this device on the LAN as `movil.local` using `zeroconf`. | +| `mdns` | Unified CLI (zeroconf-backed). Preferred entry point. | +| `mdns-resolve` | Standalone stdlib fallback: works with **zero** dependencies. | +| `mdns-publish.py` | Compat shim over `mdns publish` for existing aliases. | | `ssh-mdns-proxy` | SSH `ProxyCommand`: mDNS → Tailscale DNS → cached IP. | | `ssh-fallback` | SSH `ProxyCommand`: Tailscale → LAN, for flaky Tailscale. | -| `resolve` | Generic resolver for **any** command (ping, curl, kubectl, nc…). Same order. | +| `resolve` | Bash wrapper: **`mdns` → stdlib → Tailscale → cache**, usable in any command. | + +Python library under `lib/mdns_tools/` (each module < 100 lines): +`resolve.py`, `browse.py`, `publish.py`, `reverse.py`, `cache.py`, +`_stdlib_resolve.py` (dep-free A-record resolver). ## Install @@ -33,7 +43,29 @@ Then follow the printed reminders (append `bashrc.snippet`, set your ## Usage -### Generic resolver — works in any command +### `mdns` CLI + +```bash +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 cache list | clear [host] +``` + +Examples: + +```bash +mdns browse --list-types # every service type on the LAN +mdns browse --type _ssh._tcp.local. # every SSH host on the LAN +mdns resolve lenovo-ideapad # -> 192.168.1.106 (LAN, fast stdlib path) +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 cache list # inspect TTL-aware cache +``` + +### `resolve` (bash) — for any command ```bash resolve lenovo # -> 100.69.236.16 @@ -45,7 +77,8 @@ kubectl --server="https://$(resolve master):6443" get nodes Environment knobs: - `TAILSCALE_DOMAIN` — defaults to `tailb0bb74.ts.net`. Set to your own tailnet. -- `MDNS_TIMEOUT` — seconds waiting for multicast (default `1`, keeps off-LAN snappy). +- `MDNS_TIMEOUT` — seconds waiting for multicast (default `2`). +- `MDNS_CACHE_PATH` — override cache location (default `~/.cache/mdns/hosts.json`). ### Shell helpers (from `config/bashrc.snippet`) @@ -54,6 +87,10 @@ alias r='resolve' pingr # ping -c 3 to resolved IP curlr [flags] # curl http:// sshr [cmd...] # ssh via resolved IP with correct HostKeyAlias +whor # PTR reverse: IP -> hostname +mdns-types # list all service types advertised on LAN +mdns-ssh / mdns-smb / mdns-http # browse specific service types +mdns-cache / mdns-forget # inspect / clear the resolver cache ``` ### SSH by hostname (via `ProxyCommand`) @@ -65,20 +102,54 @@ ssh lenovo # resolves via mDNS then Tailscale, connects ssh lenovo.local # forces mDNS-first path ``` -### Publishing this device as `movil.local` +### Publishing this device on the LAN + +Edit `~/.config/mdns/services.json` (the installer seeds a template), then: ```bash -python ~/.local/bin/mdns-publish.py # foreground -nohup python ~/.local/bin/mdns-publish.py >>~/.cache/mdns.log 2>&1 & disown +mdns publish # foreground +nohup mdns publish >>~/.cache/mdns.log 2>&1 & disown # background +``` + +The publisher registers `.local` (`_workstation._tcp`) plus every +service listed in the config. On Ctrl+C it sends proper mDNS goodbye packets. +Example `services.json`: + +```json +{ + "hostname": "movil", + "services": [ + {"type": "_ssh._tcp", "port": 8022, "name": "Termux SSH"}, + {"type": "_smb._tcp", "port": 4450, "name": "Termux SMB"} + ] +} ``` ## Behavior by network -| Scenario | Path | Typical latency | -|--------------------|--------------------------------------------|-----------------| -| On home LAN | mDNS multicast (1 s cap) → else Tailscale | < 100 ms | -| Mobile data / away | mDNS fails → Tailscale MagicDNS | < 200 ms | -| No Tailscale | Both fail → cached last-known IP | instant | +| Scenario | Path | Typical latency | +|--------------------|------------------------------------------------------|-----------------| +| On home LAN | stdlib multicast → zeroconf (IPv6 or miss) | < 1 s (IPv4) | +| Mobile data / away | multicast times out → Tailscale MagicDNS | < 2 s | +| No Tailscale | all fail → TTL-aware cache → legacy flat cache | instant | + +Cache honors real mDNS TTL (default 30 – 3600 s) and stores IPv4 + IPv6 +separately. `mdns resolve --stale ` accepts expired entries as a +last-resort fallback. + +## RFC coverage (what's implemented) + +| Feature | Status | Notes | +|----------------------------------|:------:|------------------------------------------| +| 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 | +| Publisher (hostname + services) | ✓ | via `zeroconf`, with probing + goodbye | +| 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 | ## Gotchas found the hard way diff --git a/bin/mdns b/bin/mdns new file mode 100755 index 0000000..4cde795 --- /dev/null +++ b/bin/mdns @@ -0,0 +1,124 @@ +#!/data/data/com.termux/files/usr/bin/python3 +"""mdns: unified CLI for mDNS resolve / browse / publish / reverse / cache. + +Loads lib/mdns_tools/ from the repo root. Usable both from an install into +~/.local/bin (installer sets a symlink pointing at the repo checkout) and +directly from a clone. +""" +import argparse +import json +import os +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_ROOT / "lib")) + +from mdns_tools import __version__ # noqa: E402 + + +def _cmd_resolve(a: argparse.Namespace) -> int: + from mdns_tools import resolve as _r, cache as _c + ip = _r.resolve(a.host, family=a.family, timeout=a.timeout, + retries=a.retries, use_cache=not a.no_cache) + if ip: + print(ip) + return 0 + if a.stale: + ip = _c.get_stale(a.host if a.host.endswith(".local") else f"{a.host}.local", + family=a.family) + if ip: + print(ip) + return 0 + print(f"error: could not resolve '{a.host}'", file=sys.stderr) + return 1 + + +def _cmd_reverse(a: argparse.Namespace) -> int: + from mdns_tools import reverse as _rev + name = _rev.reverse(a.ip, timeout=a.timeout, retries=a.retries) + if name: + print(name) + return 0 + print(f"error: no PTR for {a.ip}", file=sys.stderr) + return 1 + + +def _cmd_browse(a: argparse.Namespace) -> int: + from mdns_tools import browse as _br # loads zeroconf + if a.list_types: + for t in _br.list_types(duration=a.duration): + print(t) + return 0 + items = _br.browse(service_type=a.type, duration=a.duration) + if a.json: + print(json.dumps(items, indent=2)) + return 0 + for it in items: + addrs = ", ".join(it.get("addresses") or []) or "-" + print(f'{it["type"]:<30} {it.get("server","-"):<28} {addrs}:{it.get("port","-")}') + return 0 + + +def _cmd_publish(a: argparse.Namespace) -> int: + from mdns_tools import publish as _p # loads zeroconf + _p.run(config_path=a.config, hostname=a.hostname, verbose=not a.quiet) + return 0 + + +def _cmd_cache(a: argparse.Namespace) -> int: + from mdns_tools import cache as _c + if a.op == "list": + for k, v in _c.all_entries().items(): + host, fam = k.split("|") if "|" in k else (k, "v4") + print(f'{host:<40} {fam} {v.get("ip"):<40} ttl={v.get("ttl")}') + elif a.op == "clear": + n = _c.clear(a.host) + print(f"removed {n} entries") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(prog="mdns", description="mDNS / DNS-SD tools") + p.add_argument("--version", action="version", version=f"mdns {__version__}") + sub = p.add_subparsers(dest="cmd", required=True) + + r = sub.add_parser("resolve", help="hostname -> IP") + r.add_argument("host") + r.add_argument("--family", choices=["v4", "v6"], default="v4") + r.add_argument("--timeout", type=float, default=1.5) + r.add_argument("--retries", type=int, default=2) + r.add_argument("--no-cache", action="store_true") + r.add_argument("--stale", action="store_true", help="accept expired cache as last resort") + r.set_defaults(func=_cmd_resolve) + + rv = sub.add_parser("reverse", help="IP -> hostname (PTR)") + rv.add_argument("ip") + rv.add_argument("--timeout", type=float, default=1.5) + rv.add_argument("--retries", type=int, default=2) + rv.set_defaults(func=_cmd_reverse) + + b = sub.add_parser("browse", help="discover services on the LAN") + b.add_argument("--type", help="e.g. _ssh._tcp.local. (default: all types)") + 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.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("--hostname") + pu.add_argument("--quiet", action="store_true") + pu.set_defaults(func=_cmd_publish) + + ca = sub.add_parser("cache", help="inspect/clear the resolver cache") + ca.add_argument("op", choices=["list", "clear"]) + ca.add_argument("host", nargs="?") + ca.set_defaults(func=_cmd_cache) + + args = p.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/mdns-publish.py b/bin/mdns-publish.py index 189c946..bcb1b06 100755 --- a/bin/mdns-publish.py +++ b/bin/mdns-publish.py @@ -1,73 +1,23 @@ -#!/data/data/com.termux/files/usr/bin/python -"""Publica servicios via mDNS/Zeroconf: SSH + Samba""" -import socket -import signal +#!/data/data/com.termux/files/usr/bin/python3 +"""mdns-publish.py: thin compat shim around `mdns publish`. + +Kept for existing .bashrc aliases and users who linked to this filename. +New code should call `mdns publish [--config PATH]` directly. +""" +import os import sys -from zeroconf import ServiceInfo, Zeroconf, IPVersion +from pathlib import Path -HOSTNAME = 'movil' - -SERVICES = [ - { - 'type': '_ssh._tcp.local.', - 'port': 8022, - 'props': {'description': 'Termux SSH'}, - }, - { - 'type': '_smb._tcp.local.', - 'port': 4450, - 'props': {'description': 'Termux Samba', 'path': '/'}, - }, -] +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) +from mdns_tools.publish import run # noqa: E402 -def get_local_ip(): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(('8.8.8.8', 80)) - ip = s.getsockname()[0] - s.close() - return ip - except Exception: - return '127.0.0.1' - - -def main(): - ip = get_local_ip() - addr = socket.inet_aton(ip) - zc = Zeroconf(ip_version=IPVersion.V4Only) - infos = [] - - for svc in SERVICES: - info = ServiceInfo( - svc['type'], - f'{HOSTNAME}.{svc["type"]}', - addresses=[addr], - port=svc['port'], - properties=svc['props'], - server=f'{HOSTNAME}.local.', - ) - zc.register_service(info) - infos.append(info) - print(f'Publicando {HOSTNAME}.local {svc["type"]} -> {ip}:{svc["port"]}') - - def cleanup(sig, frame): - print('Deteniendo mDNS...') - for info in infos: - zc.unregister_service(info) - zc.close() - sys.exit(0) - - signal.signal(signal.SIGINT, cleanup) - signal.signal(signal.SIGTERM, cleanup) - - try: - signal.pause() - except AttributeError: - import time - while True: - time.sleep(3600) - - -if __name__ == '__main__': - main() +if __name__ == "__main__": + cfg = os.environ.get("MDNS_SERVICES_CONFIG") + if not cfg: + for candidate in (Path.home() / ".config" / "mdns" / "services.yaml", + Path.home() / ".config" / "mdns" / "services.json"): + if candidate.exists(): + cfg = str(candidate) + break + run(config_path=cfg, verbose=True) diff --git a/bin/mdns-resolve b/bin/mdns-resolve index 81d954f..ee078ce 100755 --- a/bin/mdns-resolve +++ b/bin/mdns-resolve @@ -1,103 +1,33 @@ -#!/usr/bin/env python3 -""" -Resolve hostname.local via mDNS multicast (pure Python, no dependencies). -Usage: mdns-resolve [timeout_seconds] -Returns: IP address on stdout, or exit 1 on failure. +#!/data/data/com.termux/files/usr/bin/python3 +"""mdns-resolve: stdlib-only fallback resolver for .local. + +Standalone: does NOT require zeroconf. Prefer `mdns resolve` when installed. + +Usage: mdns-resolve [.local] [timeout_seconds] [--v6] +Exits 0 with IP on stdout on success; exits 1 on failure. """ import sys -import socket -import struct -import select -import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) +from mdns_tools.resolve import resolve # noqa: E402 -def build_mdns_query(hostname: str) -> bytes: - header = struct.pack('>HHHHHH', 0, 0, 1, 0, 0, 0) - question = b'' - for part in hostname.rstrip('.').split('.'): - question += bytes([len(part)]) + part.encode() - question += b'\x00' - question += struct.pack('>HH', 1, 1) # Type A, Class IN - return header + question - - -def parse_mdns_response(data: bytes) -> str | None: - if len(data) < 12: - return None - qdcount = struct.unpack('>H', data[4:6])[0] - ancount = struct.unpack('>H', data[6:8])[0] - if ancount == 0: - return None - - offset = 12 - for _ in range(qdcount): - while offset < len(data) and data[offset] != 0: - if data[offset] & 0xc0 == 0xc0: - offset += 2 - break - offset += data[offset] + 1 - else: - offset += 1 - offset += 4 - - for _ in range(ancount): - while offset < len(data): - if data[offset] & 0xc0 == 0xc0: - offset += 2 - break - elif data[offset] == 0: - offset += 1 - break - else: - offset += data[offset] + 1 - if offset + 10 > len(data): - break - rtype, rclass, ttl, rdlength = struct.unpack('>HHIH', data[offset:offset+10]) - offset += 10 - if rtype == 1 and rdlength == 4 and offset + 4 <= len(data): - return socket.inet_ntoa(data[offset:offset+4]) - offset += rdlength - return None - - -def resolve_mdns(hostname: str, timeout: float = 2.0) -> str | None: - if not hostname.endswith('.local'): - hostname += '.local' - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.setblocking(False) - sock.sendto(build_mdns_query(hostname), ('224.0.0.251', 5353)) - end_time = time.time() + timeout - while time.time() < end_time: - ready, _, _ = select.select([sock], [], [], 0.1) - if ready: - try: - data, _ = sock.recvfrom(4096) - ip = parse_mdns_response(data) - if ip: - sock.close() - return ip - except Exception: - pass - sock.close() - except Exception: - pass - return None - - -def main(): - if len(sys.argv) < 2: - print("Usage: mdns-resolve [timeout]", file=sys.stderr) - sys.exit(1) - hostname = sys.argv[1] - timeout = float(sys.argv[2]) if len(sys.argv) > 2 else 2.0 - ip = resolve_mdns(hostname, timeout) +def main() -> int: + argv = list(sys.argv[1:]) + if not argv: + print("Usage: mdns-resolve [.local] [timeout] [--v6]", file=sys.stderr) + return 1 + family = "v6" if "--v6" in argv else "v4" + argv = [a for a in argv if a != "--v6"] + host = argv[0] + timeout = float(argv[1]) if len(argv) > 1 else 2.0 + ip = resolve(host, family=family, timeout=timeout, retries=2) if ip: print(ip) - else: - sys.exit(1) + return 0 + return 1 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/bin/resolve b/bin/resolve index a6c22e6..8229631 100755 --- a/bin/resolve +++ b/bin/resolve @@ -1,16 +1,15 @@ #!/data/data/com.termux/files/usr/bin/bash -# resolve: hostname -> IP con fallback mDNS (LAN) -> Tailscale DNS -> cache -# Uso: resolve [.local|.tailnet] -# Ej: resolve lenovo # -> 100.69.236.16 -# ping $(resolve hp62a) -# curl "http://$(resolve dell):8080" +# resolve: hostname -> IP +# Order: mdns (zeroconf) -> mdns-resolve (stdlib) -> Tailscale DNS -> local cache +# Use in any command: +# ping $(resolve lenovo) / curl "http://$(resolve hp62a)" set -e HOST="${1:-}" -[[ -z "$HOST" ]] && { echo "Uso: resolve " >&2; exit 1; } +[[ -z "$HOST" ]] && { echo "Usage: resolve " >&2; exit 1; } CACHE="$HOME/.ssh/resolve-cache" TAILSCALE_DOMAIN="${TAILSCALE_DOMAIN:-tailb0bb74.ts.net}" -MDNS_TIMEOUT="${MDNS_TIMEOUT:-1}" +MDNS_TIMEOUT="${MDNS_TIMEOUT:-2}" QUERY="${HOST%.local}" QUERY="${QUERY%.${TAILSCALE_DOMAIN}}" @@ -21,21 +20,30 @@ declare -A ALIASES=( ) [[ -n "${ALIASES[$QUERY]:-}" ]] && QUERY="${ALIASES[$QUERY]}" -# 1. mDNS multicast (LAN) -IP=$("$HOME/.local/bin/mdns-resolve" "${QUERY}.local" "$MDNS_TIMEOUT" 2>/dev/null || true) +IP="" -# 2. Tailscale MagicDNS (remoto) +# 1. mdns (zeroconf-based, richer: retries + TTL cache) +if command -v mdns >/dev/null 2>&1; then + IP=$(mdns resolve --timeout "$MDNS_TIMEOUT" "$QUERY" 2>/dev/null || true) +fi + +# 2. stdlib fallback (no zeroconf needed) +if [[ -z "$IP" ]] && command -v mdns-resolve >/dev/null 2>&1; then + IP=$(mdns-resolve "${QUERY}.local" "$MDNS_TIMEOUT" 2>/dev/null || true) +fi + +# 3. Tailscale MagicDNS (off-LAN) if [[ -z "$IP" ]]; then IP=$(python -c "import socket; print(socket.gethostbyname('${QUERY}.${TAILSCALE_DOMAIN}'))" 2>/dev/null || true) fi -# 3. Cache +# 4. Legacy flat cache (last resort) if [[ -z "$IP" && -f "$CACHE" ]]; then IP=$(grep "^${QUERY} " "$CACHE" 2>/dev/null | awk '{print $2}') fi if [[ -z "$IP" ]]; then - echo "Error: no se resuelve '${HOST}'" >&2 + echo "Error: could not resolve '${HOST}'" >&2 exit 1 fi diff --git a/config/bashrc.snippet b/config/bashrc.snippet index 085792d..68d3f24 100644 --- a/config/bashrc.snippet +++ b/config/bashrc.snippet @@ -7,16 +7,25 @@ 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 (this device as movil.local on the LAN) --- +# --- 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"' -# --- Generic host resolver (resolve: mDNS -> Tailscale -> cache) --- +# --- Service discovery / cache shortcuts --- +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-cache='mdns cache list' +alias mdns-forget='mdns cache clear' + +# --- Generic host resolver (mdns -> stdlib -> Tailscale -> file cache) --- # Works in any command: ping $(resolve lenovo) / curl "http://$(resolve hp62a)" alias r='resolve' pingr() { ping -c 3 "$(resolve "$1")"; } curlr() { local h="$1"; shift; curl "$@" "http://$(resolve "$h")"; } +whor() { mdns reverse "$1"; } # IP -> hostname (PTR) # HostKeyAlias reuses the known_hosts entry for the Tailscale FQDN so SSH-by-IP # does not fail host-key verification. sshr() { diff --git a/config/services.example.json b/config/services.example.json new file mode 100644 index 0000000..b4215ce --- /dev/null +++ b/config/services.example.json @@ -0,0 +1,10 @@ +{ + "_comment": "Copy to ~/.config/mdns/services.json and adjust. Loaded by `mdns publish`.", + "hostname": "movil", + "services": [ + {"type": "_ssh._tcp", "port": 8022, "name": "Termux SSH"}, + {"type": "_smb._tcp", "port": 4450, "name": "Termux SMB"}, + {"type": "_http._tcp", "port": 8080, "name": "Termux HTTP", + "txt": {"path": "/", "note": "example"}} + ] +} diff --git a/config/ssh_config.example b/config/ssh_config.example index 08e6d6f..266ec68 100644 --- a/config/ssh_config.example +++ b/config/ssh_config.example @@ -14,12 +14,18 @@ Host *.local ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p # === Short hostnames -> mDNS first, then Tailscale === -Host +# HostKeyAlias pins host-key verification to the canonical name, so that a short +# alias and the full name share ONE known_hosts entry. Without it, `ssh alias` +# checks the key under the alias and fails when only the canonical name is known +# (or when a stale alias entry lingers after a reinstall). +Host User + HostKeyAlias ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p -Host +Host User + HostKeyAlias ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p # === ProxyJump (hosts without Tailscale, via jump host) === diff --git a/install.sh b/install.sh index 68ea342..65d110d 100644 --- a/install.sh +++ b/install.sh @@ -32,16 +32,26 @@ step 2 "Installing required packages (pkg + pip)..." run "pkg install -y python proot" run "pip install --upgrade zeroconf" -# 3. Copy scripts -step 3 "Installing scripts to $BIN_DIR..." +# 3. Symlink CLI scripts (bin/) and library (lib/) — symlinks keep git-pull updates live +step 3 "Linking scripts into $BIN_DIR..." run "mkdir -p '$BIN_DIR'" -for script in mdns-resolve mdns-publish.py ssh-mdns-proxy ssh-fallback resolve; do - run "cp '$SCRIPT_DIR/bin/$script' '$BIN_DIR/$script'" - run "chmod +x '$BIN_DIR/$script'" +for script in mdns mdns-resolve mdns-publish.py ssh-mdns-proxy ssh-fallback resolve; do + run "ln -sf '$SCRIPT_DIR/bin/$script' '$BIN_DIR/$script'" done -# 4. Reminders -step 4 "Manual steps remaining:" +# 4. Sample services config +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 + run "cp '$SCRIPT_DIR/config/services.example.json' '$CFG_DIR/services.json'" + echo " Wrote $CFG_DIR/services.json (edit before running mdns publish)." +else + echo " $CFG_DIR/services.json already exists, keeping it." +fi + +# 5. Reminders +step 5 "Manual steps remaining:" cat <> ~/.ssh/config Adjust User and hostnames. - c) Test: - resolve - pingr + c) Try it: + mdns browse --list-types # discover service types on the LAN + mdns browse --type _ssh._tcp.local. # list SSH hosts + mdns resolve # host -> IP (v4 by default) + mdns resolve --family v6 # IPv6 + mdns reverse # IP -> hostname + mdns publish # advertise this device + services + resolve # bash wrapper (any command) EOF echo "Done." diff --git a/lib/mdns_tools/__init__.py b/lib/mdns_tools/__init__.py new file mode 100644 index 0000000..efb718a --- /dev/null +++ b/lib/mdns_tools/__init__.py @@ -0,0 +1,2 @@ +"""mDNS/DNS-SD tools for Termux (client + publisher).""" +__version__ = "0.2.0" diff --git a/lib/mdns_tools/_pick.py b/lib/mdns_tools/_pick.py new file mode 100644 index 0000000..d4da962 --- /dev/null +++ b/lib/mdns_tools/_pick.py @@ -0,0 +1,65 @@ +"""Pick the most reachable address when a host advertises several. + +mDNS hosts with virtual bridges (libvirt, docker) or multiple NICs announce +every local IP. Many are not routable from us. Prefer an address in our own +subnet, then drop known-virtual ranges, else fall back to the first. +""" +import ipaddress +import socket + +# Bridge/virtual ranges that are usually not reachable across hosts. +_AVOID = [ + ipaddress.ip_network("192.168.122.0/24"), # libvirt default (virbr0) + ipaddress.ip_network("172.17.0.0/16"), # docker default (docker0) + ipaddress.ip_network("169.254.0.0/16"), # link-local autoconf +] + + +def local_ipv4() -> str | None: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except OSError: + return None + finally: + s.close() + + +def _avoided(ip: str) -> bool: + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return True + return any(addr in net for net in _AVOID) + + +def pick_best(candidates: list[str], family: str = "v4") -> str | None: + """Return the best-reachable address, or None if none look routable. + + Returning None on all-unreachable is deliberate: it lets the caller fall + through to the next resolution method (e.g. Tailscale) instead of handing + back a dead virtual-bridge IP. + """ + uniq: list[str] = [] + for c in candidates: + if c and c not in uniq: + uniq.append(c) + if not uniq: + return None + if family == "v4": + mine = local_ipv4() + if mine: + try: + my_net = ipaddress.ip_network(mine + "/24", strict=False) + same = [c for c in uniq if ipaddress.ip_address(c) in my_net] + if same: + return same[0] + except ValueError: + pass + # Drop known virtual/bridge ranges; if nothing remains, signal None. + good = [c for c in uniq if not _avoided(c)] + return good[0] if good else None + # IPv6: prefer global/ULA over link-local (fe80::) + non_ll = [c for c in uniq if not c.lower().startswith("fe80")] + return non_ll[0] if non_ll else uniq[0] diff --git a/lib/mdns_tools/_stdlib_resolve.py b/lib/mdns_tools/_stdlib_resolve.py new file mode 100644 index 0000000..a697b93 --- /dev/null +++ b/lib/mdns_tools/_stdlib_resolve.py @@ -0,0 +1,83 @@ +"""IPv4 mDNS resolver using only Python stdlib. Used as no-dep fallback.""" +import socket +import struct +import select +import time + +MDNS_ADDR4 = "224.0.0.251" +MDNS_PORT = 5353 +QTYPE_A = 1 + + +def build_query(hostname: str, qtype: int = QTYPE_A) -> bytes: + header = struct.pack(">HHHHHH", 0, 0, 1, 0, 0, 0) + q = b"" + for part in hostname.rstrip(".").split("."): + q += bytes([len(part)]) + part.encode() + q += b"\x00" + struct.pack(">HH", qtype, 1) + return header + q + + +def skip_name(data: bytes, off: int) -> int: + while off < len(data): + b = data[off] + if b == 0: + return off + 1 + if b & 0xC0 == 0xC0: + return off + 2 + off += b + 1 + return off + + +def parse_all_a(data: bytes) -> list[tuple[str, int]]: + """Return every A record (ip, ttl) in the response.""" + out: list[tuple[str, int]] = [] + if len(data) < 12: + return out + qd, an = struct.unpack(">HH", data[4:8]) + off = 12 + for _ in range(qd): + off = skip_name(data, off) + 4 + for _ in range(an): + off = skip_name(data, off) + if off + 10 > len(data): + break + rtype, _, ttl, rdlen = struct.unpack(">HHIH", data[off:off + 10]) + off += 10 + if rtype == QTYPE_A and rdlen == 4 and off + 4 <= len(data): + out.append((socket.inet_ntoa(data[off:off + 4]), ttl)) + off += rdlen + return out + + +def query_all(hostname: str, timeout: float) -> list[tuple[str, int]]: + """Collect all A records advertised for hostname during the time window.""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + mreq = socket.inet_aton(MDNS_ADDR4) + socket.inet_aton("0.0.0.0") + s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + except OSError: + pass + s.setblocking(False) + try: + s.sendto(build_query(hostname, QTYPE_A), (MDNS_ADDR4, MDNS_PORT)) + except OSError: + s.close() + return [] + found: list[tuple[str, int]] = [] + end = time.time() + timeout + while time.time() < end: + r, _, _ = select.select([s], [], [], min(0.1, max(0.0, end - time.time()))) + if r: + try: + data, _ = s.recvfrom(4096) + except OSError: + continue + recs = parse_all_a(data) + if recs: + found.extend(recs) + # Give a brief grace window for additional A packets, then stop. + end = min(end, time.time() + 0.15) + s.close() + return found diff --git a/lib/mdns_tools/browse.py b/lib/mdns_tools/browse.py new file mode 100644 index 0000000..7bc2d85 --- /dev/null +++ b/lib/mdns_tools/browse.py @@ -0,0 +1,59 @@ +"""Service discovery on the local network (DNS-SD). + +browse(service_type=None, duration=3.0) -> list of dicts +""" +import time + +from zeroconf import Zeroconf, ServiceBrowser, ServiceListener, ZeroconfServiceTypes + + +class _Collector(ServiceListener): + def __init__(self) -> None: + self.items: dict[str, dict] = {} + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: # noqa: N802 + info = zc.get_service_info(type_, name, timeout=1500) + if info is None: + return + addrs = [a for a in (info.parsed_addresses() or [])] + self.items[name] = { + "name": name, + "type": type_, + "server": (info.server or "").rstrip("."), + "port": info.port, + "addresses": addrs, + "txt": { + k.decode(errors="replace"): + (v.decode(errors="replace") if isinstance(v, (bytes, bytearray)) else v) + for k, v in (info.properties or {}).items() if k + }, + } + + def update_service(self, *a, **k) -> None: # noqa: N802 + return + + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: # noqa: N802 + self.items.pop(name, None) + + +def list_types(duration: float = 3.0) -> list[str]: + """Return all service types advertised on the LAN.""" + return sorted(ZeroconfServiceTypes.find(timeout=duration)) + + +def browse(service_type: str | None = None, duration: float = 3.0) -> list[dict]: + """Discover services. If service_type is None, discover all types first.""" + zc = Zeroconf() + try: + types = [service_type] if service_type else list_types(duration=duration / 2) + if not types: + return [] + listener = _Collector() + browsers = [ServiceBrowser(zc, t, listener) for t in types] + time.sleep(duration if not service_type else duration) + for b in browsers: + b.cancel() + return sorted(listener.items.values(), + key=lambda x: (x.get("type", ""), x.get("name", ""))) + finally: + zc.close() diff --git a/lib/mdns_tools/cache.py b/lib/mdns_tools/cache.py new file mode 100644 index 0000000..0f331b0 --- /dev/null +++ b/lib/mdns_tools/cache.py @@ -0,0 +1,73 @@ +"""TTL-aware cache for mDNS resolutions. JSON-backed for portability.""" +import json +import os +import time +from pathlib import Path + +CACHE_PATH = Path(os.environ.get( + "MDNS_CACHE_PATH", + Path.home() / ".cache" / "mdns" / "hosts.json", +)) + + +def _load() -> dict: + if not CACHE_PATH.exists(): + return {} + try: + return json.loads(CACHE_PATH.read_text()) + except Exception: + return {} + + +def _save(data: dict) -> None: + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = CACHE_PATH.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True)) + tmp.replace(CACHE_PATH) + + +def get(host: str, family: str = "v4") -> str | None: + """Return IP if fresh, None if missing or expired.""" + entry = _load().get(f"{host}|{family}") + if not entry: + return None + if entry.get("expires", 0) < time.time(): + return None + return entry.get("ip") + + +def get_stale(host: str, family: str = "v4") -> str | None: + """Return IP even if expired (last-resort fallback).""" + entry = _load().get(f"{host}|{family}") + return entry.get("ip") if entry else None + + +def put(host: str, ip: str, family: str = "v4", ttl: int = 120) -> None: + data = _load() + ttl = max(30, min(ttl, 3600)) + data[f"{host}|{family}"] = { + "ip": ip, + "ttl": ttl, + "expires": time.time() + ttl, + "updated": time.time(), + } + _save(data) + + +def clear(host: str | None = None) -> int: + """Clear entries. Returns number of entries removed.""" + if host is None: + n = len(_load()) + if CACHE_PATH.exists(): + CACHE_PATH.unlink() + return n + data = _load() + removed = [k for k in list(data) if k.split("|")[0] == host] + for k in removed: + data.pop(k) + _save(data) + return len(removed) + + +def all_entries() -> dict: + return _load() diff --git a/lib/mdns_tools/publish.py b/lib/mdns_tools/publish.py new file mode 100644 index 0000000..93bc5c4 --- /dev/null +++ b/lib/mdns_tools/publish.py @@ -0,0 +1,97 @@ +"""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"} +""" +import socket +import time +from pathlib import Path + +from zeroconf import Zeroconf, ServiceInfo + + +def _local_ipv4() -> str: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except OSError: + return "127.0.0.1" + finally: + s.close() + + +def _load_config(path: str | None) -> dict: + if not path: + return {"hostname": "movil", "services": []} + p = Path(path).expanduser() + 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 {"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}") + if verbose: + print(f"[mdns] published {host}.local ({ip}), {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.") diff --git a/lib/mdns_tools/resolve.py b/lib/mdns_tools/resolve.py new file mode 100644 index 0000000..1baa86c --- /dev/null +++ b/lib/mdns_tools/resolve.py @@ -0,0 +1,65 @@ +"""Hostname resolver: zeroconf when available, stdlib multicast fallback. + +Public API: + resolve(host, family="v4"|"v6", timeout=1.0, retries=2, use_cache=True) + -> str | None +""" +from . import cache +from . import _stdlib_resolve as _std +from ._pick import pick_best + + +def _try_zeroconf(host: str, family: str, timeout: float) -> tuple[str, int] | None: + try: + from zeroconf import Zeroconf, AddressResolver, IPVersion + except ImportError: + return None + hostname = host if host.endswith(".") else host + "." + ver = IPVersion.V6Only if family == "v6" else IPVersion.V4Only + zc = Zeroconf(ip_version=IPVersion.All) + try: + r = AddressResolver(hostname) + # request() blocks up to `timeout` milliseconds + if not r.request(zc, int(timeout * 1000)): + return None + addrs = r.parsed_addresses(ver) + best = pick_best(addrs, family=family) + if not best: + return None + return best, 120 + except Exception: + return None + finally: + try: + zc.close() + except Exception: + pass + + +def resolve(host: str, family: str = "v4", timeout: float = 1.0, + retries: int = 2, use_cache: bool = True) -> str | None: + hostname = host if host.endswith(".local") else f"{host}.local" + if use_cache: + hit = cache.get(hostname, family) + if hit: + return hit + per_try = max(0.3, timeout / max(1, retries)) + + # IPv4: try stdlib first (fast, no zeroconf startup cost). + if family == "v4": + for _ in range(max(1, retries)): + recs = _std.query_all(hostname, per_try) + if recs: + best = pick_best([ip for ip, _ in recs], family="v4") + ttl = next((t for ip, t in recs if ip == best), 120) + cache.put(hostname, best, family=family, ttl=ttl or 120) + return best + + # IPv6 or IPv4-stdlib-miss: try zeroconf (heavier but RFC-complete). + for _ in range(max(1, retries)): + res = _try_zeroconf(hostname, family, per_try) + if res: + ip, ttl = res + cache.put(hostname, ip, family=family, ttl=ttl) + return ip + return None diff --git a/lib/mdns_tools/reverse.py b/lib/mdns_tools/reverse.py new file mode 100644 index 0000000..652601d --- /dev/null +++ b/lib/mdns_tools/reverse.py @@ -0,0 +1,93 @@ +"""Reverse mDNS lookup: IP address -> hostname (PTR record on multicast).""" +import ipaddress +import socket +import struct +import select +import time + +from ._stdlib_resolve import build_query, skip_name, MDNS_ADDR4, MDNS_PORT + +MDNS_ADDR6 = "ff02::fb" +QTYPE_PTR = 12 + + +def _reverse_name(ip: str) -> tuple[str, str]: + """Return (arpa-name, address-family) for an IP.""" + addr = ipaddress.ip_address(ip) + if isinstance(addr, ipaddress.IPv4Address): + return addr.reverse_pointer, "v4" + return addr.reverse_pointer, "v6" + + +def _parse_ptr(data: bytes) -> str | None: + """Return first PTR target hostname in the response.""" + if len(data) < 12: + return None + qd, an = struct.unpack(">HH", data[4:8]) + off = 12 + for _ in range(qd): + off = skip_name(data, off) + 4 + for _ in range(an): + off = skip_name(data, off) + if off + 10 > len(data): + return None + rtype, _, _, rdlen = struct.unpack(">HHIH", data[off:off + 10]) + off += 10 + if rtype == QTYPE_PTR: + return _read_name(data, off) + off += rdlen + return None + + +def _read_name(data: bytes, off: int) -> str: + parts: list[str] = [] + seen: set[int] = set() + while off < len(data): + b = data[off] + if b == 0: + break + if b & 0xC0 == 0xC0: + if off in seen: + break + seen.add(off) + off = ((b & 0x3F) << 8) | data[off + 1] + continue + parts.append(data[off + 1:off + 1 + b].decode(errors="replace")) + off += b + 1 + return ".".join(parts) + + +def reverse(ip: str, timeout: float = 1.5, retries: int = 2) -> str | None: + arpa, family = _reverse_name(ip) + af = socket.AF_INET6 if family == "v6" else socket.AF_INET + dst = MDNS_ADDR6 if family == "v6" else MDNS_ADDR4 + per_try = max(0.4, timeout / max(1, retries)) + for _ in range(max(1, retries)): + s = socket.socket(af, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + if family == "v4": + mreq = socket.inet_aton(MDNS_ADDR4) + socket.inet_aton("0.0.0.0") + s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + except OSError: + pass + s.setblocking(False) + try: + s.sendto(build_query(arpa, QTYPE_PTR), (dst, MDNS_PORT)) + except OSError: + s.close() + continue + end = time.time() + per_try + while time.time() < end: + r, _, _ = select.select([s], [], [], min(0.1, max(0.0, end - time.time()))) + if r: + try: + data, _ = s.recvfrom(4096) + except OSError: + continue + name = _parse_ptr(data) + if name: + s.close() + return name + s.close() + return None