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:
2026-09-28 16:13:17 +03:00
co-authored by Claude Opus 5.5
parent 034106918d
commit 76aac203d6
5 changed files with 281 additions and 18 deletions
+31 -8
View File
@@ -1,11 +1,11 @@
import asyncio
import ipaddress
import json
import os
import socket
import time
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import List
@@ -19,6 +19,7 @@ from models.product import Category, Product
from models.table import Table, TableGroup
from services import printer_service
from services.cloud_sync import _sync_once, _push_menu_snapshot
from services.lan_ip import OVERRIDE_KEY, resolve_lan_ip, validate_lan_ip
from middleware.license_check import license_state
from config import settings
@@ -37,12 +38,6 @@ def health():
API_VERSION = 1
def _lan_ip() -> str | None:
"""The address phones should use. Only HOST_IP is trusted: inside Docker any
auto-detection returns the container's bridge IP, which phones can't reach."""
return os.environ.get("HOST_IP", "").strip() or None
@router.get("/identity")
def identity(db: Session = Depends(get_db)):
"""Public, unauthenticated. Lets a phone confirm which venue a server is
@@ -67,6 +62,8 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
reachable = printer_service.check_printer(p.ip_address, p.port)
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
lan = resolve_lan_ip(db)
licensed = license_state.get("licensed", True)
locked = license_state.get("locked", False)
lock_pending = license_state.get("lock_pending", False)
@@ -111,11 +108,37 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
"last_sync": license_state.get("last_sync"),
"waiter_domain": license_state.get("waiter_domain"),
"site_id": settings.SITE_ID or None,
"lan_ip": _lan_ip(),
"lan_ip": lan.get("effective"),
"lan_ip_info": lan,
"printers": printer_statuses,
}
class LanIpOverride(BaseModel):
ip: str | None = None # null / empty → clear the override (back to automatic)
@router.put("/lan-ip-override")
def set_lan_ip_override(body: LanIpOverride, db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Pin the server's LAN IP (what phones and the pairing QR use). Clearing it
returns to automatic detection / HOST_IP."""
row = db.query(PosSettings).filter(PosSettings.key == OVERRIDE_KEY).first()
if body.ip and body.ip.strip():
try:
value = validate_lan_ip(body.ip)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if row:
row.value = value
row.updated_by_id = user.id or None
else:
db.add(PosSettings(key=OVERRIDE_KEY, value=value, updated_by_id=user.id or None))
elif row:
db.delete(row)
db.commit()
return resolve_lan_ip(db)
@router.post("/sync-license")
async def sync_license_now(user: User = Depends(require_manager)):
"""Trigger an immediate cloud heartbeat and return the fresh license state."""