mDNS + Tailscale hostname resolution tools for Termux. Fixes .local resolution on Android/Bionic libc.
Go to file
Andres Garcia 5e5d42f997 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>
2026-07-20 12:59:10 -05:00
bin feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests 2026-07-20 12:59:10 -05:00
config feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests 2026-07-20 12:59:10 -05:00
lib/mdns_tools feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests 2026-07-20 12:59:10 -05:00
tests feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests 2026-07-20 12:59:10 -05:00
.gitignore init: extraer herramientas mDNS de claude-code-termux 2026-07-19 20:53:44 -05:00
LICENSE init: extraer herramientas mDNS de claude-code-termux 2026-07-19 20:53:44 -05:00
README.md feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests 2026-07-20 12:59:10 -05:00
install.sh feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests 2026-07-20 12:59:10 -05:00
pyproject.toml feat: publisher-daemon (singleton + re-announce), sync-hosts, status/stop y tests 2026-07-20 12:59:10 -05:00

README.md

mdns-termux

mDNS + Tailscale hostname resolution for Termux (Android).

Termux runs on Android's Bionic libc, which does not resolve .local names and lacks getent. This repo ships small, dependency-light helpers that fix that — without root, and without a custom system resolver.

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

git clone https://devops.ingeniumcodex.com/andresgarcia0313/mdns-termux.git
cd mdns-termux
bash install.sh          # or --dry-run first

Then follow the printed reminders (append bashrc.snippet, set your TAILSCALE_DOMAIN, optionally merge ssh_config.example).

Usage

mdns CLI

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 (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.
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):

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:

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 publish --address printer 192.168.1.50  # publish printer.local -> that IP
mdns browse --watch                          # live +/=/- events, all types
mdns cache list                              # inspect TTL-aware cache

resolve (bash) — for any command

resolve lenovo                       # -> 100.69.236.16
ping   $(resolve hp62a)
curl   "http://$(resolve dell):8080"
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 2).
  • MDNS_CACHE_PATH — override cache location (default ~/.cache/mdns/hosts.json).

Shell helpers (from config/bashrc.snippet)

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)

Drop config/ssh_config.example into ~/.ssh/config. Then:

ssh lenovo                # resolves via mDNS then Tailscale, connects
ssh lenovo.local          # forces mDNS-first path

Publishing this device on the LAN

Edit ~/.config/mdns/services.json (the installer seeds a template), then:

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:

{
  "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 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
Live service monitor mdns browse --watch (like avahi-browse)
Publisher (hostname + services) via zeroconf, with probing + goodbye
Bare hostname->IP publish mdns publish --address (avahi-publish -a)
Name-conflict handling RFC 6762 probe + auto-rename
Multicast group membership IP_ADD_MEMBERSHIP on all sockets
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:

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 implementation), anything you advertise is visible to standard mDNS stacks — no special client needed on the other end.

Verified on a real LAN:

  • Termux → Avahi (Linux): with mdns publish running on the phone, a Linux box with nss-mdns resolved movil.local and pinged it, with no extra config:
    $ getent hosts movil.local
    192.168.1.112   movil.local
    $ ping movil.local            # 64 bytes ... time=3.84 ms
    
  • Avahi → Termux: mdns browse on the phone sees services published by Avahi/Bonjour hosts (SMB, SSH, _workstation, Chromecast, AirPlay, …).

So a Mac (Bonjour), a Linux box (Avahi), or a Windows machine (Bonjour Print Services) will all discover and resolve what this tool publishes, and vice versa.

Gotchas found the hard way

  • getent doesn't exist on Termux. Scripts that shell out to it silently fail. This repo uses python -c "import socket; socket.gethostbyname(...)" as the portable fallback on Bionic.
  • Shebang matters. #!/data/data/com.termux/files/usr/bin/bash is required for declare -A. A stray backslash (#\!) makes the kernel fall through to sh, which rejects bash-only syntax with Syntax error: "(" unexpected.
  • mDNS off-LAN is a no-op. Multicast (224.0.0.251) does not cross networks. Over mobile data, only Tailscale resolves.
  • zeroconf is not pkg-managed. After restoring Termux from a tar backup, re-run pip install zeroconf or mdns-publish.py will silently die with ModuleNotFoundError.
  • Silent failures are the enemy. The .bashrc snippet deliberately logs to ~/.cache/mdns.log instead of /dev/null. Keep it that way.

Tailscale on Termux

Tailscale runs as the Android app, not as a Termux CLI — tailscale up inside a shell won't work. The app provides MagicDNS for *.<your-tailnet>.ts.net, which Bionic-libc resolves fine (it's just a regular DNS lookup).

  • claude-code-termux — Claude Code CLI setup for Termux. Uses this repo for SSH/host resolution.

License

MIT — see LICENSE.