wrolpi/controller/lib/captive_portal.py
lrnselfreliance d701846254
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Hotspot captive portal: welcome page with the WROLPi's address (#709)
* Hotspot captive portal: welcome page with the WROLPi's address

Devices that join the hotspot probe a fixed plain-HTTP URL to check for
Internet access.  NetworkManager's shared-mode dnsmasq now resolves those
probe hostnames (and `wrolpi`) to the hotspot address via an include the
Controller writes when the hotspot starts, the probes land on the Controller
(port 80), and are redirected to /portal: "This is your WROLPi, its address is
10.42.0.1, open WROLPi / open the Controller".

Only the probe hostnames are hijacked so the hotspot keeps forwarding
everything else when the WROLPi has an uplink.  The include is corrected and
the connection re-activated once if NetworkManager picked a non-default
subnet.  Writing the include is best-effort; the hotspot starts regardless.

`hotspot.captive_portal` (controller.yaml, default true) toggles the probe
redirects and the include; like the other hotspot settings it applies on the
next hotspot start, so saving while the hotspot is up reports
restart_required and both Controller UIs say so.  The status endpoint gains
the hotspot address and portal URL, shown in both UIs.  repair.sh only
creates the include directory so a disabled setting survives upgrades.
Native only: Docker has no hotspot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: address review

* React: re-read the hotspot address whenever the HotspotToggle starts or
  stops the hotspot, matching the fallback UI's refresh-on-status.
* A WPA3 start that reverts to WPA2 leaves the hotspot up, so it now gets
  the captive portal address correction too.
* The corrective `nmcli connection up` logs a warning on a non-zero exit;
  dnsmasq would otherwise keep the old address silently.
* HotspotStatusResponse.captive_portal defaults to true like everywhere else.
* Trim design narrative from the module docstring and React comments.
* The include-before-activate test now observes the include at the moment
  of the `nmcli ... hotspot` call instead of inferring from call order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: never forward any record type for probe hosts

`address=` only synthesizes A/AAAA answers.  iOS also asks for the HTTPS
record type; dnsmasq forwarded that upstream, Apple's answer aliased to
captive.g.aaplimg.com, and the phone fetched the genuine "Success" from
Apple instead of the portal.  Each probe host now also gets `local=`, so
dnsmasq answers every type itself, and the CDN alias is a probe host too.

Observed with dnsmasq query logging on a hotspot with an eth0 uplink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: let acknowledged clients finish so the OS offers "Done"

A phone's sign-in sheet only shows "Done" once the OS re-probes and gets
the genuine success reply.  The Controller now remembers each client (by
hotspot IP) that has loaded the portal page and answers its later probes
with the reply each OS expects (Apple "Success" HTML, Android 204, Windows
"Microsoft Connect Test", ...).  The set is cleared when the hotspot
starts so a new session shows the page again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: acknowledge on Continue, not on view

iOS fetches the portal page in the background before opening the sheet,
then re-probes after the sheet's own navigation.  Acknowledging on view
made that re-probe return "Success" as the sheet's content.  Phones only
re-check the network (and offer "Done") after a full-page navigation, so
the welcome page now has a Continue button leading to /portal/continue,
which is the only place a client is acknowledged.  The Continue page says
to tap Done and links to WROLPi and the Controller.

Also hijack clients3.google.com (older Android probe host).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* help: bump wrolpi-help to the captive portal and Speed Test docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: comments describe the Continue design; clear acks on stop

Three comments still described acknowledging on view, which c72a7f7b
removed.  Acknowledged clients are now also cleared when the hotspot
stops, so a lease reused in a later session sees the portal page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 20:10:02 -06:00

157 lines
6.6 KiB
Python

"""
Hotspot captive portal: the dnsmasq include which points OS connectivity-probe hostnames at this
WROLPi, so devices that join the hotspot are shown its address. Native only (Docker has no hotspot).
"""
import json
import logging
import subprocess
from pathlib import Path
from typing import Optional
from controller.lib.config import get_config_value, is_docker_mode
logger = logging.getLogger(__name__)
# Hostnames the major operating systems probe for Internet access. Only these are hijacked: a
# wildcard would break the hotspot's forwarding of everything else to the WROLPi's uplink.
PROBE_HOSTS = (
'captive.apple.com', # iOS / macOS
'captive.g.aaplimg.com', # the CDN name captive.apple.com aliases to
'connectivitycheck.gstatic.com', # Android
'connectivitycheck.android.com', # Android (older)
'clients3.google.com', # Android (older)
'www.msftconnecttest.com', # Windows 10+
'www.msftncsi.com', # Windows 7/8
'detectportal.firefox.com', # Firefox
'connectivity-check.ubuntu.com', # Ubuntu / NetworkManager (probes `/`, served by the dashboard)
'nmcheck.gnome.org', # GNOME
)
# Paths those probes request, and the reply each OS takes as "online". The Controller redirects a
# probe to the portal until the client taps Continue, then returns the expected reply so the OS
# shows its "Done" button and keeps using the hotspot.
# `/` is not listed: the dashboard already answers it with HTML, which a probe treats as a portal.
PROBE_SUCCESS_RESPONSES = {
'/hotspot-detect.html': (200, 'text/html', # Apple
'<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>'),
'/generate_204': (204, 'text/plain', ''), # Android
'/connecttest.txt': (200, 'text/plain', 'Microsoft Connect Test'), # Windows 10+
'/ncsi.txt': (200, 'text/plain', 'Microsoft NCSI'), # Windows 7/8
'/success.txt': (200, 'text/plain', 'success\n'), # Firefox
'/canonical.html': (200, 'text/html', # Firefox
'<meta http-equiv="refresh" content="0;url=https://support.mozilla.org/kb/captive-portal"/>'),
'/check_network_status.txt': (200, 'text/plain', 'NetworkManager is online\n'), # GNOME
}
PROBE_PATHS = tuple(PROBE_SUCCESS_RESPONSES)
# `http://wrolpi/` also reaches the WROLPi from a hotspot client.
FRIENDLY_NAME = 'wrolpi'
# NetworkManager starts its shared-mode dnsmasq with `--conf-dir=` pointing here and reads it
# only when the Hotspot connection is activated.
DNSMASQ_SHARED_DIR = Path('/etc/NetworkManager/dnsmasq-shared.d')
INCLUDE_FILE = DNSMASQ_SHARED_DIR / '50-wrolpi-captive-portal.conf'
# NetworkManager's default shared-mode subnet. The real address is read once the hotspot is up.
DEFAULT_HOTSPOT_IP = '10.42.0.1'
PORTAL_PATH = '/portal'
# Where Continue leads. Phones only re-probe (and offer "Done") after a full-page navigation.
PORTAL_CONTINUE_PATH = '/portal/continue'
def is_captive_portal_enabled() -> bool:
"""The maintainer's `hotspot.captive_portal` setting in controller.yaml. Never true in Docker."""
if is_docker_mode():
return False
return bool(get_config_value('hotspot.captive_portal', True))
def get_hotspot_ip(device: str) -> Optional[str]:
"""
The IPv4 address the hotspot device currently has, or None when it has none (hotspot down),
the device is missing, or `ip` is unavailable (Docker, macOS).
"""
if is_docker_mode() or not device:
return None
try:
result = subprocess.run(
['ip', '-4', '-j', 'addr', 'show', 'dev', device],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return None
for interface in json.loads(result.stdout or '[]'):
for addr in interface.get('addr_info') or []:
if addr.get('family') == 'inet' and addr.get('local'):
return addr['local']
except (subprocess.SubprocessError, FileNotFoundError, ValueError) as e:
logger.debug('Could not read hotspot IP of %s: %s', device, e)
return None
def render_dnsmasq_config(ip: str) -> str:
"""The dnsmasq include which points the probe hostnames and the friendly name at the hotspot."""
lines = [
'# Managed by the WROLPi Controller; changes are overwritten when the hotspot starts.',
'# Captive portal: captive-portal probe hostnames resolve to this WROLPi.',
'# Only these names are hijacked so the hotspot still forwards everything else.',
]
for host in (*PROBE_HOSTS, FRIENDLY_NAME):
# `address=` only answers A/AAAA. Phones also ask for the HTTPS record type, and a
# forwarded answer leads them to the real host; `local=` never forwards any type.
lines.append(f'local=/{host}/')
lines.append(f'address=/{host}/{ip}')
return '\n'.join(lines) + '\n'
def apply_dnsmasq_config(ip: str = DEFAULT_HOTSPOT_IP) -> bool:
"""
Write the dnsmasq include when the captive portal is enabled, remove it when disabled.
Best-effort: a failure is logged and swallowed, because a working hotspot matters more than
the portal. Returns True when the include file now matches the setting.
"""
try:
if is_captive_portal_enabled():
INCLUDE_FILE.parent.mkdir(parents=True, exist_ok=True)
INCLUDE_FILE.write_text(render_dnsmasq_config(ip))
INCLUDE_FILE.chmod(0o644)
logger.info('Captive portal dnsmasq config written for %s', ip)
elif INCLUDE_FILE.exists():
INCLUDE_FILE.unlink()
logger.info('Captive portal disabled; dnsmasq config removed')
return True
except OSError as e:
logger.warning('Could not update captive portal dnsmasq config %s: %s', INCLUDE_FILE, e)
return False
def dnsmasq_config_ip() -> Optional[str]:
"""The IP the current include file points at, or None when there is no include file."""
try:
for line in INCLUDE_FILE.read_text().splitlines():
if line.startswith('address=/'):
return line.rsplit('/', 1)[-1].strip() or None
except OSError:
pass
return None
# Clients (by hotspot IP) which tapped Continue on the portal page. Their later probes get the
# success reply. Cleared when the hotspot starts or stops so a new session shows the page again.
_acknowledged_clients: set[str] = set()
def acknowledge_client(ip: str):
"""The client at `ip` tapped Continue on the portal page; its probes now say "online"."""
if ip:
_acknowledged_clients.add(ip)
def is_client_acknowledged(ip: str) -> bool:
return ip in _acknowledged_clients
def reset_acknowledged_clients():
_acknowledged_clients.clear()