fix(install): detect HOST_IP from physical NICs only, never a VPN tunnel

`ip route get 1.1.1.1` returns the tunnel address on servers running a
full-tunnel WireGuard/Tailscale/ZeroTier client, so phones got a QR code and
URL pointing at an address they can't reach. Detection now uses the
main-table default route's interface if it is real hardware
(/sys/class/net/<if>/device), else the first physical NIC with an IPv4,
else `hostname -I` as a last resort (the installer shows it for
confirmation). .env.example says HOST_IP must be the physical LAN address.

Tested with a stubbed `ip` + fake sysfs in Debian: wg-quick full tunnel,
tunnel owning the default route, no default route and WiFi-only all pick
the physical address; full install.sh scenarios unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 15:46:51 +03:00
co-authored by Claude Opus 5.5
parent 0f3237f125
commit 034106918d
2 changed files with 31 additions and 9 deletions
+27 -7
View File
@@ -7,17 +7,37 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# LAN IP of this machine (the address phones use: http://<HOST_IP>).
# `ip route get` picks the interface that actually routes out, which avoids
# docker0 / bridge addresses that `hostname -I` may list first.
# LAN IP of this machine on the PHYSICAL network (the address phones use:
# http://<HOST_IP>). Must never be a VPN address: with a full-tunnel WireGuard /
# Tailscale / ZeroTier client, "the interface that reaches the internet" is the
# tunnel, which phones on the restaurant WiFi can't reach.
# Real NICs (Ethernet, WiFi) have /sys/class/net/<if>/device; VPN tunnels,
# Docker bridges, veths and loopback don't.
# XENIA_SYS_NET overrides the sysfs path (tests only).
detect_host_ip() {
local ip=""
if command -v ip >/dev/null 2>&1; then
ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
local sys_net="${XENIA_SYS_NET:-/sys/class/net}" ifc ip=""
command -v ip >/dev/null 2>&1 || { hostname -I 2>/dev/null | awk '{print $1}'; return; }
first_ipv4() { ip -4 -o addr show dev "$1" 2>/dev/null | awk '{split($4, a, "/"); print a[1]; exit}'; }
# 1) Interface of the main-table default route, if it's real hardware.
# (wg-quick full-tunnel routing uses policy rules + its own table, so the
# main table's default route still points at the physical uplink.)
ifc=$(ip route show default 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}')
if [ -n "$ifc" ] && [ -e "$sys_net/$ifc/device" ]; then
ip=$(first_ipv4 "$ifc")
fi
# 2) Otherwise the first real-hardware interface that has an IPv4 address.
if [ -z "$ip" ]; then
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
for path in "$sys_net"/*; do
ifc=$(basename "$path")
[ -e "$path/device" ] || continue
ip=$(first_ipv4 "$ifc")
[ -n "$ip" ] && break
done
fi
# 3) Last resort — the admin confirms it at the prompt anyway.
[ -z "$ip" ] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
echo "$ip"
}