Files

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)