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,91 @@
|
||||
"""
|
||||
The server's LAN address — the one phones use (http://<ip>), the pairing QR
|
||||
encodes and the cloud heartbeat reports. One resolver, used everywhere.
|
||||
|
||||
Priority:
|
||||
1. override — set once in the manager (pos_settings 'network.lan_ip_override')
|
||||
2. detected — live, from the netinfo helper container (services/netinfo_helper.py)
|
||||
3. env — HOST_IP from .env (written by install.sh)
|
||||
→ None: the manager falls back to the address it was opened with.
|
||||
|
||||
When the address in use (override / env) differs from what the helper sees
|
||||
right now, `mismatch` is set so the manager can warn before phones break
|
||||
(typical cause: the router's DHCP handed the server a new address).
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
OVERRIDE_KEY = "network.lan_ip_override"
|
||||
NETINFO_FILE = os.environ.get("NETINFO_FILE", "/netinfo/host_ip.json")
|
||||
DETECTION_MAX_AGE_SECONDS = 300 # helper writes every 60s; older means it stopped
|
||||
|
||||
|
||||
def validate_lan_ip(value: str) -> str:
|
||||
"""Normalise and check an override: a private, non-loopback IPv4 address."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(value.strip())
|
||||
except ValueError:
|
||||
raise ValueError("Μη έγκυρη διεύθυνση IP (π.χ. 192.168.1.50)")
|
||||
if addr.version != 4 or not addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
raise ValueError("Η διεύθυνση πρέπει να είναι IPv4 τοπικού δικτύου (π.χ. 192.168.x.x ή 10.x.x.x)")
|
||||
return str(addr)
|
||||
|
||||
|
||||
def read_detected(path: str = NETINFO_FILE, now: datetime | None = None) -> dict | None:
|
||||
"""Latest helper result, or None if the helper isn't running / file is stale."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
detected_at = datetime.fromisoformat(data["detected_at"])
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
now = now or datetime.now(timezone.utc)
|
||||
data["age_seconds"] = int((now - detected_at).total_seconds())
|
||||
data["stale"] = data["age_seconds"] > DETECTION_MAX_AGE_SECONDS
|
||||
return data
|
||||
|
||||
|
||||
def resolve_lan_ip(db=None, override: str | None = None, detected: dict | None = None,
|
||||
env: str | None = None) -> dict:
|
||||
"""Effective LAN IP + where it came from. Pass db to read the override setting;
|
||||
the explicit arguments exist for tests."""
|
||||
if db is not None and override is None:
|
||||
from models.settings import PosSettings
|
||||
row = db.query(PosSettings).filter(PosSettings.key == OVERRIDE_KEY).first()
|
||||
override = row.value.strip() if row and row.value and row.value.strip() else None
|
||||
if detected is None:
|
||||
detected = read_detected()
|
||||
if env is None:
|
||||
env = os.environ.get("HOST_IP", "").strip() or None
|
||||
|
||||
live = detected if detected and not detected.get("stale") and detected.get("ip") else None
|
||||
live_ip = live["ip"] if live else None
|
||||
live_all = {c["ip"] for c in (live or {}).get("candidates", [])} | ({live_ip} if live_ip else set())
|
||||
|
||||
if override:
|
||||
effective, source = override, "override"
|
||||
elif live_ip:
|
||||
effective, source = live_ip, "detected"
|
||||
elif env:
|
||||
effective, source = env, "env"
|
||||
else:
|
||||
effective, source = None, None
|
||||
|
||||
return {
|
||||
"effective": effective,
|
||||
"source": source, # override | detected | env | None
|
||||
"override": override,
|
||||
"detected": live_ip,
|
||||
"detected_candidates": (live or {}).get("candidates", []),
|
||||
"detection": (
|
||||
"unsupported" if detected and detected.get("unsupported")
|
||||
else "stale" if detected and detected.get("stale")
|
||||
else "ok" if live_ip
|
||||
else "unavailable"
|
||||
),
|
||||
"env": env,
|
||||
# Using a fixed address that this machine no longer has on any NIC
|
||||
"mismatch": bool(effective and source in ("override", "env") and live_ip and effective not in live_all),
|
||||
}
|
||||
Reference in New Issue
Block a user