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) <noreply@anthropic.com>
This commit is contained in:
Andres Garcia 2026-07-20 07:06:53 -05:00
parent 0b6642ec84
commit 96dcd177ec
17 changed files with 862 additions and 202 deletions

View File

@ -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 `<host>.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 <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 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 <host> # ping -c 3 to resolved IP
curlr <host> [flags] # curl http://<resolved-ip>
sshr <host> [cmd...] # ssh via resolved IP with correct HostKeyAlias
whor <ip> # 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 `<hostname>.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 |
|--------------------|------------------------------------------------------|-----------------|
| 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 <host>` 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

124
bin/mdns Executable file
View File

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

View File

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

View File

@ -1,103 +1,33 @@
#!/usr/bin/env python3
"""
Resolve hostname.local via mDNS multicast (pure Python, no dependencies).
Usage: mdns-resolve <hostname.local> [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 <host>.local.
Standalone: does NOT require zeroconf. Prefer `mdns resolve` when installed.
Usage: mdns-resolve <hostname>[.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 <hostname.local> [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 <hostname>[.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())

View File

@ -1,16 +1,15 @@
#!/data/data/com.termux/files/usr/bin/bash
# resolve: hostname -> IP con fallback mDNS (LAN) -> Tailscale DNS -> cache
# Uso: resolve <hostname>[.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 <hostname>" >&2; exit 1; }
[[ -z "$HOST" ]] && { echo "Usage: resolve <hostname>" >&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

View File

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

View File

@ -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"}}
]
}

View File

@ -14,12 +14,18 @@ Host *.local
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
# === Short hostnames -> mDNS first, then Tailscale ===
Host <host-1>
# 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 <host-1-canonical> <host-1-alias>
User <your-user>
HostKeyAlias <host-1-canonical>
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
Host <host-2>
Host <host-2-canonical> <host-2-alias>
User <your-user>
HostKeyAlias <host-2-canonical>
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
# === ProxyJump (hosts without Tailscale, via jump host) ===

View File

@ -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 <<EOF
a) Append aliases to ~/.bashrc:
@ -52,9 +62,14 @@ cat <<EOF
cat $SCRIPT_DIR/config/ssh_config.example >> ~/.ssh/config
Adjust User and hostnames.
c) Test:
resolve <hostname>
pingr <hostname>
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> # host -> IP (v4 by default)
mdns resolve <host> --family v6 # IPv6
mdns reverse <ip> # IP -> hostname
mdns publish # advertise this device + services
resolve <host> # bash wrapper (any command)
EOF
echo "Done."

View File

@ -0,0 +1,2 @@
"""mDNS/DNS-SD tools for Termux (client + publisher)."""
__version__ = "0.2.0"

65
lib/mdns_tools/_pick.py Normal file
View File

@ -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]

View File

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

59
lib/mdns_tools/browse.py Normal file
View File

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

73
lib/mdns_tools/cache.py Normal file
View File

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

97
lib/mdns_tools/publish.py Normal file
View File

@ -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.")

65
lib/mdns_tools/resolve.py Normal file
View File

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

93
lib/mdns_tools/reverse.py Normal file
View File

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