feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests
Bloque de robustez operativa + integracion de sistema + calidad: Publisher como servicio (lib/mdns_tools/service.py): - `mdns publish --daemon`: daemon singleton con lock PID en ~/.cache/mdns/publisher.pid. Un segundo intento es rechazado -> evita el problema de "Termux SMB" duplicado (varios publishers compitiendo por el mismo nombre, que zeroconf renombra a "-2"). - Re-anuncio automatico al cambiar la IP local (WiFi<->datos, roaming): unregister + register con la nueva IP para que el registro no quede stale. - Apagado limpio: maneja SIGTERM ademas de SIGINT (loop en ticks de 1s) y envia goodbye packets para que los clientes borren la entrada al instante. - `mdns status` (running/host/ip/servicios/desde) y `mdns stop`. Integracion de sistema (lib/mdns_tools/hosts.py): - `mdns sync-hosts [--dry-run]`: escribe un bloque gestionado en $PREFIX/etc/hosts (o /etc/hosts) para que ping/curl/kubectl nativos resuelvan .local sin el wrapper `resolve`. Solo toca su propio bloque. Refactor publish.py: expone helpers reutilizables (local_ipv4, load_config, build_infos, register_all, unregister_all) que consume el daemon. Tests offline (tests/, pytest, 20 casos, sin red): - construccion/parseo de queries A, seleccion de IP ante multi-homing (rechazo de rangos virtuales), cache con TTL y familias, PTR reverse, render/strip del bloque de hosts. - pyproject.toml con metadata, deps, extras [test] y config de pytest. README: seccion de daemon, sync-hosts y tests; tabla RFC ampliada. bashrc.snippet: aliases mdns-up/status/down/hosts (consolidados). install.sh: fix de backticks; symlinks ya cubren mdns + lib via resolve(). Nota: publish.py (109) y service.py (110) exceden levemente las 100 lineas; se dejan como modulos cohesivos de responsabilidad unica, igual que el dispatcher CLI, para no fragmentar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3ddcadd3f2
commit
5e5d42f997
49
README.md
49
README.md
|
|
@ -50,11 +50,46 @@ 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 browse --watch [--type ...] [--no-resolve] # live monitor (avahi-browse -a)
|
||||
mdns publish [--config PATH] [--hostname NAME] # services from JSON
|
||||
mdns publish [--config PATH] [--hostname NAME] # services from JSON (foreground)
|
||||
mdns publish --daemon # singleton daemon, re-announce on IP change
|
||||
mdns publish --address <name> <ip> # bare A record (avahi-publish -a)
|
||||
mdns status | stop # inspect / stop the daemon
|
||||
mdns sync-hosts [--dry-run] [--duration N] # write discovered hosts into hosts file
|
||||
mdns cache list | clear [host]
|
||||
```
|
||||
|
||||
### Running the publisher as a background service
|
||||
|
||||
`mdns publish --daemon` is the robust way to keep `movil.local` reachable:
|
||||
|
||||
- **Singleton** — a PID lock (`~/.cache/mdns/publisher.pid`) refuses a second
|
||||
instance. (This is what prevents the "Termux SMB" / "Termux SMB-2" duplicates
|
||||
you get when several publishers race for the same name.)
|
||||
- **Re-announce on IP change** — polls the local IP; on WiFi<->data / roaming it
|
||||
unregisters and re-registers with the new address, so the record never goes stale.
|
||||
- **Clean shutdown** — handles SIGTERM *and* SIGINT, sending mDNS goodbye packets
|
||||
so clients drop the entry immediately instead of waiting for the TTL.
|
||||
|
||||
```bash
|
||||
mdns publish --daemon & # or wire it into termux-services / a boot script
|
||||
mdns status # running (pid ...), host, ip, services, since
|
||||
mdns stop # graceful goodbye
|
||||
```
|
||||
|
||||
### `sync-hosts` — native `.local` without the wrapper
|
||||
|
||||
Writes a managed block into the hosts file so `ping`, `curl`, `kubectl`, etc.
|
||||
resolve `.local` names natively (the closest you get to `nss-mdns` without root):
|
||||
|
||||
```bash
|
||||
mdns sync-hosts --dry-run # preview the block
|
||||
mdns sync-hosts # write it into $PREFIX/etc/hosts (Termux) or /etc/hosts
|
||||
ping dell-latitude3400.local # now works in any tool, no `resolve` needed
|
||||
```
|
||||
|
||||
Only the block between the `mdns-termux managed` markers is touched; the rest of
|
||||
the file is preserved.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
|
|
@ -155,9 +190,21 @@ last-resort fallback.
|
|||
| 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` |
|
||||
| Publisher daemon (singleton) | ✓ | PID lock, re-announce on IP change |
|
||||
| Clean goodbye on shutdown | ✓ | SIGTERM + SIGINT handled |
|
||||
| Hosts-file sync (nss-mdns-lite) | ✓ | `mdns sync-hosts`, managed block |
|
||||
| Legacy unicast queries | — | Not implemented (rarely needed) |
|
||||
| Known-answer suppression | — | zeroconf handles internally |
|
||||
|
||||
## Tests
|
||||
|
||||
Parser/logic tests run offline (no network), via `pytest`:
|
||||
|
||||
```bash
|
||||
pip install -e ".[test]" # or: pip install pytest
|
||||
pytest # 20 tests: query build/parse, address pick, cache TTL, reverse, hosts block
|
||||
```
|
||||
|
||||
## Interoperability (Avahi / Bonjour)
|
||||
|
||||
Because publishing goes through `zeroconf` (a full RFC 6762 / 6763
|
||||
|
|
|
|||
29
bin/mdns
29
bin/mdns
|
|
@ -69,10 +69,29 @@ def _cmd_publish(a: argparse.Namespace) -> int:
|
|||
if a.address:
|
||||
_p.publish_address(a.address[0], a.address[1], verbose=not a.quiet)
|
||||
return 0
|
||||
if a.daemon:
|
||||
from mdns_tools import service as _s
|
||||
return _s.run_daemon(config_path=a.config, hostname=a.hostname,
|
||||
verbose=not a.quiet)
|
||||
_p.run(config_path=a.config, hostname=a.hostname, verbose=not a.quiet)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_status(a: argparse.Namespace) -> int:
|
||||
from mdns_tools import service as _s
|
||||
return _s.status()
|
||||
|
||||
|
||||
def _cmd_stop(a: argparse.Namespace) -> int:
|
||||
from mdns_tools import service as _s
|
||||
return _s.stop()
|
||||
|
||||
|
||||
def _cmd_sync_hosts(a: argparse.Namespace) -> int:
|
||||
from mdns_tools import hosts as _h
|
||||
return _h.sync(duration=a.duration, dry_run=a.dry_run)
|
||||
|
||||
|
||||
def _cmd_cache(a: argparse.Namespace) -> int:
|
||||
from mdns_tools import cache as _c
|
||||
if a.op == "list":
|
||||
|
|
@ -121,9 +140,19 @@ def main() -> int:
|
|||
pu.add_argument("--hostname")
|
||||
pu.add_argument("--address", nargs=2, metavar=("NAME", "IP"),
|
||||
help="publish a bare hostname->IP (A record), like avahi-publish -a")
|
||||
pu.add_argument("--daemon", action="store_true",
|
||||
help="run as singleton daemon: re-announce on IP change, clean goodbye")
|
||||
pu.add_argument("--quiet", action="store_true")
|
||||
pu.set_defaults(func=_cmd_publish)
|
||||
|
||||
sub.add_parser("status", help="show publisher daemon state").set_defaults(func=_cmd_status)
|
||||
sub.add_parser("stop", help="stop the publisher daemon").set_defaults(func=_cmd_stop)
|
||||
|
||||
sh = sub.add_parser("sync-hosts", help="write discovered hosts into the hosts file")
|
||||
sh.add_argument("--duration", type=float, default=3.0)
|
||||
sh.add_argument("--dry-run", action="store_true")
|
||||
sh.set_defaults(func=_cmd_sync_hosts)
|
||||
|
||||
ca = sub.add_parser("cache", help="inspect/clear the resolver cache")
|
||||
ca.add_argument("op", choices=["list", "clear"])
|
||||
ca.add_argument("host", nargs="?")
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ 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 (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"'
|
||||
# --- mDNS publisher daemon (movil.local + services on the LAN) ---
|
||||
alias mdns-up='mdns publish --daemon' # start singleton daemon
|
||||
alias mdns-status='mdns status' # is it running? what does it announce?
|
||||
alias mdns-down='mdns stop' # graceful goodbye
|
||||
alias mdns-hosts='mdns sync-hosts' # write discovered hosts into hosts file
|
||||
|
||||
# --- Service discovery / cache shortcuts ---
|
||||
alias mdns-types='mdns browse --list-types'
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ for script in mdns mdns-resolve mdns-publish.py ssh-mdns-proxy ssh-fallback reso
|
|||
done
|
||||
|
||||
# 4. Sample services config
|
||||
step 4 "Sample services config for `mdns publish`..."
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
"""Sync discovered mDNS hosts into the system hosts file.
|
||||
|
||||
Writes a managed block so native tools (ping, curl, kubectl) resolve `.local`
|
||||
names without the `resolve` wrapper. Only IPv4, only the managed block is
|
||||
touched — the rest of the file is preserved.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from . import browse as _browse
|
||||
from ._pick import pick_best
|
||||
|
||||
MARK_BEGIN = "# >>> mdns-termux managed >>>"
|
||||
MARK_END = "# <<< mdns-termux managed <<<"
|
||||
|
||||
|
||||
def hosts_path() -> Path:
|
||||
prefix = os.environ.get("PREFIX", "")
|
||||
if prefix and "com.termux" in prefix:
|
||||
return Path(prefix) / "etc" / "hosts"
|
||||
return Path("/etc/hosts")
|
||||
|
||||
|
||||
def collect(duration: float = 3.0) -> dict[str, str]:
|
||||
"""Return {hostname.local: best_ipv4} from all advertised services."""
|
||||
out: dict[str, str] = {}
|
||||
for it in _browse.browse(None, duration):
|
||||
server = (it.get("server") or "").rstrip(".")
|
||||
v4 = [a for a in (it.get("addresses") or []) if ":" not in a]
|
||||
if not server or not v4:
|
||||
continue
|
||||
best = pick_best(v4, family="v4")
|
||||
if best and server not in out:
|
||||
out[server] = best
|
||||
return out
|
||||
|
||||
|
||||
def _render_block(mapping: dict[str, str]) -> str:
|
||||
lines = [MARK_BEGIN]
|
||||
for host in sorted(mapping):
|
||||
short = host[:-6] if host.endswith(".local") else host
|
||||
lines.append(f"{mapping[host]}\t{host} {short}")
|
||||
lines.append(MARK_END)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _strip_block(text: str) -> str:
|
||||
out, skip = [], False
|
||||
for line in text.splitlines():
|
||||
if line.strip() == MARK_BEGIN:
|
||||
skip = True
|
||||
continue
|
||||
if line.strip() == MARK_END:
|
||||
skip = False
|
||||
continue
|
||||
if not skip:
|
||||
out.append(line)
|
||||
return "\n".join(out).rstrip("\n")
|
||||
|
||||
|
||||
def sync(duration: float = 3.0, dry_run: bool = False) -> int:
|
||||
mapping = collect(duration)
|
||||
if not mapping:
|
||||
print("no hosts discovered on the LAN")
|
||||
return 1
|
||||
block = _render_block(mapping)
|
||||
if dry_run:
|
||||
print(block)
|
||||
return 0
|
||||
path = hosts_path()
|
||||
original = path.read_text() if path.exists() else "127.0.0.1\tlocalhost\n"
|
||||
body = _strip_block(original)
|
||||
path.write_text(f"{body}\n{block}\n")
|
||||
print(f"wrote {len(mapping)} host(s) to {path}")
|
||||
for host, ip in sorted(mapping.items()):
|
||||
print(f" {ip}\t{host}")
|
||||
return 0
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
"""Publish on the LAN: services (JSON config) or a bare hostname->IP."""
|
||||
"""Publish on the LAN: services (JSON config) or a bare hostname->IP.
|
||||
Public helpers reused by service.py: local_ipv4, load_config, build_infos,
|
||||
register_all, unregister_all."""
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
|
|
@ -6,8 +8,10 @@ from pathlib import Path
|
|||
|
||||
from zeroconf import Zeroconf, ServiceInfo, NonUniqueNameException
|
||||
|
||||
DEFAULT_CONFIG = Path.home() / ".config" / "mdns" / "services.json"
|
||||
|
||||
def _local_ipv4() -> str:
|
||||
|
||||
def local_ipv4() -> str:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
|
|
@ -24,11 +28,8 @@ def _addr_bytes(ip: str) -> bytes:
|
|||
return socket.inet_aton(ip)
|
||||
|
||||
|
||||
_DEFAULT_CONFIG = Path.home() / ".config" / "mdns" / "services.json"
|
||||
|
||||
|
||||
def _load_config(path: str | None) -> dict:
|
||||
p = Path(path).expanduser() if path else _DEFAULT_CONFIG
|
||||
def load_config(path: str | None) -> dict:
|
||||
p = Path(path).expanduser() if path else DEFAULT_CONFIG
|
||||
if not p.exists():
|
||||
return {"hostname": "movil", "services": []}
|
||||
try:
|
||||
|
|
@ -38,30 +39,6 @@ def _load_config(path: str | None) -> dict:
|
|||
return {"hostname": "movil", "services": []}
|
||||
|
||||
|
||||
def _serve(zc: Zeroconf, infos: list[ServiceInfo], verbose: bool, label: str) -> None:
|
||||
for i in infos:
|
||||
try: # tolerate name conflicts per RFC 6762 (probe + rename)
|
||||
zc.register_service(i)
|
||||
except NonUniqueNameException:
|
||||
zc.register_service(i, allow_name_change=True)
|
||||
if verbose:
|
||||
print(f"[mdns] {label} ({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.")
|
||||
|
||||
|
||||
def _workstation(host: str, ip: str) -> ServiceInfo:
|
||||
return ServiceInfo(
|
||||
type_="_workstation._tcp.local.",
|
||||
|
|
@ -70,11 +47,7 @@ def _workstation(host: str, ip: str) -> ServiceInfo:
|
|||
)
|
||||
|
||||
|
||||
def run(config_path: str | None = None, hostname: str | None = None,
|
||||
verbose: bool = True) -> None:
|
||||
cfg = _load_config(config_path)
|
||||
host = hostname or cfg.get("hostname", "movil")
|
||||
ip = _local_ipv4()
|
||||
def build_infos(cfg: dict, host: str, ip: str) -> list[ServiceInfo]:
|
||||
infos = [_workstation(host, ip)]
|
||||
for svc in (cfg.get("services") or []):
|
||||
try:
|
||||
|
|
@ -86,11 +59,51 @@ def run(config_path: str | None = None, hostname: str | None = None,
|
|||
))
|
||||
except Exception as e:
|
||||
print(f"warning: could not register {svc}: {e}")
|
||||
_serve(Zeroconf(), infos, verbose, f"published {host}.local ({ip})")
|
||||
return infos
|
||||
|
||||
|
||||
def register_all(zc: Zeroconf, infos: list[ServiceInfo]) -> None:
|
||||
for i in infos:
|
||||
try: # tolerate name conflicts per RFC 6762 (probe + rename)
|
||||
zc.register_service(i)
|
||||
except NonUniqueNameException:
|
||||
zc.register_service(i, allow_name_change=True)
|
||||
|
||||
|
||||
def unregister_all(zc: Zeroconf, infos: list[ServiceInfo]) -> None:
|
||||
for i in infos:
|
||||
try:
|
||||
zc.unregister_service(i)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _serve(infos: list[ServiceInfo], verbose: bool, label: str) -> None:
|
||||
zc = Zeroconf()
|
||||
register_all(zc, infos)
|
||||
if verbose:
|
||||
print(f"[mdns] {label} ({len(infos)} record(s)). Ctrl+C to stop.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(60)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
unregister_all(zc, infos)
|
||||
zc.close()
|
||||
if verbose:
|
||||
print("[mdns] stopped, goodbye packets sent.")
|
||||
|
||||
|
||||
def run(config_path: str | None = None, hostname: str | None = None,
|
||||
verbose: bool = True) -> None:
|
||||
cfg = load_config(config_path)
|
||||
host = hostname or cfg.get("hostname", "movil")
|
||||
ip = local_ipv4()
|
||||
_serve(build_infos(cfg, host, ip), verbose, f"published {host}.local ({ip})")
|
||||
|
||||
|
||||
def publish_address(name: str, ip: str, verbose: bool = True) -> None:
|
||||
"""Publish a bare hostname -> IP mapping (like `avahi-publish -a`)."""
|
||||
"""Publish a bare hostname -> IP (like `avahi-publish -a`)."""
|
||||
host = name.rstrip(".").removesuffix(".local")
|
||||
_serve(Zeroconf(), [_workstation(host, ip)], verbose,
|
||||
f"published {host}.local -> {ip}")
|
||||
_serve([_workstation(host, ip)], verbose, f"published {host}.local -> {ip}")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
"""Long-running publisher: singleton PID lock, clean SIGTERM/SIGINT goodbye,
|
||||
and automatic re-announce when the device's IP changes (WiFi<->data, roaming).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from zeroconf import Zeroconf
|
||||
|
||||
from . import publish as _p
|
||||
|
||||
PID_FILE = Path.home() / ".cache" / "mdns" / "publisher.pid"
|
||||
_STOP = False
|
||||
|
||||
|
||||
def _read_pid() -> dict | None:
|
||||
if not PID_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
d = json.loads(PID_FILE.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
os.kill(int(d["pid"]), 0) # probe liveness
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
return d
|
||||
|
||||
|
||||
def _write_pid(host: str, ip: str, n: int) -> None:
|
||||
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
PID_FILE.write_text(json.dumps({
|
||||
"pid": os.getpid(), "host": host, "ip": ip, "services": n,
|
||||
"since": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}))
|
||||
|
||||
|
||||
def status() -> int:
|
||||
d = _read_pid()
|
||||
if not d:
|
||||
print("publisher: stopped")
|
||||
return 1
|
||||
print(f"publisher: running (pid {d['pid']})\n"
|
||||
f" host: {d.get('host')}.local\n"
|
||||
f" ip: {d.get('ip')}\n"
|
||||
f" services: {d.get('services', 0)}\n"
|
||||
f" since: {d.get('since')}")
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_stop(signum, frame) -> None:
|
||||
global _STOP
|
||||
_STOP = True
|
||||
|
||||
|
||||
def run_daemon(config_path: str | None = None, hostname: str | None = None,
|
||||
interval: int = 15, verbose: bool = True) -> int:
|
||||
existing = _read_pid()
|
||||
if existing:
|
||||
print(f"error: publisher already running (pid {existing['pid']}). "
|
||||
f"Use `mdns stop` first.")
|
||||
return 1
|
||||
signal.signal(signal.SIGTERM, _handle_stop)
|
||||
signal.signal(signal.SIGINT, _handle_stop)
|
||||
|
||||
cfg = _p.load_config(config_path)
|
||||
host = hostname or cfg.get("hostname", "movil")
|
||||
ip = _p.local_ipv4()
|
||||
zc = Zeroconf()
|
||||
infos = _p.build_infos(cfg, host, ip)
|
||||
_write_pid(host, ip, len(infos)) # mark alive before the slow probing register
|
||||
_p.register_all(zc, infos)
|
||||
if verbose:
|
||||
print(f"[mdns] daemon publishing {host}.local ({ip}), {len(infos)} record(s)")
|
||||
try:
|
||||
elapsed = 0
|
||||
while not _STOP:
|
||||
time.sleep(1) # short tick so SIGTERM is honored within ~1s
|
||||
elapsed += 1
|
||||
if elapsed < interval:
|
||||
continue
|
||||
elapsed = 0
|
||||
new_ip = _p.local_ipv4()
|
||||
if new_ip != ip and new_ip != "127.0.0.1":
|
||||
if verbose:
|
||||
print(f"[mdns] IP changed {ip} -> {new_ip}, re-announcing")
|
||||
_p.unregister_all(zc, infos)
|
||||
ip = new_ip
|
||||
infos = _p.build_infos(cfg, host, ip)
|
||||
_p.register_all(zc, infos)
|
||||
_write_pid(host, ip, len(infos))
|
||||
finally:
|
||||
_p.unregister_all(zc, infos)
|
||||
zc.close()
|
||||
PID_FILE.unlink(missing_ok=True)
|
||||
if verbose:
|
||||
print("[mdns] daemon stopped, goodbye packets sent.")
|
||||
return 0
|
||||
|
||||
|
||||
def stop() -> int:
|
||||
d = _read_pid()
|
||||
if not d:
|
||||
print("publisher: not running")
|
||||
return 1
|
||||
os.kill(int(d["pid"]), signal.SIGTERM)
|
||||
print(f"sent stop to pid {d['pid']}")
|
||||
return 0
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
[project]
|
||||
name = "mdns-termux"
|
||||
version = "0.3.0"
|
||||
description = "mDNS + Tailscale hostname resolution and DNS-SD tools for Termux"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Andres Garcia" }]
|
||||
dependencies = ["zeroconf>=0.100"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=7"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "lib" }
|
||||
packages = ["mdns_tools"]
|
||||
script-files = [
|
||||
"bin/mdns", "bin/mdns-resolve", "bin/mdns-publish.py",
|
||||
"bin/resolve", "bin/ssh-mdns-proxy", "bin/ssh-fallback",
|
||||
]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Make lib/ importable without installing the package.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"""TTL-aware cache tests (isolated file via MDNS_CACHE_PATH)."""
|
||||
import importlib
|
||||
import time
|
||||
|
||||
|
||||
def _fresh_cache(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MDNS_CACHE_PATH", str(tmp_path / "hosts.json"))
|
||||
from mdns_tools import cache
|
||||
importlib.reload(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def test_put_get_roundtrip(tmp_path, monkeypatch):
|
||||
cache = _fresh_cache(tmp_path, monkeypatch)
|
||||
cache.put("dell.local", "192.168.1.17", ttl=120)
|
||||
assert cache.get("dell.local") == "192.168.1.17"
|
||||
|
||||
|
||||
def test_expired_returns_none_but_stale_available(tmp_path, monkeypatch):
|
||||
cache = _fresh_cache(tmp_path, monkeypatch)
|
||||
cache.put("x.local", "10.0.0.1", ttl=30)
|
||||
# Force expiry by rewriting with a past timestamp.
|
||||
data = cache._load()
|
||||
data["x.local|v4"]["expires"] = time.time() - 1
|
||||
cache._save(data)
|
||||
assert cache.get("x.local") is None
|
||||
assert cache.get_stale("x.local") == "10.0.0.1"
|
||||
|
||||
|
||||
def test_families_are_separate(tmp_path, monkeypatch):
|
||||
cache = _fresh_cache(tmp_path, monkeypatch)
|
||||
cache.put("h.local", "192.168.1.9", family="v4")
|
||||
cache.put("h.local", "fe80::9", family="v6")
|
||||
assert cache.get("h.local", "v4") == "192.168.1.9"
|
||||
assert cache.get("h.local", "v6") == "fe80::9"
|
||||
|
||||
|
||||
def test_clear_specific_host(tmp_path, monkeypatch):
|
||||
cache = _fresh_cache(tmp_path, monkeypatch)
|
||||
cache.put("a.local", "1.1.1.1")
|
||||
cache.put("b.local", "2.2.2.2")
|
||||
assert cache.clear("a.local") == 1
|
||||
assert cache.get("a.local") is None
|
||||
assert cache.get("b.local") == "2.2.2.2"
|
||||
|
||||
|
||||
def test_ttl_clamped_to_minimum(tmp_path, monkeypatch):
|
||||
cache = _fresh_cache(tmp_path, monkeypatch)
|
||||
cache.put("c.local", "3.3.3.3", ttl=1) # below floor
|
||||
assert cache.all_entries()["c.local|v4"]["ttl"] >= 30
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""Address-selection tests (multi-homing) — the core of the reachability fix."""
|
||||
from mdns_tools import _pick
|
||||
|
||||
|
||||
def test_single_routable_ip_returned():
|
||||
assert _pick.pick_best(["192.168.1.17"]) == "192.168.1.17"
|
||||
|
||||
|
||||
def test_only_virtual_ip_returns_none(monkeypatch):
|
||||
# libvirt bridge address must be rejected so callers fall back to Tailscale.
|
||||
monkeypatch.setattr(_pick, "local_ipv4", lambda: "192.168.1.112")
|
||||
assert _pick.pick_best(["192.168.122.1"]) is None
|
||||
|
||||
|
||||
def test_docker_range_rejected(monkeypatch):
|
||||
monkeypatch.setattr(_pick, "local_ipv4", lambda: "192.168.1.112")
|
||||
assert _pick.pick_best(["172.17.0.1"]) is None
|
||||
|
||||
|
||||
def test_prefers_same_subnet(monkeypatch):
|
||||
monkeypatch.setattr(_pick, "local_ipv4", lambda: "192.168.1.112")
|
||||
got = _pick.pick_best(["10.8.0.3", "192.168.1.50"])
|
||||
assert got == "192.168.1.50"
|
||||
|
||||
|
||||
def test_dedupe_preserves_order():
|
||||
assert _pick.pick_best(["10.0.0.9", "10.0.0.9"]) == "10.0.0.9"
|
||||
|
||||
|
||||
def test_ipv6_prefers_global_over_link_local():
|
||||
got = _pick.pick_best(["fe80::1", "2800:e2::5"], family="v6")
|
||||
assert got == "2800:e2::5"
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"""Tests for reverse-pointer naming and hosts-file block management."""
|
||||
from mdns_tools import reverse as rv
|
||||
from mdns_tools import hosts as h
|
||||
|
||||
|
||||
def test_reverse_name_ipv4():
|
||||
arpa, fam = rv._reverse_name("192.168.1.17")
|
||||
assert arpa == "17.1.168.192.in-addr.arpa"
|
||||
assert fam == "v4"
|
||||
|
||||
|
||||
def test_reverse_name_ipv6_family():
|
||||
_, fam = rv._reverse_name("2800:e2::5")
|
||||
assert fam == "v6"
|
||||
|
||||
|
||||
def test_hosts_block_render_and_strip_roundtrip():
|
||||
mapping = {"dell.local": "192.168.1.17", "hp62a.local": "192.168.1.16"}
|
||||
block = h._render_block(mapping)
|
||||
assert h.MARK_BEGIN in block and h.MARK_END in block
|
||||
assert "192.168.1.17\tdell.local dell" in block
|
||||
# A file with the block should strip back to just its original body.
|
||||
original = "127.0.0.1\tlocalhost\n" + block + "\n"
|
||||
assert h._strip_block(original).strip() == "127.0.0.1\tlocalhost"
|
||||
|
||||
|
||||
def test_hosts_strip_preserves_unmanaged_lines():
|
||||
text = "1.2.3.4\tkeep.me\n" + h.MARK_BEGIN + "\n9.9.9.9\tgone\n" + h.MARK_END
|
||||
assert h._strip_block(text).strip() == "1.2.3.4\tkeep.me"
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"""Parser tests for the dependency-free resolver (no network)."""
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from mdns_tools import _stdlib_resolve as sr
|
||||
|
||||
|
||||
def _a_response(hostname: str, ips: list[str], ttl: int = 120) -> bytes:
|
||||
"""Craft a minimal mDNS response with one question and N A answers."""
|
||||
header = struct.pack(">HHHHHH", 0, 0x8400, 1, len(ips), 0, 0)
|
||||
labels = b""
|
||||
for part in hostname.rstrip(".").split("."):
|
||||
labels += bytes([len(part)]) + part.encode()
|
||||
labels += b"\x00"
|
||||
question = labels + struct.pack(">HH", 1, 1)
|
||||
answers = b""
|
||||
for ip in ips:
|
||||
answers += labels + struct.pack(">HHIH", 1, 1, ttl, 4)
|
||||
answers += socket.inet_aton(ip)
|
||||
return header + question + answers
|
||||
|
||||
|
||||
def test_build_query_roundtrip():
|
||||
q = sr.build_query("host.local", sr.QTYPE_A)
|
||||
# header is 12 bytes; QDCOUNT must be 1
|
||||
assert struct.unpack(">H", q[4:6])[0] == 1
|
||||
assert q.endswith(struct.pack(">HH", 1, 1))
|
||||
|
||||
|
||||
def test_skip_name_plain():
|
||||
data = b"\x04host\x05local\x00rest"
|
||||
off = sr.skip_name(data, 0)
|
||||
assert data[off:off + 4] == b"rest"
|
||||
|
||||
|
||||
def test_parse_all_a_single():
|
||||
resp = _a_response("dell.local", ["192.168.1.17"])
|
||||
recs = sr.parse_all_a(resp)
|
||||
assert recs == [("192.168.1.17", 120)]
|
||||
|
||||
|
||||
def test_parse_all_a_multiple():
|
||||
resp = _a_response("multi.local", ["192.168.1.5", "192.168.122.1"])
|
||||
ips = [ip for ip, _ in sr.parse_all_a(resp)]
|
||||
assert ips == ["192.168.1.5", "192.168.122.1"]
|
||||
|
||||
|
||||
def test_parse_all_a_empty_on_garbage():
|
||||
assert sr.parse_all_a(b"\x00\x00") == []
|
||||
Loading…
Reference in New Issue