diff --git a/Plugin/unraid/include/config.php b/Plugin/unraid/include/config.php index 86448f8..9b239ee 100644 --- a/Plugin/unraid/include/config.php +++ b/Plugin/unraid/include/config.php @@ -416,28 +416,47 @@ function vv_get_hostname(bool $flush = false): string { return $name; } -// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages -// the mapping so this survives IP changes), falls back to parsing `tailscale status` text. +// Mirror of common.sh resolve_tailscale_ip(): resolves a hostname to a Tailscale IPv4. +// +// `tailscale status` is asked first, and that ordering is the whole point. It used to try +// `tailscale ip -4` first, on the reasoning that Tailscale owns the mapping — true, but that call +// resolves through MagicDNS, and MagicDNS does not work here: these hosts sit on separate tailnets +// shared into each other, so the name misses and the call falls through to a system DNS lookup +// that times out. Measured at **5.04 seconds, every call**, against 0.010s for the status parse. +// +// Nine files resolve peers this way — the AI RPC, node_chat, the arr collector, the media mesh +// view — so that was five seconds added to every mesh operation on both hosts, quietly, for as +// long as the mesh has existed. Nothing looked broken; everything was just slow. +// +// Order is now: exact match, unambiguous prefix match, then the DNS path bounded to two seconds as +// a last resort. Exactness is not given up to get the speed — `tailscale status` carries the same +// mapping `tailscale ip` would return, and an exact name match against it is exactly as precise. +// +// Memoised per request: a page that resolves the same partner four times paid four lookups. function vv_resolve_tailscale_ip(string $hostname): string { - $h = strtolower($hostname); - $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: ''); - if ($ip) return $ip; + static $cache = []; + $h = strtolower($hostname); + if (isset($cache[$h])) return $cache[$h]; - // Fallback: unambiguous prefix match against tailscale status (either direction) — handles - // Unraid's 15-char NetBIOS hostname truncation vs. a longer name recorded in master.conf. - // Only accept the match when exactly one peer could qualify; never guess between multiple - // candidates that happen to share a prefix (e.g. server1/server10). - $out = shell_exec('tailscale status 2>/dev/null') ?: ''; + $out = shell_exec('tailscale status 2>/dev/null') ?: ''; $matches = []; foreach (explode("\n", $out) as $line) { $cols = preg_split('/\s+/', trim($line)); if (!isset($cols[1])) continue; $peerHost = strtolower(explode('.', $cols[1])[0]); - if (str_starts_with($peerHost, $h) || str_starts_with($h, $peerHost)) { - $matches[] = $cols[0]; - } + + if ($peerHost === $h) return $cache[$h] = $cols[0]; + + // Unambiguous prefix match, either direction — handles Unraid's 15-char NetBIOS hostname + // truncation against a longer name recorded in master.conf. Only accepted when exactly one + // peer could qualify; never a guess between candidates sharing a prefix (server1/server10). + if (str_starts_with($peerHost, $h) || str_starts_with($h, $peerHost)) $matches[] = $cols[0]; } - return count($matches) === 1 ? $matches[0] : ''; + if (count($matches) === 1) return $cache[$h] = $matches[0]; + + // Only reached when status cannot decide. Bounded, because this is the path that blocks on DNS + // when MagicDNS is unavailable — which is the normal case on this mesh. + return $cache[$h] = trim(shell_exec('timeout 2 tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: ''); } // Cached alongside the others: this reads master.conf in full and is called by vv_conf_vars() on diff --git a/common.sh b/common.sh index 70b5bfd..392b4e2 100755 --- a/common.sh +++ b/common.sh @@ -921,26 +921,48 @@ resolve_remote_ip() { info "$ICON_NET Remote IP: $REMOTE_SERVER" } -# Resolve any hostname to a Tailscale IPv4 — tries direct lookup, falls back to status parse. +# Resolve any hostname to a Tailscale IPv4 — status parse first, DNS as a bounded last resort. # Usage: ip=$(resolve_tailscale_ip "hostname") — returns empty string on failure. +# +# `tailscale ip -4` used to be tried first, because Tailscale owns the mapping. It also resolves +# through MagicDNS, which does not work on this mesh — the two hosts are on separate tailnets +# shared into each other — so the name misses and the call blocks on a system DNS lookup until it +# times out. Five seconds, every call, on every rsync, partnership check and remote collector. +# The status parse costs ten milliseconds and carries the same mapping. +# +# Exactness is not traded away for the speed: an exact name match against `tailscale status` is as +# precise as the lookup it replaces. The prefix match keeps its ambiguity guard, and the DNS path +# still runs — bounded, and only when status cannot decide. +# +# Mirror of vv_resolve_tailscale_ip() in Plugin/unraid/include/config.php; the two must agree. resolve_tailscale_ip() { local hostname="${1,,}" - local ip - ip=$(tailscale ip -4 "$hostname" 2>/dev/null) - if [[ -n "$ip" ]]; then - echo "$ip" + local status_out exact matches count ip + + status_out=$(tailscale status 2>/dev/null) + + exact=$(echo "$status_out" | awk -v name="$hostname" ' + { split(tolower($2), parts, "."); if (parts[1] == name) { print $1; exit } }') + if [[ -n "$exact" ]]; then + echo "$exact" return fi - # Fallback: unambiguous prefix match against tailscale status (either direction) — handles - # Unraid's 15-char NetBIOS hostname truncation vs. a longer name recorded in master.conf. - # Only accept the match when exactly one peer could qualify; never guess between multiple - # candidates that happen to share a prefix (e.g. server1/server10). - local matches count - matches=$(tailscale status 2>/dev/null | awk -v name="$hostname" ' + + # Unambiguous prefix match, either direction — handles Unraid's 15-char NetBIOS hostname + # truncation vs. a longer name recorded in master.conf. Only accept the match when exactly one + # peer could qualify; never guess between candidates that share a prefix (server1/server10). + matches=$(echo "$status_out" | awk -v name="$hostname" ' { split(tolower($2), parts, "."); host = parts[1]; if (index(host, name) == 1 || index(name, host) == 1) print $1 }') count=$(echo "$matches" | grep -c .) - [[ "$count" -eq 1 ]] && echo "$matches" + if [[ "$count" -eq 1 ]]; then + echo "$matches" + return + fi + + # Last resort, and the slow one. Bounded so a broken MagicDNS costs two seconds, not five. + ip=$(timeout 2 tailscale ip -4 "$hostname" 2>/dev/null) + [[ -n "$ip" ]] && echo "$ip" } # Strips the "unraid-" prefix (case-insensitive) and title-cases what remains.