11 KiB
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 totailb0bb74.ts.net. Set to your own tailnet.MDNS_TIMEOUT— seconds waiting for multicast (default2).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 publishrunning on the phone, a Linux box withnss-mdnsresolvedmovil.localand 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 browseon 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
getentdoesn't exist on Termux. Scripts that shell out to it silently fail. This repo usespython -c "import socket; socket.gethostbyname(...)"as the portable fallback on Bionic.- Shebang matters.
#!/data/data/com.termux/files/usr/bin/bashis required fordeclare -A. A stray backslash (#\!) makes the kernel fall through tosh, which rejects bash-only syntax withSyntax error: "(" unexpected. - mDNS off-LAN is a no-op. Multicast (224.0.0.251) does not cross networks. Over mobile data, only Tailscale resolves.
zeroconfis notpkg-managed. After restoring Termux from atarbackup, re-runpip install zeroconformdns-publish.pywill silently die withModuleNotFoundError.- Silent failures are the enemy. The
.bashrcsnippet deliberately logs to~/.cache/mdns.loginstead 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).
Related
- claude-code-termux — Claude Code CLI setup for Termux. Uses this repo for SSH/host resolution.
License
MIT — see LICENSE.