commit 0b6642ec84cc97f0499836f6b3e80ab7386ef010 Author: Andres Garcia Date: Sun Jul 19 20:53:44 2026 -0500 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1682e6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.log +*.tmp +*.bak +*.bak-* +__pycache__/ +*.pyc +.venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..49fbb8d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..20262fe --- /dev/null +++ b/README.md @@ -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 `.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 # ping -c 3 to resolved IP +curlr [flags] # curl http:// +sshr [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 +`*..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`. diff --git a/bin/mdns-publish.py b/bin/mdns-publish.py new file mode 100755 index 0000000..189c946 --- /dev/null +++ b/bin/mdns-publish.py @@ -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() diff --git a/bin/mdns-resolve b/bin/mdns-resolve new file mode 100755 index 0000000..81d954f --- /dev/null +++ b/bin/mdns-resolve @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Resolve hostname.local via mDNS multicast (pure Python, no dependencies). +Usage: mdns-resolve [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 [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() diff --git a/bin/resolve b/bin/resolve new file mode 100755 index 0000000..a6c22e6 --- /dev/null +++ b/bin/resolve @@ -0,0 +1,47 @@ +#!/data/data/com.termux/files/usr/bin/bash +# resolve: hostname -> IP con fallback mDNS (LAN) -> Tailscale DNS -> cache +# Uso: resolve [.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 " >&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" diff --git a/bin/ssh-fallback b/bin/ssh-fallback new file mode 100755 index 0000000..bcd6ac1 --- /dev/null +++ b/bin/ssh-fallback @@ -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="$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" diff --git a/bin/ssh-mdns-proxy b/bin/ssh-mdns-proxy new file mode 100755 index 0000000..b694309 --- /dev/null +++ b/bin/ssh-mdns-proxy @@ -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 [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" diff --git a/config/bashrc.snippet b/config/bashrc.snippet new file mode 100644 index 0000000..085792d --- /dev/null +++ b/config/bashrc.snippet @@ -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 diff --git a/config/ssh_config.example b/config/ssh_config.example new file mode 100644 index 0000000..08e6d6f --- /dev/null +++ b/config/ssh_config.example @@ -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 + ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p + +# === Short hostnames -> mDNS first, then Tailscale === +Host + User + ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p + +Host + User + ProxyCommand ~/.local/bin/ssh-mdns-proxy %h %p + +# === ProxyJump (hosts without Tailscale, via jump host) === +# Host +# HostName .local +# User +# ProxyJump diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..68ea342 --- /dev/null +++ b/install.sh @@ -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 <> ~/.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 + pingr + +EOF +echo "Done."