init: extraer herramientas mDNS de claude-code-termux

Scripts (bin/):
- mdns-resolve: resolver .local por multicast UDP (stdlib Python).
- mdns-publish.py: publicar este dispositivo como movil.local (zeroconf).
- ssh-mdns-proxy: ProxyCommand SSH con orden mDNS -> Tailscale -> cache.
- ssh-fallback: ProxyCommand con fallback Tailscale -> LAN.
- resolve: resolver genérico para cualquier comando (ping, curl, kubectl...).

Config (config/):
- bashrc.snippet: aliases r/pingr/curlr/sshr + auto-arranque publisher.
- ssh_config.example: entradas ProxyCommand para .local y hostnames cortos.

Docs y setup:
- README.md standalone con guía de uso, comportamiento por red y gotchas
  (getent ausente en Bionic, shebang bash-only, mDNS no cruza redes,
  zeroconf no sobrevive tar-backups, no silenciar logs).
- install.sh independiente con --dry-run.

Origen: extraído de andresgarcia0313/claude-code-termux para reutilizarse
sin acoplar a Claude Code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andres Garcia 2026-07-19 20:53:44 -05:00
commit 0b6642ec84
11 changed files with 565 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
*.log
*.tmp
*.bak
*.bak-*
__pycache__/
*.pyc
.venv/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andrés García
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

113
README.md Normal file
View File

@ -0,0 +1,113 @@
# 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
| Script | Role |
|--------|------|
| `mdns-resolve` | Resolve `<host>.local` via raw multicast UDP (pure Python, stdlib only). |
| `mdns-publish.py` | Publish this device on the LAN as `movil.local` using `zeroconf`. |
| `ssh-mdns-proxy` | SSH `ProxyCommand`: mDNS → Tailscale DNS → cached IP. |
| `ssh-fallback` | SSH `ProxyCommand`: Tailscale → LAN, for flaky Tailscale. |
| `resolve` | Generic resolver for **any** command (ping, curl, kubectl, nc…). Same order. |
## Install
```bash
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
### Generic resolver — works in any command
```bash
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 `1`, keeps off-LAN snappy).
### Shell helpers (from `config/bashrc.snippet`)
```bash
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
```
### SSH by hostname (via `ProxyCommand`)
Drop `config/ssh_config.example` into `~/.ssh/config`. Then:
```bash
ssh lenovo # resolves via mDNS then Tailscale, connects
ssh lenovo.local # forces mDNS-first path
```
### Publishing this device as `movil.local`
```bash
python ~/.local/bin/mdns-publish.py # foreground
nohup python ~/.local/bin/mdns-publish.py >>~/.cache/mdns.log 2>&1 & disown
```
## Behavior by network
| Scenario | Path | Typical latency |
|--------------------|--------------------------------------------|-----------------|
| On home LAN | mDNS multicast (1 s cap) → else Tailscale | < 100 ms |
| Mobile data / away | mDNS fails → Tailscale MagicDNS | < 200 ms |
| No Tailscale | Both fail → cached last-known IP | instant |
## 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).
## Related
- [claude-code-termux](https://devops.ingeniumcodex.com/andresgarcia0313/claude-code-termux)
— Claude Code CLI setup for Termux. Uses this repo for SSH/host resolution.
## License
MIT — see `LICENSE`.

73
bin/mdns-publish.py Executable file
View File

@ -0,0 +1,73 @@
#!/data/data/com.termux/files/usr/bin/python
"""Publica servicios via mDNS/Zeroconf: SSH + Samba"""
import socket
import signal
import sys
from zeroconf import ServiceInfo, Zeroconf, IPVersion
HOSTNAME = 'movil'
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():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return '127.0.0.1'
def main():
ip = get_local_ip()
addr = socket.inet_aton(ip)
zc = Zeroconf(ip_version=IPVersion.V4Only)
infos = []
for svc in SERVICES:
info = ServiceInfo(
svc['type'],
f'{HOSTNAME}.{svc["type"]}',
addresses=[addr],
port=svc['port'],
properties=svc['props'],
server=f'{HOSTNAME}.local.',
)
zc.register_service(info)
infos.append(info)
print(f'Publicando {HOSTNAME}.local {svc["type"]} -> {ip}:{svc["port"]}')
def cleanup(sig, frame):
print('Deteniendo mDNS...')
for info in infos:
zc.unregister_service(info)
zc.close()
sys.exit(0)
signal.signal(signal.SIGINT, cleanup)
signal.signal(signal.SIGTERM, cleanup)
try:
signal.pause()
except AttributeError:
import time
while True:
time.sleep(3600)
if __name__ == '__main__':
main()

103
bin/mdns-resolve Executable file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Resolve hostname.local via mDNS multicast (pure Python, no dependencies).
Usage: mdns-resolve <hostname.local> [timeout_seconds]
Returns: IP address on stdout, or exit 1 on failure.
"""
import sys
import socket
import struct
import select
import time
def build_mdns_query(hostname: str) -> bytes:
header = struct.pack('>HHHHHH', 0, 0, 1, 0, 0, 0)
question = b''
for part in hostname.rstrip('.').split('.'):
question += bytes([len(part)]) + part.encode()
question += b'\x00'
question += struct.pack('>HH', 1, 1) # Type A, Class IN
return header + question
def parse_mdns_response(data: bytes) -> str | None:
if len(data) < 12:
return None
qdcount = struct.unpack('>H', data[4:6])[0]
ancount = struct.unpack('>H', data[6:8])[0]
if ancount == 0:
return None
offset = 12
for _ in range(qdcount):
while offset < len(data) and data[offset] != 0:
if data[offset] & 0xc0 == 0xc0:
offset += 2
break
offset += data[offset] + 1
else:
offset += 1
offset += 4
for _ in range(ancount):
while offset < len(data):
if data[offset] & 0xc0 == 0xc0:
offset += 2
break
elif data[offset] == 0:
offset += 1
break
else:
offset += data[offset] + 1
if offset + 10 > len(data):
break
rtype, rclass, ttl, rdlength = struct.unpack('>HHIH', data[offset:offset+10])
offset += 10
if rtype == 1 and rdlength == 4 and offset + 4 <= len(data):
return socket.inet_ntoa(data[offset:offset+4])
offset += rdlength
return None
def resolve_mdns(hostname: str, timeout: float = 2.0) -> str | None:
if not hostname.endswith('.local'):
hostname += '.local'
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setblocking(False)
sock.sendto(build_mdns_query(hostname), ('224.0.0.251', 5353))
end_time = time.time() + timeout
while time.time() < end_time:
ready, _, _ = select.select([sock], [], [], 0.1)
if ready:
try:
data, _ = sock.recvfrom(4096)
ip = parse_mdns_response(data)
if ip:
sock.close()
return ip
except Exception:
pass
sock.close()
except Exception:
pass
return None
def main():
if len(sys.argv) < 2:
print("Usage: mdns-resolve <hostname.local> [timeout]", file=sys.stderr)
sys.exit(1)
hostname = sys.argv[1]
timeout = float(sys.argv[2]) if len(sys.argv) > 2 else 2.0
ip = resolve_mdns(hostname, timeout)
if ip:
print(ip)
else:
sys.exit(1)
if __name__ == "__main__":
main()

47
bin/resolve Executable file
View File

@ -0,0 +1,47 @@
#!/data/data/com.termux/files/usr/bin/bash
# resolve: hostname -> IP con fallback mDNS (LAN) -> Tailscale DNS -> cache
# Uso: resolve <hostname>[.local|.tailnet]
# Ej: resolve lenovo # -> 100.69.236.16
# ping $(resolve hp62a)
# curl "http://$(resolve dell):8080"
set -e
HOST="${1:-}"
[[ -z "$HOST" ]] && { echo "Uso: resolve <hostname>" >&2; exit 1; }
CACHE="$HOME/.ssh/resolve-cache"
TAILSCALE_DOMAIN="${TAILSCALE_DOMAIN:-tailb0bb74.ts.net}"
MDNS_TIMEOUT="${MDNS_TIMEOUT:-1}"
QUERY="${HOST%.local}"
QUERY="${QUERY%.${TAILSCALE_DOMAIN}}"
declare -A ALIASES=(
[dell]="dell-latitude3400"
[lenovo]="lenovo-ideapad"
)
[[ -n "${ALIASES[$QUERY]:-}" ]] && QUERY="${ALIASES[$QUERY]}"
# 1. mDNS multicast (LAN)
IP=$("$HOME/.local/bin/mdns-resolve" "${QUERY}.local" "$MDNS_TIMEOUT" 2>/dev/null || true)
# 2. Tailscale MagicDNS (remoto)
if [[ -z "$IP" ]]; then
IP=$(python -c "import socket; print(socket.gethostbyname('${QUERY}.${TAILSCALE_DOMAIN}'))" 2>/dev/null || true)
fi
# 3. Cache
if [[ -z "$IP" && -f "$CACHE" ]]; then
IP=$(grep "^${QUERY} " "$CACHE" 2>/dev/null | awk '{print $2}')
fi
if [[ -z "$IP" ]]; then
echo "Error: no se resuelve '${HOST}'" >&2
exit 1
fi
mkdir -p "$(dirname "$CACHE")"
grep -v "^${QUERY} " "$CACHE" > "${CACHE}.tmp" 2>/dev/null || true
echo "${QUERY} ${IP}" >> "${CACHE}.tmp"
mv "${CACHE}.tmp" "$CACHE"
echo "$IP"

23
bin/ssh-fallback Executable file
View File

@ -0,0 +1,23 @@
#!/data/data/com.termux/files/usr/bin/bash
#
# ssh-fallback: Proxy para SSH con fallback automático
# Usado como ProxyCommand en ~/.ssh/config
#
# Uso: ssh-fallback <tailscale_host> <lan_ip> <port>
TAILSCALE_HOST="$1"
LAN_IP="$2"
PORT="${3:-22}"
# Intentar Tailscale primero (1 segundo timeout)
if ping -c 1 -W 1 "$TAILSCALE_HOST" &>/dev/null 2>&1; then
exec nc -w 5 "$TAILSCALE_HOST" "$PORT"
fi
# Fallback a IP LAN
if ping -c 1 -W 1 "$LAN_IP" &>/dev/null 2>&1; then
exec nc -w 5 "$LAN_IP" "$PORT"
fi
# Último intento: Tailscale sin verificar
exec nc -w 10 "$TAILSCALE_HOST" "$PORT"

52
bin/ssh-mdns-proxy Executable file
View File

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# ssh-mdns-proxy: Resolve .local via mDNS with fallbacks, then connect
#
# Resolution order: mDNS → Tailscale DNS → cached IP
# Usage: ssh-mdns-proxy <hostname> [port]
#
# Configure your Tailscale domain:
TAILSCALE_DOMAIN="${TAILSCALE_DOMAIN:-tailb0bb74.ts.net}"
HOST="$1"
PORT="${2:-22}"
CACHE="$HOME/.ssh/mdns-cache"
QUERY="${HOST%.local}"
# Alias mapping (short name -> mDNS hostname)
declare -A ALIASES=(
[dell]="dell-latitude3400"
[lenovo]="lenovo-ideapad"
)
[[ -n "${ALIASES[$QUERY]}" ]] && QUERY="${ALIASES[$QUERY]}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# 1. mDNS resolution
IP=$("$SCRIPT_DIR/mdns-resolve" "${QUERY}.local" 2 2>/dev/null)
# 2. Fallback: Tailscale DNS (Termux carece de getent, usar python si esta disponible)
if [[ -z "$IP" ]]; then
if command -v getent >/dev/null 2>&1; then
IP=$(getent hosts "${QUERY}.${TAILSCALE_DOMAIN}" 2>/dev/null | awk '{print $1}')
else
IP=$(python -c "import socket; print(socket.gethostbyname('${QUERY}.${TAILSCALE_DOMAIN}'))" 2>/dev/null)
fi
fi
# 3. Fallback: cached IP
if [[ -z "$IP" && -f "$CACHE" ]]; then
IP=$(grep "^${QUERY} " "$CACHE" 2>/dev/null | awk '{print $2}')
fi
if [[ -z "$IP" ]]; then
echo "Error: cannot resolve ${HOST}" >&2
exit 1
fi
# Save to cache
mkdir -p "$(dirname "$CACHE")"
grep -v "^${QUERY} " "$CACHE" > "${CACHE}.tmp" 2>/dev/null || true
echo "${QUERY} ${IP}" >> "${CACHE}.tmp"
mv "${CACHE}.tmp" "$CACHE"
exec nc "$IP" "$PORT"

37
config/bashrc.snippet Normal file
View File

@ -0,0 +1,37 @@
# === mdns-termux - .bashrc additions ===
# Append these lines to your ~/.bashrc
# Ensure ~/.local/bin is on PATH
export PATH="$HOME/.local/bin:$PATH"
# Your Tailscale tailnet (change to yours). Used as fallback for resolve/sshr.
export TAILSCALE_DOMAIN="tailXXXXXX.ts.net"
# --- mDNS publisher (this device as movil.local on the LAN) ---
alias mdns-status='pgrep -af mdns-publish || echo "mDNS publisher is not running"'
alias mdns-restart='pkill -f mdns-publish; sleep 1; nohup python ~/.local/bin/mdns-publish.py >>~/.cache/mdns.log 2>&1 & disown && echo "mDNS restarted"'
alias mdns-stop='pkill -f mdns-publish && echo "mDNS stopped"'
# --- Generic host resolver (resolve: mDNS -> Tailscale -> cache) ---
# Works in any command: ping $(resolve lenovo) / curl "http://$(resolve hp62a)"
alias r='resolve'
pingr() { ping -c 3 "$(resolve "$1")"; }
curlr() { local h="$1"; shift; curl "$@" "http://$(resolve "$h")"; }
# HostKeyAlias reuses the known_hosts entry for the Tailscale FQDN so SSH-by-IP
# does not fail host-key verification.
sshr() {
local h="$1"; shift
local canonical="$h"
# Add your own short-name -> canonical mappings here:
case "$h" in dell) canonical="dell-latitude3400";; lenovo) canonical="lenovo-ideapad";; esac
ssh -o "HostKeyAlias=${canonical}.${TAILSCALE_DOMAIN}" \
"YOUR_USER@$(resolve "$h")" "$@"
}
# --- Auto-start publisher on shell login (optional) ---
# Uncomment if you want movil.local advertised whenever you open a Termux shell.
# Failures land in ~/.cache/mdns.log — do NOT redirect to /dev/null.
#
# mkdir -p ~/.cache
# pgrep -f "mdns-publish" >/dev/null 2>&1 || \
# nohup python ~/.local/bin/mdns-publish.py >>~/.cache/mdns.log 2>&1 & disown

29
config/ssh_config.example Normal file
View File

@ -0,0 +1,29 @@
# SSH Config for Termux with mDNS/Tailscale resolution
# Copy relevant sections to ~/.ssh/config
Host *
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ServerAliveInterval 60
ServerAliveCountMax 3
ConnectTimeout 10
# === mDNS (.local) - Dynamic resolution ===
Host *.local
User <your-user>
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
# === Short hostnames -> mDNS first, then Tailscale ===
Host <host-1>
User <your-user>
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
Host <host-2>
User <your-user>
ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p
# === ProxyJump (hosts without Tailscale, via jump host) ===
# Host <lan-only-host>
# HostName <lan-only-host>.local
# User <user>
# ProxyJump <jump-host>

60
install.sh Normal file
View File

@ -0,0 +1,60 @@
#!/data/data/com.termux/files/usr/bin/bash
# install.sh - Install mdns-termux scripts to ~/.local/bin
# Usage: bash install.sh [--dry-run]
set -euo pipefail
DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DIR="$HOME/.local/bin"
run() {
if [[ $DRY_RUN -eq 1 ]]; then
echo "[dry-run] $*"
else
eval "$@"
fi
}
step() { echo ""; echo ">>> Step $1: $2"; }
# 1. Verify Termux
step 1 "Verifying Termux environment..."
if [[ -z "${PREFIX:-}" || "$PREFIX" != *"com.termux"* ]]; then
echo "ERROR: This installer targets Termux. \$PREFIX not set as expected." >&2
exit 1
fi
echo " Termux detected: $PREFIX"
# 2. Base packages
step 2 "Installing required packages (pkg + pip)..."
run "pkg install -y python proot"
run "pip install --upgrade zeroconf"
# 3. Copy scripts
step 3 "Installing scripts to $BIN_DIR..."
run "mkdir -p '$BIN_DIR'"
for script in mdns-resolve mdns-publish.py ssh-mdns-proxy ssh-fallback resolve; do
run "cp '$SCRIPT_DIR/bin/$script' '$BIN_DIR/$script'"
run "chmod +x '$BIN_DIR/$script'"
done
# 4. Reminders
step 4 "Manual steps remaining:"
cat <<EOF
a) Append aliases to ~/.bashrc:
cat $SCRIPT_DIR/config/bashrc.snippet >> ~/.bashrc
Then edit TAILSCALE_DOMAIN inside your ~/.bashrc.
b) (Optional) Merge SSH config for .local + short-name resolution:
cat $SCRIPT_DIR/config/ssh_config.example >> ~/.ssh/config
Adjust User and hostnames.
c) Test:
resolve <hostname>
pingr <hostname>
EOF
echo "Done."