89 lines
3.2 KiB
Python
89 lines
3.2 KiB
Python
"""LAN address discovery for the "scan to connect" QR flow.
|
|
|
|
The QR encodes a deep link to the hosted PWA:
|
|
http(s)://<address>/app#invite=<invite-code>
|
|
so a phone's native camera app can scan it and open the login page
|
|
with server URL + invite code already filled in.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
import urllib.parse
|
|
|
|
|
|
def primary_lan_ip() -> str | None:
|
|
"""IP of the interface the default route goes through, without sending traffic."""
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(("10.255.255.255", 1)) # unroutable probe: no packets leave the host
|
|
ip = s.getsockname()[0]
|
|
finally:
|
|
s.close()
|
|
return None if ip.startswith("127.") else ip
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _lan_rank(ip: str) -> int:
|
|
"""Lower = more likely to be the address phones on the office LAN can reach."""
|
|
if ip.startswith("192.168."):
|
|
return 0
|
|
if ip.startswith("10."):
|
|
return 1
|
|
parts = ip.split(".")
|
|
if parts[0] == "172" and parts[1].isdigit() and 16 <= int(parts[1]) <= 31:
|
|
return 2
|
|
return 3 # CGNAT/VPN/odd ranges — last resort
|
|
|
|
|
|
def lan_ip_candidates() -> list[str]:
|
|
"""All plausible LAN IPv4 addresses, best first, de-duplicated."""
|
|
ips: list[str] = []
|
|
primary = primary_lan_ip()
|
|
if primary:
|
|
ips.append(primary)
|
|
try:
|
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
|
ip = info[4][0]
|
|
if not ip.startswith("127.") and ip not in ips:
|
|
ips.append(ip)
|
|
except OSError:
|
|
pass
|
|
return sorted(ips, key=_lan_rank)
|
|
|
|
|
|
def host_port(host_header: str | None, scheme: str) -> tuple[str | None, int | None]:
|
|
"""Split the request Host header into (hostname, port). None when absent."""
|
|
if not host_header:
|
|
return None, None
|
|
try:
|
|
split = urllib.parse.urlsplit("//" + host_header)
|
|
port = split.port
|
|
except ValueError: # malformed host header
|
|
return None, None
|
|
if port is None:
|
|
port = 443 if scheme == "https" else None
|
|
return (split.hostname or None), port
|
|
|
|
|
|
def build_base_urls(request) -> list[str]:
|
|
"""Addresses phones in the team network can reach, best guess first.
|
|
|
|
- Behind TLS (Caddy/https): the request's own Host is the public address
|
|
phones should use, as-is.
|
|
- Plain http (LAN mode): the browser's Host is often localhost, so swap in
|
|
detected LAN IPs while keeping the request's port.
|
|
The /app deep-link fragment (invite code) is appended by the QR endpoint.
|
|
"""
|
|
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
|
host, port = host_port(request.headers.get("host"), scheme)
|
|
if scheme == "https" and host:
|
|
return [f"https://{host}/app"]
|
|
urls = []
|
|
for ip in lan_ip_candidates():
|
|
urls.append(f"http://{ip}:{port}/app" if port else f"http://{ip}/app")
|
|
if host and host not in ("localhost", "127.0.0.1") and not host.startswith("127."):
|
|
urls.append(f"http://{host}:{port}/app" if port else f"http://{host}/app")
|
|
return list(dict.fromkeys(urls)) # dedupe, keep order (host may equal a probed IP)
|