wrolpi/controller/test/test_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

264 lines
11 KiB
Python

"""
Tests for the hotspot captive portal: dnsmasq include, landing page, and probe redirects.
"""
import json
import subprocess
from unittest import mock
import pytest
from controller.lib import captive_portal
from controller.lib.captive_portal import (
DEFAULT_HOTSPOT_IP,
FRIENDLY_NAME,
PROBE_HOSTS,
PORTAL_CONTINUE_PATH,
PROBE_PATHS,
PROBE_SUCCESS_RESPONSES,
acknowledge_client,
apply_dnsmasq_config,
is_client_acknowledged,
reset_acknowledged_clients,
dnsmasq_config_ip,
get_hotspot_ip,
is_captive_portal_enabled,
render_dnsmasq_config,
)
IP_JSON = json.dumps([{
"ifname": "wlan0",
"addr_info": [{"family": "inet", "local": "10.42.0.1", "prefixlen": 24}],
}])
@pytest.fixture
def native_mode():
with mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=False):
yield
class TestRenderDnsmasqConfig:
def test_every_probe_host_and_friendly_name(self):
"""Each probe hostname and `wrolpi` resolve to the given IP."""
config = render_dnsmasq_config('10.42.0.1')
for host in PROBE_HOSTS:
assert f'address=/{host}/10.42.0.1' in config
assert f'address=/{FRIENDLY_NAME}/10.42.0.1' in config
def test_probe_hosts_are_never_forwarded(self):
"""iOS asks for the HTTPS record type too; a forwarded answer leads it to the real Apple."""
config = render_dnsmasq_config('10.42.0.1')
for host in (*PROBE_HOSTS, FRIENDLY_NAME):
assert f'local=/{host}/' in config
def test_apple_cdn_alias_is_a_probe_host(self):
assert 'captive.g.aaplimg.com' in PROBE_HOSTS
def test_no_wildcard(self):
"""A wildcard would break the hotspot's Internet passthrough."""
assert '/#/' not in render_dnsmasq_config('10.42.0.1')
def test_uses_given_ip(self):
config = render_dnsmasq_config('10.42.1.1')
assert '10.42.1.1' in config
assert '10.42.0.1' not in config
class TestApplyDnsmasqConfig:
def test_enabled_writes_file(self, native_mode, reset_runtime_config, tmp_path):
include = tmp_path / 'shared.d' / 'portal.conf'
with mock.patch.object(captive_portal, 'INCLUDE_FILE', include):
assert apply_dnsmasq_config('10.42.0.1') is True
assert include.read_text() == render_dnsmasq_config('10.42.0.1')
assert include.stat().st_mode & 0o777 == 0o644
assert dnsmasq_config_ip() == '10.42.0.1'
def test_disabled_removes_stale_file(self, native_mode, reset_runtime_config, tmp_path):
include = tmp_path / 'portal.conf'
include.write_text('address=/captive.apple.com/10.42.0.1\n')
with mock.patch.object(captive_portal, 'INCLUDE_FILE', include), \
mock.patch("controller.lib.captive_portal.get_config_value", return_value=False):
assert apply_dnsmasq_config('10.42.0.1') is True
assert not include.exists()
assert dnsmasq_config_ip() is None
def test_disabled_without_file_is_fine(self, native_mode, reset_runtime_config, tmp_path):
include = tmp_path / 'portal.conf'
with mock.patch.object(captive_portal, 'INCLUDE_FILE', include), \
mock.patch("controller.lib.captive_portal.get_config_value", return_value=False):
assert apply_dnsmasq_config() is True
def test_write_failure_is_best_effort(self, native_mode, reset_runtime_config, tmp_path, caplog):
"""A read-only /etc must not stop the hotspot from starting."""
include = tmp_path / 'portal.conf'
with mock.patch.object(captive_portal, 'INCLUDE_FILE', include), \
mock.patch("pathlib.Path.write_text", side_effect=PermissionError("read-only")):
assert apply_dnsmasq_config('10.42.0.1') is False
assert 'Could not update captive portal' in caplog.text
def test_docker_never_writes(self, reset_runtime_config, tmp_path):
include = tmp_path / 'portal.conf'
with mock.patch.object(captive_portal, 'INCLUDE_FILE', include), \
mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=True):
assert is_captive_portal_enabled() is False
apply_dnsmasq_config('10.42.0.1')
assert not include.exists()
class TestIsCaptivePortalEnabled:
def test_default_true(self, native_mode, reset_runtime_config):
assert is_captive_portal_enabled() is True
def test_setting_false(self, native_mode, reset_runtime_config):
with mock.patch("controller.lib.captive_portal.get_config_value", return_value=False):
assert is_captive_portal_enabled() is False
class TestGetHotspotIp:
def test_parses_ip_json(self, native_mode):
result = mock.Mock(returncode=0, stdout=IP_JSON)
with mock.patch("subprocess.run", return_value=result) as run:
assert get_hotspot_ip('wlan0') == '10.42.0.1'
assert run.call_args[0][0] == ['ip', '-4', '-j', 'addr', 'show', 'dev', 'wlan0']
def test_no_address_when_hotspot_down(self, native_mode):
result = mock.Mock(returncode=0, stdout=json.dumps([{"ifname": "wlan0", "addr_info": []}]))
with mock.patch("subprocess.run", return_value=result):
assert get_hotspot_ip('wlan0') is None
def test_missing_device(self, native_mode):
result = mock.Mock(returncode=1, stdout='')
with mock.patch("subprocess.run", return_value=result):
assert get_hotspot_ip('wlan9') is None
@pytest.mark.parametrize("error", [FileNotFoundError(), subprocess.TimeoutExpired('ip', 5)])
def test_ip_unavailable(self, native_mode, error):
with mock.patch("subprocess.run", side_effect=error):
assert get_hotspot_ip('wlan0') is None
def test_docker(self):
with mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=True):
assert get_hotspot_ip('wlan0') is None
class TestProbeRedirects:
"""OS connectivity probes are redirected to the portal when the feature is on."""
@pytest.mark.parametrize("path", PROBE_PATHS)
def test_probe_redirects_when_enabled(self, test_client, path):
response = test_client.get(path, follow_redirects=False)
assert response.status_code == 302
assert response.headers['location'] == '/portal'
assert response.headers['cache-control'] == 'no-store'
def test_head_probe_redirects(self, test_client):
response = test_client.head('/hotspot-detect.html', follow_redirects=False)
assert response.status_code == 302
def test_probe_404_when_disabled(self, test_client):
with mock.patch("controller.api.captive_portal.is_captive_portal_enabled", return_value=False):
response = test_client.get('/generate_204', follow_redirects=False)
assert response.status_code == 404
def test_probe_404_in_docker(self, test_client_docker_mode):
response = test_client_docker_mode.get('/hotspot-detect.html', follow_redirects=False)
assert response.status_code == 404
class TestAcknowledgedClients:
"""Once a client taps Continue, its probes say "online" so the OS offers Done."""
def test_viewing_the_portal_does_not_acknowledge(self, test_client):
"""iOS fetches the page in the background before showing it; only a tap counts."""
with mock.patch("controller.api.captive_portal.get_hotspot_ip", return_value='10.42.0.1'):
assert test_client.get('/portal').status_code == 200
assert not is_client_acknowledged('testclient')
assert test_client.get('/hotspot-detect.html', follow_redirects=False).status_code == 302
def test_continue_page(self, test_client):
with mock.patch("controller.api.captive_portal.get_hotspot_ip", return_value='10.42.0.1'):
response = test_client.get(PORTAL_CONTINUE_PATH)
assert response.status_code == 200
assert response.headers['cache-control'] == 'no-store'
assert 'Done' in response.text
assert '10.42.0.1' in response.text
assert 'https://10.42.0.1/' in response.text
assert is_client_acknowledged('testclient')
def test_continue_404_when_hotspot_down(self, test_client):
with mock.patch("controller.api.captive_portal.get_hotspot_ip", return_value=None):
assert test_client.get(PORTAL_CONTINUE_PATH).status_code == 404
assert not is_client_acknowledged('testclient')
def test_continue_404_in_docker(self, test_client_docker_mode):
assert test_client_docker_mode.get(PORTAL_CONTINUE_PATH).status_code == 404
@pytest.mark.parametrize("path", PROBE_PATHS)
def test_probe_succeeds_after_continue(self, test_client, path):
with mock.patch("controller.api.captive_portal.get_hotspot_ip", return_value='10.42.0.1'):
assert test_client.get(PORTAL_CONTINUE_PATH).status_code == 200
response = test_client.get(path, follow_redirects=False)
status, media_type, body = PROBE_SUCCESS_RESPONSES[path]
assert response.status_code == status
assert response.text == body
assert response.headers['cache-control'] == 'no-store'
if body:
assert response.headers['content-type'].startswith(media_type)
def test_apple_probe_says_success(self, test_client):
"""iOS looks for this exact body before it shows the Done button."""
acknowledge_client('testclient')
response = test_client.get('/hotspot-detect.html')
assert response.status_code == 200
assert '<BODY>Success</BODY>' in response.text
def test_other_clients_still_redirected(self, test_client):
acknowledge_client('10.42.0.99')
assert not is_client_acknowledged('testclient')
assert test_client.get('/generate_204', follow_redirects=False).status_code == 302
def test_reset_shows_portal_again(self, test_client):
acknowledge_client('testclient')
reset_acknowledged_clients()
assert test_client.get('/generate_204', follow_redirects=False).status_code == 302
def test_acknowledge_ignores_missing_ip(self):
acknowledge_client(None)
assert not is_client_acknowledged(None)
class TestPortalPage:
def test_shows_address_and_links(self, test_client):
with mock.patch("controller.api.captive_portal.get_hotspot_ip", return_value='10.42.0.1'):
response = test_client.get('/portal')
assert response.status_code == 200
assert response.headers['cache-control'] == 'no-store'
body = response.text
assert '10.42.0.1' in body
assert f'href="{PORTAL_CONTINUE_PATH}"' in body
assert 'WROLPi' in body # the SSID
def test_404_when_hotspot_down(self, test_client):
"""A page with no address is worse than no page."""
with mock.patch("controller.api.captive_portal.get_hotspot_ip", return_value=None):
assert test_client.get('/portal').status_code == 404
def test_404_in_docker(self, test_client_docker_mode):
assert test_client_docker_mode.get('/portal').status_code == 404
def test_served_even_when_probes_disabled(self, test_client):
"""The setting controls the automatic sheet, not the page itself."""
with mock.patch("controller.api.captive_portal.get_hotspot_ip", return_value='10.42.0.1'), \
mock.patch("controller.api.captive_portal.is_captive_portal_enabled", return_value=False):
assert test_client.get('/portal').status_code == 200
class TestDefaultIp:
def test_default_is_networkmanager_shared_subnet(self):
assert DEFAULT_HOTSPOT_IP == '10.42.0.1'