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>
135 lines
4.9 KiB
Python
135 lines
4.9 KiB
Python
"""
|
|
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()
|