feat(backend): runtime LAN-IP detection (netinfo helper) + manual override
The backend's bridge network can't see the host's NICs, so HOST_IP from install.sh went stale silently after a DHCP change. Now: - services/netinfo_helper.py runs as a new `netinfo` service (same backend image, network_mode: host): every 60s it picks the PHYSICAL LAN address (main-table default-route NIC if real hardware, else first real NIC with IPv4; never WireGuard/Tailscale/ZeroTier/bridges/veths, ignores 169.254) and writes it to the shared `netinfo` volume. Stdlib only. On Docker Desktop (linuxkit/WSL2 kernel) it reports "unsupported" instead of the VM's meaningless address. - services/lan_ip.py: one resolver used by /api/system/status (lan_ip + lan_ip_info), the pairing QR and the cloud heartbeat's local_ip: override (pos_settings network.lan_ip_override) → live detection (ignored when older than 5 min) → HOST_IP. Flags `mismatch` when a pinned address is no longer on any of the machine's NICs. - PUT /api/system/lan-ip-override (manager): set a private IPv4 or null to return to automatic; public/loopback/link-local/IPv6 rejected (422). - cloud_sync._get_local_ip uses the resolver (no more socket trick that returned the container IP). Tested: helper selection on a fake sysfs/route table (8 cases incl. VPN default routes) + real ioctl/route parsing on a Linux kernel; resolver priority/staleness/mismatch/validation (18 cases); isolated full stack: HOST_IP fallback on Docker Desktop, override save/validate/auth, simulated Linux detection incl. DHCP change and dead helper, heartbeat IP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Host LAN-IP detector — runs as its own container with `network_mode: host`.
|
||||
|
||||
The backend lives in a Docker bridge network and can only see its container
|
||||
address, never the machine's real network cards. This helper shares the host's
|
||||
network namespace, finds the server's address on the PHYSICAL network
|
||||
(Ethernet/WiFi — never a WireGuard/Tailscale/ZeroTier tunnel, Docker bridge or
|
||||
veth) and writes it to a small JSON file on a volume the backend reads
|
||||
(services/lan_ip.py). It refreshes every minute, so a DHCP change shows up
|
||||
without anyone re-running install.sh.
|
||||
|
||||
Selection (same rules as install.sh's detect_host_ip):
|
||||
1. the interface of the main-table default route, if it is real hardware
|
||||
(real NICs have /sys/class/net/<if>/device; tunnels and bridges don't);
|
||||
2. otherwise the first real-hardware interface that has an IPv4 address.
|
||||
|
||||
On Docker Desktop (Windows/Mac dev machines) "the host network" is Docker's own
|
||||
Linux VM, whose address means nothing to phones — the helper detects that and
|
||||
reports no IP instead of a wrong one.
|
||||
|
||||
Standalone on purpose: standard library only, no app imports, no database.
|
||||
Run: python -m services.netinfo_helper
|
||||
"""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
OUT_FILE = os.environ.get("NETINFO_FILE", "/netinfo/host_ip.json")
|
||||
INTERVAL_SECONDS = int(os.environ.get("NETINFO_INTERVAL", "60"))
|
||||
SIOCGIFADDR = 0x8915
|
||||
|
||||
|
||||
def ipv4_of(ifname: str) -> str | None:
|
||||
"""Primary IPv4 address of an interface, or None if it has none."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
try:
|
||||
packed = fcntl.ioctl(s.fileno(), SIOCGIFADDR, struct.pack("256s", ifname[:15].encode()))
|
||||
except OSError:
|
||||
return None
|
||||
return socket.inet_ntoa(packed[20:24])
|
||||
|
||||
|
||||
def default_route_iface(route_file: str = "/proc/net/route") -> str | None:
|
||||
"""Interface of the main routing table's default route (lowest metric)."""
|
||||
best = None
|
||||
try:
|
||||
with open(route_file) as f:
|
||||
next(f) # header
|
||||
for line in f:
|
||||
cols = line.split()
|
||||
if len(cols) < 7:
|
||||
continue
|
||||
iface, dest, flags, metric = cols[0], cols[1], int(cols[3], 16), int(cols[6])
|
||||
if dest == "00000000" and flags & 0x1: # default route, RTF_UP
|
||||
if best is None or metric < best[1]:
|
||||
best = (iface, metric)
|
||||
except OSError:
|
||||
return None
|
||||
return best[0] if best else None
|
||||
|
||||
|
||||
def detect(sys_net: str = "/sys/class/net", route_file: str = "/proc/net/route", ipv4=ipv4_of) -> dict:
|
||||
"""Pick the physical LAN address. Pure apart from the injected lookups (testable)."""
|
||||
try:
|
||||
names = sorted(os.listdir(sys_net))
|
||||
except OSError:
|
||||
names = []
|
||||
candidates = []
|
||||
for name in names:
|
||||
if not os.path.exists(os.path.join(sys_net, name, "device")):
|
||||
continue # tunnel, bridge, veth, loopback
|
||||
addr = ipv4(name)
|
||||
if addr and not addr.startswith("169.254."):
|
||||
candidates.append({"iface": name, "ip": addr})
|
||||
|
||||
default_if = default_route_iface(route_file)
|
||||
chosen = next((c for c in candidates if c["iface"] == default_if), None)
|
||||
if chosen is None and candidates:
|
||||
chosen = candidates[0]
|
||||
return {
|
||||
"ip": chosen["ip"] if chosen else None,
|
||||
"iface": chosen["iface"] if chosen else None,
|
||||
"candidates": candidates,
|
||||
"default_iface": default_if,
|
||||
}
|
||||
|
||||
|
||||
def docker_desktop() -> bool:
|
||||
"""Docker Desktop's VM kernel identifies itself; its 'host' network isn't the LAN."""
|
||||
release = platform.release().lower()
|
||||
return "linuxkit" in release or "microsoft" in release
|
||||
|
||||
|
||||
def write_atomic(path: str, data: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def run_once() -> dict:
|
||||
if docker_desktop():
|
||||
result = {"ip": None, "iface": None, "candidates": [], "default_iface": None,
|
||||
"unsupported": "docker-desktop"}
|
||||
else:
|
||||
result = detect()
|
||||
result["detected_at"] = datetime.now(timezone.utc).isoformat()
|
||||
write_atomic(OUT_FILE, result)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
last_ip = object()
|
||||
while True:
|
||||
try:
|
||||
result = run_once()
|
||||
if result["ip"] != last_ip:
|
||||
print(f"netinfo: LAN IP {result['ip']} via {result['iface']} "
|
||||
f"(candidates: {result['candidates']}{', ' + result['unsupported'] if result.get('unsupported') else ''})",
|
||||
flush=True)
|
||||
last_ip = result["ip"]
|
||||
except Exception as e: # never die — the backend treats a stale file as "unknown"
|
||||
print(f"netinfo: detection failed: {e}", flush=True)
|
||||
time.sleep(INTERVAL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user