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:
parent
0b6642ec84
commit
96dcd177ec
95
README.md
95
README.md
|
|
@ -12,13 +12,23 @@ Works on any network: LAN via multicast, off-LAN via Tailscale MagicDNS.
|
||||||
|
|
||||||
## What's in here
|
## 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 |
|
| Script | Role |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `mdns-resolve` | Resolve `<host>.local` via raw multicast UDP (pure Python, stdlib only). |
|
| `mdns` | Unified CLI (zeroconf-backed). Preferred entry point. |
|
||||||
| `mdns-publish.py` | Publish this device on the LAN as `movil.local` using `zeroconf`. |
|
| `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-mdns-proxy` | SSH `ProxyCommand`: mDNS → Tailscale DNS → cached IP. |
|
||||||
| `ssh-fallback` | SSH `ProxyCommand`: Tailscale → LAN, for flaky Tailscale. |
|
| `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
|
## Install
|
||||||
|
|
||||||
|
|
@ -33,7 +43,29 @@ Then follow the printed reminders (append `bashrc.snippet`, set your
|
||||||
|
|
||||||
## Usage
|
## 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
|
```bash
|
||||||
resolve lenovo # -> 100.69.236.16
|
resolve lenovo # -> 100.69.236.16
|
||||||
|
|
@ -45,7 +77,8 @@ kubectl --server="https://$(resolve master):6443" get nodes
|
||||||
Environment knobs:
|
Environment knobs:
|
||||||
|
|
||||||
- `TAILSCALE_DOMAIN` — defaults to `tailb0bb74.ts.net`. Set to your own tailnet.
|
- `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`)
|
### Shell helpers (from `config/bashrc.snippet`)
|
||||||
|
|
||||||
|
|
@ -54,6 +87,10 @@ alias r='resolve'
|
||||||
pingr <host> # ping -c 3 to resolved IP
|
pingr <host> # ping -c 3 to resolved IP
|
||||||
curlr <host> [flags] # curl http://<resolved-ip>
|
curlr <host> [flags] # curl http://<resolved-ip>
|
||||||
sshr <host> [cmd...] # ssh via resolved IP with correct HostKeyAlias
|
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`)
|
### SSH by hostname (via `ProxyCommand`)
|
||||||
|
|
@ -65,20 +102,54 @@ ssh lenovo # resolves via mDNS then Tailscale, connects
|
||||||
ssh lenovo.local # forces mDNS-first path
|
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
|
```bash
|
||||||
python ~/.local/bin/mdns-publish.py # foreground
|
mdns publish # foreground
|
||||||
nohup python ~/.local/bin/mdns-publish.py >>~/.cache/mdns.log 2>&1 & disown
|
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
|
## Behavior by network
|
||||||
|
|
||||||
| Scenario | Path | Typical latency |
|
| Scenario | Path | Typical latency |
|
||||||
|--------------------|--------------------------------------------|-----------------|
|
|--------------------|------------------------------------------------------|-----------------|
|
||||||
| On home LAN | mDNS multicast (1 s cap) → else Tailscale | < 100 ms |
|
| On home LAN | stdlib multicast → zeroconf (IPv6 or miss) | < 1 s (IPv4) |
|
||||||
| Mobile data / away | mDNS fails → Tailscale MagicDNS | < 200 ms |
|
| Mobile data / away | multicast times out → Tailscale MagicDNS | < 2 s |
|
||||||
| No Tailscale | Both fail → cached last-known IP | instant |
|
| 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
|
## Gotchas found the hard way
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -1,73 +1,23 @@
|
||||||
#!/data/data/com.termux/files/usr/bin/python
|
#!/data/data/com.termux/files/usr/bin/python3
|
||||||
"""Publica servicios via mDNS/Zeroconf: SSH + Samba"""
|
"""mdns-publish.py: thin compat shim around `mdns publish`.
|
||||||
import socket
|
|
||||||
import signal
|
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
|
import sys
|
||||||
from zeroconf import ServiceInfo, Zeroconf, IPVersion
|
from pathlib import Path
|
||||||
|
|
||||||
HOSTNAME = 'movil'
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
|
||||||
|
from mdns_tools.publish import run # noqa: E402
|
||||||
SERVICES = [
|
|
||||||
{
|
|
||||||
'type': '_ssh._tcp.local.',
|
|
||||||
'port': 8022,
|
|
||||||
'props': {'description': 'Termux SSH'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type': '_smb._tcp.local.',
|
|
||||||
'port': 4450,
|
|
||||||
'props': {'description': 'Termux Samba', 'path': '/'},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_local_ip():
|
if __name__ == "__main__":
|
||||||
try:
|
cfg = os.environ.get("MDNS_SERVICES_CONFIG")
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
if not cfg:
|
||||||
s.connect(('8.8.8.8', 80))
|
for candidate in (Path.home() / ".config" / "mdns" / "services.yaml",
|
||||||
ip = s.getsockname()[0]
|
Path.home() / ".config" / "mdns" / "services.json"):
|
||||||
s.close()
|
if candidate.exists():
|
||||||
return ip
|
cfg = str(candidate)
|
||||||
except Exception:
|
break
|
||||||
return '127.0.0.1'
|
run(config_path=cfg, verbose=True)
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
|
||||||
118
bin/mdns-resolve
118
bin/mdns-resolve
|
|
@ -1,103 +1,33 @@
|
||||||
#!/usr/bin/env python3
|
#!/data/data/com.termux/files/usr/bin/python3
|
||||||
"""
|
"""mdns-resolve: stdlib-only fallback resolver for <host>.local.
|
||||||
Resolve hostname.local via mDNS multicast (pure Python, no dependencies).
|
|
||||||
Usage: mdns-resolve <hostname.local> [timeout_seconds]
|
Standalone: does NOT require zeroconf. Prefer `mdns resolve` when installed.
|
||||||
Returns: IP address on stdout, or exit 1 on failure.
|
|
||||||
|
Usage: mdns-resolve <hostname>[.local] [timeout_seconds] [--v6]
|
||||||
|
Exits 0 with IP on stdout on success; exits 1 on failure.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import socket
|
from pathlib import Path
|
||||||
import struct
|
|
||||||
import select
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
|
||||||
import time
|
from mdns_tools.resolve import resolve # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def build_mdns_query(hostname: str) -> bytes:
|
def main() -> int:
|
||||||
header = struct.pack('>HHHHHH', 0, 0, 1, 0, 0, 0)
|
argv = list(sys.argv[1:])
|
||||||
question = b''
|
if not argv:
|
||||||
for part in hostname.rstrip('.').split('.'):
|
print("Usage: mdns-resolve <hostname>[.local] [timeout] [--v6]", file=sys.stderr)
|
||||||
question += bytes([len(part)]) + part.encode()
|
return 1
|
||||||
question += b'\x00'
|
family = "v6" if "--v6" in argv else "v4"
|
||||||
question += struct.pack('>HH', 1, 1) # Type A, Class IN
|
argv = [a for a in argv if a != "--v6"]
|
||||||
return header + question
|
host = argv[0]
|
||||||
|
timeout = float(argv[1]) if len(argv) > 1 else 2.0
|
||||||
|
ip = resolve(host, family=family, timeout=timeout, retries=2)
|
||||||
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)
|
|
||||||
if ip:
|
if ip:
|
||||||
print(ip)
|
print(ip)
|
||||||
else:
|
return 0
|
||||||
sys.exit(1)
|
return 1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
sys.exit(main())
|
||||||
|
|
|
||||||
32
bin/resolve
32
bin/resolve
|
|
@ -1,16 +1,15 @@
|
||||||
#!/data/data/com.termux/files/usr/bin/bash
|
#!/data/data/com.termux/files/usr/bin/bash
|
||||||
# resolve: hostname -> IP con fallback mDNS (LAN) -> Tailscale DNS -> cache
|
# resolve: hostname -> IP
|
||||||
# Uso: resolve <hostname>[.local|.tailnet]
|
# Order: mdns (zeroconf) -> mdns-resolve (stdlib) -> Tailscale DNS -> local cache
|
||||||
# Ej: resolve lenovo # -> 100.69.236.16
|
# Use in any command:
|
||||||
# ping $(resolve hp62a)
|
# ping $(resolve lenovo) / curl "http://$(resolve hp62a)"
|
||||||
# curl "http://$(resolve dell):8080"
|
|
||||||
set -e
|
set -e
|
||||||
HOST="${1:-}"
|
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"
|
CACHE="$HOME/.ssh/resolve-cache"
|
||||||
TAILSCALE_DOMAIN="${TAILSCALE_DOMAIN:-tailb0bb74.ts.net}"
|
TAILSCALE_DOMAIN="${TAILSCALE_DOMAIN:-tailb0bb74.ts.net}"
|
||||||
MDNS_TIMEOUT="${MDNS_TIMEOUT:-1}"
|
MDNS_TIMEOUT="${MDNS_TIMEOUT:-2}"
|
||||||
|
|
||||||
QUERY="${HOST%.local}"
|
QUERY="${HOST%.local}"
|
||||||
QUERY="${QUERY%.${TAILSCALE_DOMAIN}}"
|
QUERY="${QUERY%.${TAILSCALE_DOMAIN}}"
|
||||||
|
|
@ -21,21 +20,30 @@ declare -A ALIASES=(
|
||||||
)
|
)
|
||||||
[[ -n "${ALIASES[$QUERY]:-}" ]] && QUERY="${ALIASES[$QUERY]}"
|
[[ -n "${ALIASES[$QUERY]:-}" ]] && QUERY="${ALIASES[$QUERY]}"
|
||||||
|
|
||||||
# 1. mDNS multicast (LAN)
|
IP=""
|
||||||
IP=$("$HOME/.local/bin/mdns-resolve" "${QUERY}.local" "$MDNS_TIMEOUT" 2>/dev/null || true)
|
|
||||||
|
|
||||||
# 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
|
if [[ -z "$IP" ]]; then
|
||||||
IP=$(python -c "import socket; print(socket.gethostbyname('${QUERY}.${TAILSCALE_DOMAIN}'))" 2>/dev/null || true)
|
IP=$(python -c "import socket; print(socket.gethostbyname('${QUERY}.${TAILSCALE_DOMAIN}'))" 2>/dev/null || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Cache
|
# 4. Legacy flat cache (last resort)
|
||||||
if [[ -z "$IP" && -f "$CACHE" ]]; then
|
if [[ -z "$IP" && -f "$CACHE" ]]; then
|
||||||
IP=$(grep "^${QUERY} " "$CACHE" 2>/dev/null | awk '{print $2}')
|
IP=$(grep "^${QUERY} " "$CACHE" 2>/dev/null | awk '{print $2}')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$IP" ]]; then
|
if [[ -z "$IP" ]]; then
|
||||||
echo "Error: no se resuelve '${HOST}'" >&2
|
echo "Error: could not resolve '${HOST}'" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,25 @@ export PATH="$HOME/.local/bin:$PATH"
|
||||||
# Your Tailscale tailnet (change to yours). Used as fallback for resolve/sshr.
|
# Your Tailscale tailnet (change to yours). Used as fallback for resolve/sshr.
|
||||||
export TAILSCALE_DOMAIN="tailXXXXXX.ts.net"
|
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-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-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"'
|
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)"
|
# Works in any command: ping $(resolve lenovo) / curl "http://$(resolve hp62a)"
|
||||||
alias r='resolve'
|
alias r='resolve'
|
||||||
pingr() { ping -c 3 "$(resolve "$1")"; }
|
pingr() { ping -c 3 "$(resolve "$1")"; }
|
||||||
curlr() { local h="$1"; shift; curl "$@" "http://$(resolve "$h")"; }
|
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
|
# HostKeyAlias reuses the known_hosts entry for the Tailscale FQDN so SSH-by-IP
|
||||||
# does not fail host-key verification.
|
# does not fail host-key verification.
|
||||||
sshr() {
|
sshr() {
|
||||||
|
|
|
||||||
|
|
@ -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"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -14,12 +14,18 @@ Host *.local
|
||||||
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
|
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
|
||||||
|
|
||||||
# === Short hostnames -> mDNS first, then Tailscale ===
|
# === 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>
|
User <your-user>
|
||||||
|
HostKeyAlias <host-1-canonical>
|
||||||
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
|
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
|
||||||
|
|
||||||
Host <host-2>
|
Host <host-2-canonical> <host-2-alias>
|
||||||
User <your-user>
|
User <your-user>
|
||||||
|
HostKeyAlias <host-2-canonical>
|
||||||
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
|
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
|
||||||
|
|
||||||
# === ProxyJump (hosts without Tailscale, via jump host) ===
|
# === ProxyJump (hosts without Tailscale, via jump host) ===
|
||||||
|
|
|
||||||
35
install.sh
35
install.sh
|
|
@ -32,16 +32,26 @@ step 2 "Installing required packages (pkg + pip)..."
|
||||||
run "pkg install -y python proot"
|
run "pkg install -y python proot"
|
||||||
run "pip install --upgrade zeroconf"
|
run "pip install --upgrade zeroconf"
|
||||||
|
|
||||||
# 3. Copy scripts
|
# 3. Symlink CLI scripts (bin/) and library (lib/) — symlinks keep git-pull updates live
|
||||||
step 3 "Installing scripts to $BIN_DIR..."
|
step 3 "Linking scripts into $BIN_DIR..."
|
||||||
run "mkdir -p '$BIN_DIR'"
|
run "mkdir -p '$BIN_DIR'"
|
||||||
for script in mdns-resolve mdns-publish.py ssh-mdns-proxy ssh-fallback resolve; do
|
for script in mdns mdns-resolve mdns-publish.py ssh-mdns-proxy ssh-fallback resolve; do
|
||||||
run "cp '$SCRIPT_DIR/bin/$script' '$BIN_DIR/$script'"
|
run "ln -sf '$SCRIPT_DIR/bin/$script' '$BIN_DIR/$script'"
|
||||||
run "chmod +x '$BIN_DIR/$script'"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# 4. Reminders
|
# 4. Sample services config
|
||||||
step 4 "Manual steps remaining:"
|
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
|
cat <<EOF
|
||||||
|
|
||||||
a) Append aliases to ~/.bashrc:
|
a) Append aliases to ~/.bashrc:
|
||||||
|
|
@ -52,9 +62,14 @@ cat <<EOF
|
||||||
cat $SCRIPT_DIR/config/ssh_config.example >> ~/.ssh/config
|
cat $SCRIPT_DIR/config/ssh_config.example >> ~/.ssh/config
|
||||||
Adjust User and hostnames.
|
Adjust User and hostnames.
|
||||||
|
|
||||||
c) Test:
|
c) Try it:
|
||||||
resolve <hostname>
|
mdns browse --list-types # discover service types on the LAN
|
||||||
pingr <hostname>
|
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
|
EOF
|
||||||
echo "Done."
|
echo "Done."
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""mDNS/DNS-SD tools for Termux (client + publisher)."""
|
||||||
|
__version__ = "0.2.0"
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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.")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue