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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user