diff --git a/app/src/components/admin/ControllerPage.js b/app/src/components/admin/ControllerPage.js index 05d1284c..768a6ed0 100644 --- a/app/src/components/admin/ControllerPage.js +++ b/app/src/components/admin/ControllerPage.js @@ -19,7 +19,7 @@ import { toast, } from "../ui"; import {APIButton, BluetoothToggle, DesktopToggle, DirectorySearch, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle, VncToggle,} from "../Common"; -import {useDockerized, useMediaDirectory} from "../../hooks/customHooks"; +import {useDockerized, useHotspot, useMediaDirectory} from "../../hooks/customHooks"; import {Media} from "../../contexts/contexts"; import { addFstabEntry, @@ -31,6 +31,7 @@ import { getHotspotDevices, getHotspotProtocols, getHotspotSettings, + getHotspotStatus, getSambaStatus, getServiceLogs, getServices, @@ -1457,10 +1458,13 @@ function HotspotSettingsForm() { const dockerized = useDockerized(); const [devices, setDevices] = React.useState([]); const [protocols, setProtocols] = React.useState([]); - const [form, setForm] = React.useState({device: '', ssid: '', password: '', protocol: ''}); + const [form, setForm] = React.useState( + {device: '', ssid: '', password: '', protocol: '', captive_portal: true}); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [qrOpen, setQrOpen] = React.useState(false); + const [hotspotStatus, setHotspotStatus] = React.useState(null); + const {on: hotspotOn} = useHotspot(); React.useEffect(() => { const fetchSettings = async () => { @@ -1471,6 +1475,7 @@ function HotspotSettingsForm() { ssid: settings.ssid, password: settings.password, protocol: settings.protocol, + captive_portal: settings.captive_portal !== false, }); setDevices(devicesResponse.devices || []); } catch (e) { @@ -1482,6 +1487,28 @@ function HotspotSettingsForm() { fetchSettings(); }, []); + // Re-read the address whenever the sibling HotspotToggle starts or stops the hotspot. + React.useEffect(() => { + if (dockerized) { + return; + } + let cancelled = false; + const fetchStatus = async () => { + try { + const status = await getHotspotStatus(); + if (!cancelled) { + setHotspotStatus(status || null); + } + } catch (e) { + console.error('Failed to fetch hotspot status', e); + } + }; + fetchStatus(); + return () => { + cancelled = true; + }; + }, [dockerized, hotspotOn]); + // The supported protocols depend on the selected device's hardware. React.useEffect(() => { if (!form.device) { @@ -1519,8 +1546,20 @@ function HotspotSettingsForm() { setSaving(true); try { const settings = await updateHotspotSettings(form); - setForm(settings); - toast({type: 'success', title: 'Hotspot settings saved', time: 3000}); + setForm({ + device: settings.device, + ssid: settings.ssid, + password: settings.password, + protocol: settings.protocol, + captive_portal: settings.captive_portal !== false, + }); + // Settings apply when the hotspot starts (dnsmasq only reads its config then). + toast({ + type: 'success', + title: 'Hotspot settings saved', + description: settings.restart_required ? 'Restart the hotspot to apply the new settings.' : undefined, + time: settings.restart_required ? 6000 : 3000, + }); } catch (e) { toast({type: 'error', title: 'Failed to save hotspot settings', description: e.message, time: 5000}); } finally { @@ -1549,8 +1588,14 @@ function HotspotSettingsForm() { const escapeWifi = (s) => s.replace(/([\\;,"])/g, '\\$1'); const qrCodeValue = `WIFI:S:${escapeWifi(form.ssid)};T:WPA;P:${escapeWifi(form.password)};;`; + const address = hotspotStatus && hotspotStatus.enabled && hotspotStatus.ip; + return
Hotspot Settings
+ {address &&

+ Hotspot address: {address} + {hotspotStatus.portal_url ? <> · Welcome page: {hotspotStatus.portal_url} : ' · Captive portal off'} +

} setForm({...form, protocol: value})} /> + setForm({...form, captive_portal: e.currentTarget.checked})} + />
, @@ -152,16 +159,21 @@ describe('ControllerPage', () => { beforeEach(() => { jest.clearAllMocks(); // Reset useDockerized to false for regular tests - const {useDockerized} = require('../../hooks/customHooks'); + const {useDockerized, useHotspot} = require('../../hooks/customHooks'); useDockerized.mockReturnValue(false); + useHotspot.mockReturnValue({on: true, setHotspot: jest.fn()}); // resetMocks clears module-factory implementations, so configure hotspot mocks here. const controllerApi = require('../../api/controller'); controllerApi.getHotspotSettings.mockResolvedValue( - {device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa2'}); + {device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa2', captive_portal: true}); + controllerApi.getHotspotStatus.mockResolvedValue( + {enabled: true, available: true, ssid: 'WROLPi', device: 'wlan0', ip: '10.42.0.1', + captive_portal: true, portal_url: 'http://10.42.0.1/portal'}); controllerApi.getHotspotDevices.mockResolvedValue({devices: ['wlan0']}); controllerApi.getHotspotProtocols.mockResolvedValue({device: 'wlan0', protocols: ['wpa2', 'wpa3']}); controllerApi.updateHotspotSettings.mockResolvedValue( - {device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa2'}); + {device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa2', captive_portal: true, + restart_required: false}); }); test('renders Services section', async () => { @@ -235,6 +247,75 @@ describe('ControllerPage', () => { expect(screen.queryByText('WPA3')).not.toBeInTheDocument(); }); + test('captive portal checkbox reflects the saved setting and is sent on save', async () => { + const controllerApi = require('../../api/controller'); + controllerApi.getHotspotSettings.mockResolvedValue( + {device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa2', captive_portal: false}); + renderControllerPage(); + const checkbox = await screen.findByLabelText('Captive portal'); + expect(checkbox).not.toBeChecked(); + + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + await waitFor(() => { + expect(controllerApi.updateHotspotSettings).toHaveBeenCalledWith( + expect.objectContaining({captive_portal: true})); + }); + }); + + test('saving hotspot settings while the hotspot is up tells the user to restart it', async () => { + const controllerApi = require('../../api/controller'); + controllerApi.updateHotspotSettings.mockResolvedValue( + {device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa2', captive_portal: false, + restart_required: true}); + const {toast} = require('../ui'); + renderControllerPage(); + await screen.findByLabelText('Captive portal'); + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + await waitFor(() => { + expect(toast).toHaveBeenCalledWith(expect.objectContaining( + {description: 'Restart the hotspot to apply the new settings.'})); + }); + }); + + test('shows the hotspot address and welcome page while the hotspot is up', async () => { + renderControllerPage(); + expect(await screen.findByText('10.42.0.1')).toBeInTheDocument(); + expect(screen.getByText(/Welcome page: http:\/\/10\.42\.0\.1\/portal/)).toBeInTheDocument(); + }); + + test('hotspot address is re-read when the hotspot is toggled', async () => { + const controllerApi = require('../../api/controller'); + const {useHotspot} = require('../../hooks/customHooks'); + useHotspot.mockReturnValue({on: false, setHotspot: jest.fn()}); + controllerApi.getHotspotStatus.mockResolvedValue( + {enabled: false, available: true, device: 'wlan0', ip: null, captive_portal: true, portal_url: null}); + const {rerender} = renderControllerPage(); + await screen.findByLabelText('Captive portal'); + expect(controllerApi.getHotspotStatus).toHaveBeenCalledTimes(1); + expect(screen.queryByText('10.42.0.1')).not.toBeInTheDocument(); + + // The sibling HotspotToggle starts the hotspot. + useHotspot.mockReturnValue({on: true, setHotspot: jest.fn()}); + controllerApi.getHotspotStatus.mockResolvedValue( + {enabled: true, available: true, device: 'wlan0', ip: '10.42.0.1', captive_portal: true, + portal_url: 'http://10.42.0.1/portal'}); + rerender( + + + + + + + + + + ); + expect(await screen.findByText('10.42.0.1')).toBeInTheDocument(); + expect(controllerApi.getHotspotStatus).toHaveBeenCalledTimes(2); + }); + test('hotspot protocol dropdown only offers WPA2 on a Raspberry Pi', async () => { const {getHotspotProtocols} = require('../../api/controller'); getHotspotProtocols.mockResolvedValue({device: 'wlan0', protocols: ['wpa2']}); @@ -254,6 +335,7 @@ describe('DiskSection primary unmount', () => { const controllerApi = require('../../api/controller'); beforeEach(() => { + require('../../hooks/customHooks').useHotspot.mockReturnValue({on: false, setHotspot: jest.fn()}); controllerApi.getDisks.mockResolvedValue([ { name: 'sda1', @@ -332,8 +414,9 @@ describe('groupServices', () => { describe('ControllerPage in Docker mode', () => { beforeEach(() => { jest.clearAllMocks(); - const {useDockerized} = require('../../hooks/customHooks'); + const {useDockerized, useHotspot} = require('../../hooks/customHooks'); useDockerized.mockReturnValue(true); + useHotspot.mockReturnValue({on: false, setHotspot: jest.fn()}); }); test('hides reboot/shutdown buttons in Docker mode', async () => { diff --git a/controller/api/admin.py b/controller/api/admin.py index 2839df3f..cec52fb6 100644 --- a/controller/api/admin.py +++ b/controller/api/admin.py @@ -30,6 +30,7 @@ from controller.api.schemas import ( VncActionResponse, VncStatusResponse, ) +from controller.lib.captive_portal import is_captive_portal_enabled from controller.lib.admin import ( block_bluetooth, disable_throttle, @@ -112,6 +113,7 @@ async def hotspot_settings() -> HotspotSettingsResponse: ssid=get_hotspot_ssid(), password=get_hotspot_password(), protocol=get_hotspot_protocol(), + captive_portal=is_captive_portal_enabled(), ) @@ -122,11 +124,12 @@ async def hotspot_settings_update(request: HotspotSettingsRequest) -> HotspotSet if is_docker_mode(): raise HTTPException(status_code=500, detail="Not available in Docker mode") result = update_hotspot_settings(device=request.device, ssid=request.ssid, password=request.password, - protocol=request.protocol) + protocol=request.protocol, captive_portal=request.captive_portal) if not result.get("success"): raise HTTPException(status_code=400, detail=result.get("error", "Failed")) return HotspotSettingsResponse(device=result["device"], ssid=result["ssid"], password=result["password"], - protocol=result["protocol"]) + protocol=result["protocol"], captive_portal=result["captive_portal"], + restart_required=result.get("restart_required", False)) @router.post("/api/hotspot/start", response_model=HotspotActionResponse) diff --git a/controller/api/captive_portal.py b/controller/api/captive_portal.py new file mode 100644 index 00000000..ed0029ee --- /dev/null +++ b/controller/api/captive_portal.py @@ -0,0 +1,81 @@ +""" +Captive portal routes: the landing page hotspot clients see, and the OS connectivity probes +which are redirected to it. + +These live at bare paths (not under /api/) because phones request fixed URLs such as +`http://captive.apple.com/hotspot-detect.html`; the hotspot's DNS points those hostnames here. +""" +import logging +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse, Response +from fastapi.templating import Jinja2Templates + +from controller.lib.admin import get_hotspot_device, get_hotspot_ssid +from controller.lib.captive_portal import ( + PORTAL_CONTINUE_PATH, + PORTAL_PATH, + PROBE_PATHS, + PROBE_SUCCESS_RESPONSES, + acknowledge_client, + get_hotspot_ip, + is_captive_portal_enabled, + is_client_acknowledged, +) +from controller.lib.config import is_docker_mode + +logger = logging.getLogger(__name__) + +router = APIRouter() + +templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) + +# Phones must re-probe every time; a cached redirect would show a stale portal. +NO_STORE = {"Cache-Control": "no-store"} + + +def _render_portal(request: Request, connected: bool): + if is_docker_mode(): + raise HTTPException(status_code=404) + ip = get_hotspot_ip(get_hotspot_device()) + if not ip: + # Hotspot is down; a page with the wrong address is worse than none. + raise HTTPException(status_code=404) + context = {"ip": ip, "ssid": get_hotspot_ssid(), "connected": connected, + "continue_path": PORTAL_CONTINUE_PATH} + return templates.TemplateResponse(request, "portal.html", context, headers=NO_STORE) + + +@router.get(PORTAL_PATH, include_in_schema=False) +async def portal(request: Request): + """The welcome page: this WROLPi's address and a Continue button.""" + return _render_portal(request, connected=False) + + +@router.get(PORTAL_CONTINUE_PATH, include_in_schema=False) +async def portal_continue(request: Request): + """ + The page after Continue. The phone re-probes after this navigation; the client is now + acknowledged so that probe succeeds and the sheet offers "Done". + """ + response = _render_portal(request, connected=True) # 404s first if the hotspot is down + acknowledge_client(request.client.host if request.client else None) + return response + + +async def probe(request: Request): + """ + A connectivity probe. Anything but the expected reply makes the phone show the portal, so + redirect until this client has tapped Continue on the portal, then give the reply it expects. + """ + if not is_captive_portal_enabled(): + raise HTTPException(status_code=404) + if is_client_acknowledged(request.client.host if request.client else None): + status, media_type, body = PROBE_SUCCESS_RESPONSES[request.url.path] + return Response(content=body, status_code=status, media_type=media_type, headers=NO_STORE) + return RedirectResponse(url=PORTAL_PATH, status_code=302, headers=NO_STORE) + + +for _path in PROBE_PATHS: + router.add_api_route(_path, probe, methods=["GET", "HEAD"], include_in_schema=False) diff --git a/controller/api/schemas.py b/controller/api/schemas.py index c476b2c5..06c7c8cb 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -156,6 +156,9 @@ class HotspotStatusResponse(BaseModel): reason: Optional[str] = Field(default=None, description="Reason if unavailable") ssid: Optional[str] = Field(default=None, description="Hotspot SSID when enabled") device: Optional[str] = Field(default=None, description="WiFi device name") + ip: Optional[str] = Field(default=None, description="Hotspot IPv4 address when enabled") + captive_portal: bool = Field(default=True, description="Whether the captive portal is enabled") + portal_url: Optional[str] = Field(default=None, description="Landing page URL when the hotspot is up") class HotspotDevicesResponse(BaseModel): @@ -178,6 +181,8 @@ class HotspotSettingsRequest(BaseModel): ssid: Optional[str] = Field(default=None, description="Hotspot SSID") password: Optional[str] = Field(default=None, description="Hotspot password (8 to 63 characters)") protocol: Optional[str] = Field(default=None, description="Hotspot protocol (wpa2 or wpa3)") + captive_portal: Optional[bool] = Field(default=None, + description="Show a landing page to devices that join the hotspot") class HotspotSettingsResponse(BaseModel): @@ -187,6 +192,9 @@ class HotspotSettingsResponse(BaseModel): ssid: str = Field(description="Hotspot SSID") password: str = Field(description="Hotspot password") protocol: str = Field(description="Hotspot protocol (wpa2 or wpa3)") + captive_portal: bool = Field(default=True, description="Show a landing page to devices that join the hotspot") + restart_required: bool = Field(default=False, + description="A saved change only applies after the hotspot is restarted") class HotspotActionResponse(BaseModel): diff --git a/controller/conftest.py b/controller/conftest.py index 3685f316..c357188b 100644 --- a/controller/conftest.py +++ b/controller/conftest.py @@ -57,6 +57,16 @@ def _isolate_media_directory(tmp_path_factory, monkeypatch): monkeypatch.setenv("MEDIA_DIRECTORY", str(media_dir)) +@pytest.fixture(autouse=True) +def _isolate_dnsmasq_include(tmp_path_factory, monkeypatch): + """Point the captive-portal dnsmasq include at a temp file so tests never write /etc.""" + import controller.lib.captive_portal as captive_portal + include = tmp_path_factory.mktemp("dnsmasq") / "50-wrolpi-captive-portal.conf" + monkeypatch.setattr(captive_portal, "INCLUDE_FILE", include) + # Portal acknowledgements are process state; every test starts with none. + captive_portal.reset_acknowledged_clients() + + @pytest.fixture(autouse=True) def _patch_status_worker(): """Replace the real status worker with a no-op for every test. diff --git a/controller/defaults.py b/controller/defaults.py index 92d0f9cf..2e469170 100644 --- a/controller/defaults.py +++ b/controller/defaults.py @@ -122,6 +122,8 @@ DEFAULT_CONFIG = { "ssid": "WROLPi", "password": "wrolpi hotspot", "protocol": "wpa2", # wpa2 (wpa-psk) or wpa3 (sae); see get_device_hotspot_protocols + # Show a landing page (with this WROLPi's address) to devices that join the hotspot. + "captive_portal": True, }, "throttle": { diff --git a/controller/lib/admin.py b/controller/lib/admin.py index 16b9564a..6efc31ae 100644 --- a/controller/lib/admin.py +++ b/controller/lib/admin.py @@ -14,6 +14,7 @@ from pathlib import Path import yaml from controller.lib import config as config_lib +from controller.lib import captive_portal as captive_portal_lib from controller.lib.config import is_docker_mode, get_config_value, get_media_directory logger = logging.getLogger(__name__) @@ -162,10 +163,13 @@ def get_device_hotspot_protocols(device: str) -> list[str]: def update_hotspot_settings(device: str = None, ssid: str = None, password: str = None, - protocol: str = None) -> dict: + protocol: str = None, captive_portal: bool = None) -> dict: """ Update hotspot settings in controller.yaml. Only provided values are changed. + Settings are applied when the hotspot starts, so a change saved while the hotspot is up + reports `restart_required`. + Returns: dict with success status and the current settings """ @@ -195,6 +199,9 @@ def update_hotspot_settings(device: str = None, ssid: str = None, password: str if protocol: config_lib.update_config('hotspot.protocol', protocol) changed = True + if captive_portal is not None: + config_lib.update_config('hotspot.captive_portal', bool(captive_portal)) + changed = True if changed: try: @@ -202,14 +209,18 @@ def update_hotspot_settings(device: str = None, ssid: str = None, password: str except RuntimeError as e: return {"success": False, "error": str(e)} - logger.info("Hotspot settings updated (device=%s, ssid=%s, protocol=%s)", - get_hotspot_device(), get_hotspot_ssid(), get_hotspot_protocol()) + logger.info("Hotspot settings updated (device=%s, ssid=%s, protocol=%s, captive_portal=%s)", + get_hotspot_device(), get_hotspot_ssid(), get_hotspot_protocol(), + captive_portal_lib.is_captive_portal_enabled()) return { "success": True, "device": get_hotspot_device(), "ssid": get_hotspot_ssid(), "password": get_hotspot_password(), "protocol": get_hotspot_protocol(), + "captive_portal": captive_portal_lib.is_captive_portal_enabled(), + # dnsmasq only reads its include when the Hotspot connection activates. + "restart_required": changed and get_hotspot_status() == HotspotStatus.connected, } @@ -372,6 +383,9 @@ def get_hotspot_status_dict() -> dict: reason: Optional[str] - Reason if unavailable ssid: Optional[str] - Hotspot SSID when enabled device: Optional[str] - WiFi device name + ip: Optional[str] - Hotspot IPv4 address when enabled + captive_portal: bool - Whether the captive portal is enabled + portal_url: Optional[str] - Landing page URL when enabled """ device = get_hotspot_device() status = get_hotspot_status() @@ -388,12 +402,17 @@ def get_hotspot_status_dict() -> dict: elif status == HotspotStatus.in_use: reason = "WiFi device is connected to a network" + ip = captive_portal_lib.get_hotspot_ip(device) if enabled else None + portal_enabled = captive_portal_lib.is_captive_portal_enabled() return { "enabled": enabled, "available": available, "reason": reason, "ssid": get_hotspot_ssid() if enabled else None, "device": device, + "ip": ip, + "captive_portal": portal_enabled, + "portal_url": f"http://{ip}{captive_portal_lib.PORTAL_PATH}" if ip and portal_enabled else None, } @@ -447,6 +466,14 @@ def start_hotspot() -> dict: logger.warning("WiFi device %s did not become ready after turning radio on", device) return {"success": False, "error": f"WiFi device {device} not ready after turning radio on"} + # dnsmasq reads the captive portal include only when the connection activates, so it + # must be in place first. The address is not known until then; NetworkManager almost + # always picks the default, and the file is corrected below if it did not. + captive_portal_lib.apply_dnsmasq_config(captive_portal_lib.dnsmasq_config_ip() + or captive_portal_lib.DEFAULT_HOTSPOT_IP) + # A new hotspot session: every client sees the portal page again. + captive_portal_lib.reset_acknowledged_clients() + # Create hotspot using NetworkManager logger.info("Starting hotspot on %s with SSID %s", device, ssid) result = subprocess.run( @@ -505,6 +532,8 @@ def start_hotspot() -> dict: capture_output=True, text=True, timeout=30, ) if revert_modify.returncode == 0 and revert_up.returncode == 0: + # The hotspot is up (as WPA2), so it still needs its captive portal address. + _fix_captive_portal_ip(device) return {"success": False, "error": f"WPA3 is not supported by {device}; hotspot reverted to WPA2: {error}"} revert_error = (revert_modify.stderr if revert_modify.returncode != 0 else revert_up.stderr).strip() @@ -515,6 +544,8 @@ def start_hotspot() -> dict: # WPA2 is what nmcli created the hotspot with anyway; not fatal. logger.warning("Failed to explicitly set WPA2 on hotspot: %s", error) + _fix_captive_portal_ip(device) + logger.info("Hotspot started successfully on %s (%s)", device, protocol) return {"success": True, "ssid": ssid, "device": device} @@ -529,6 +560,30 @@ def start_hotspot() -> dict: return {"success": False, "error": str(e)} +def _fix_captive_portal_ip(device: str): + """ + Re-point the captive portal include at the address NetworkManager actually assigned. + + Rare: NetworkManager uses 10.42.1.1 (and so on) only when another shared connection + already holds 10.42.0.1. Re-activating the connection makes dnsmasq re-read the file. + """ + if not captive_portal_lib.is_captive_portal_enabled(): + return + ip = captive_portal_lib.get_hotspot_ip(device) + if not ip or ip == captive_portal_lib.dnsmasq_config_ip(): + return + logger.info("Hotspot address is %s, rewriting captive portal config and re-activating", ip) + if not captive_portal_lib.apply_dnsmasq_config(ip): + return + try: + up = subprocess.run(["nmcli", "connection", "up", "Hotspot"], capture_output=True, text=True, timeout=30) + if up.returncode != 0: + logger.warning("Could not re-activate hotspot for captive portal; dnsmasq still serves the old" + " address: %s", up.stderr.strip()) + except (subprocess.SubprocessError, FileNotFoundError) as e: + logger.warning("Could not re-activate hotspot for captive portal: %s", e) + + def stop_hotspot() -> dict: """ Stop the WiFi hotspot by turning the WiFi radio off. @@ -540,6 +595,8 @@ def stop_hotspot() -> dict: return {"success": False, "error": "Not available in Docker mode"} logger.info("Stopping hotspot (turning WiFi radio off)") + # The session is over; a lease reused later must see the portal page again. + captive_portal_lib.reset_acknowledged_clients() try: # Turn off the radio to disconnect hotspot result = subprocess.run( diff --git a/controller/lib/captive_portal.py b/controller/lib/captive_portal.py new file mode 100644 index 00000000..3686219f --- /dev/null +++ b/controller/lib/captive_portal.py @@ -0,0 +1,157 @@ +""" +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 + 'SuccessSuccess'), + '/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 + ''), + '/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() diff --git a/controller/main.py b/controller/main.py index 48eaa8cb..755f6218 100644 --- a/controller/main.py +++ b/controller/main.py @@ -25,6 +25,7 @@ from controller import __version__ from fastapi.middleware.cors import CORSMiddleware from controller.api.admin import router as admin_router +from controller.api.captive_portal import router as captive_portal_router from controller.api.disks import router as disks_router from controller.api.onboarding import router as onboarding_router from controller.api.ready import router as ready_router @@ -157,6 +158,7 @@ app.add_middleware( # Include routers app.include_router(admin_router) +app.include_router(captive_portal_router) app.include_router(disks_router) app.include_router(onboarding_router) app.include_router(ready_router) diff --git a/controller/templates/index.html b/controller/templates/index.html index 2aa07af3..2e2e9c27 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -977,6 +977,7 @@
+
Hotspot Settings
@@ -1392,6 +1397,7 @@ id: 'hotspot-btn', label: 'Hotspot', statusUrl: 'api/hotspot/status', startUrl: 'api/hotspot/start', stopUrl: 'api/hotspot/stop', isOn: data => data.enabled, + onStatus: showHotspotAddress, }, bluetooth: { id: 'bluetooth-btn', label: 'Bluetooth', @@ -1429,6 +1435,7 @@ try { const data = await apiCall(config.statusUrl); toggleStates[name] = config.isOn(data); + if (config.onStatus) config.onStatus(data); btn.title = ''; if (!data.available) { btn.textContent = `${config.label} Unavailable`; @@ -1481,6 +1488,19 @@ } } + // Hotspot clients need the address to reach this WROLPi; the portal page tells them. + function showHotspotAddress(data) { + const el = document.getElementById('hotspot-address'); + if (!el) return; + if (data.enabled && data.ip) { + el.textContent = `Hotspot address: ${data.ip}` + + (data.portal_url ? ` · Welcome page: ${data.portal_url}` : ' · Captive portal off'); + el.hidden = false; + } else { + el.hidden = true; + } + } + async function loadHotspotSettings() { const ssidInput = document.getElementById('hotspot-ssid'); if (!ssidInput) return; @@ -1491,6 +1511,7 @@ ]); ssidInput.value = settings.ssid; document.getElementById('hotspot-password').value = settings.password; + document.getElementById('hotspot-captive-portal').checked = settings.captive_portal !== false; const select = document.getElementById('hotspot-device'); select.innerHTML = ''; // Keep the saved device selectable even if it is not currently present. @@ -1543,13 +1564,17 @@ const btn = event.target; btn.disabled = true; try { - await apiCall('api/hotspot/settings', 'POST', { + const saved = await apiCall('api/hotspot/settings', 'POST', { ssid: document.getElementById('hotspot-ssid').value, password: document.getElementById('hotspot-password').value, device: document.getElementById('hotspot-device').value, protocol: document.getElementById('hotspot-protocol').value || null, + captive_portal: document.getElementById('hotspot-captive-portal').checked, }); await updateToggleButton('hotspot'); + if (saved.restart_required) { + alert('Saved. Restart the hotspot to apply the new settings.'); + } } catch (e) { alert(`Failed to save hotspot settings: ${e.message}`); } diff --git a/controller/templates/portal.html b/controller/templates/portal.html new file mode 100644 index 00000000..54841328 --- /dev/null +++ b/controller/templates/portal.html @@ -0,0 +1,79 @@ + + + + + + Welcome to WROLPi + + + + {% if connected %} +

You are connected

+

Tap Done in the corner of this window to keep using the + {{ ssid }} hotspot.

+ {% else %} +

This is your WROLPi

+

You are connected to the {{ ssid }} hotspot.

+ {% endif %} + +

The address of this WROLPi is

+
{{ ip }}
+ + {% if connected %} + Open WROLPi + Open the Controller + {% else %} + {# Phones only re-check the network (and offer "Done") after a full-page navigation. #} + Continue + {% endif %} + +
+

Open your browser to https://{{ ip }} to use WROLPi.

+

Your browser will warn about the certificate the first time. That is expected for an + offline WROLPi; continue to the site.

+

This hotspot does not provide Internet access.

+
+ + diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py index 4da0530f..61b1d37b 100644 --- a/controller/test/test_admin.py +++ b/controller/test/test_admin.py @@ -8,6 +8,7 @@ import pytest import yaml from controller.lib.config import reload_config_from_drive +from controller.lib import captive_portal from controller.lib.admin import ( apply_timezone_from_config, block_bluetooth, @@ -558,6 +559,23 @@ class TestGetHotspotStatusDict: assert result["available"] is True assert result["enabled"] is True + def test_ip_and_portal_url_when_connected(self, reset_runtime_config): + with mock.patch("controller.lib.admin.get_hotspot_status", return_value=HotspotStatus.connected), \ + mock.patch("controller.lib.captive_portal.get_hotspot_ip", return_value='10.42.0.1'), \ + mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=False): + result = get_hotspot_status_dict() + assert result["ip"] == '10.42.0.1' + assert result["captive_portal"] is True + assert result["portal_url"] == 'http://10.42.0.1/portal' + + def test_no_ip_when_off(self, reset_runtime_config): + with mock.patch("controller.lib.admin.get_hotspot_status", return_value=HotspotStatus.off), \ + mock.patch("controller.lib.captive_portal.get_hotspot_ip") as get_ip: + result = get_hotspot_status_dict() + assert result["ip"] is None + assert result["portal_url"] is None + get_ip.assert_not_called() + class TestHotspotSettingsMigration: """Legacy hotspot settings in wrolpi.yaml are copied into controller.yaml on startup.""" @@ -654,6 +672,28 @@ class TestUpdateHotspotSettings: saved = yaml.safe_load(mock_config_path.read_text()) assert saved['hotspot']['protocol'] == 'wpa3' + def test_updates_captive_portal(self, mock_config_path, reset_runtime_config): + """The captive portal toggle is saved to controller.yaml and returned.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False), \ + mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=False): + result = update_hotspot_settings(captive_portal=False) + + assert result["success"] is True + assert result["captive_portal"] is False + saved = yaml.safe_load(mock_config_path.read_text()) + assert saved['hotspot']['captive_portal'] is False + + def test_restart_required_only_while_hotspot_up(self, mock_config_path, reset_runtime_config): + """A change applies on the next hotspot start, so say so when the hotspot is running.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False), \ + mock.patch("controller.lib.admin.get_hotspot_status", return_value=HotspotStatus.connected): + assert update_hotspot_settings(captive_portal=False)["restart_required"] is True + # Nothing changed: nothing to apply. + assert update_hotspot_settings()["restart_required"] is False + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False), \ + mock.patch("controller.lib.admin.get_hotspot_status", return_value=HotspotStatus.off): + assert update_hotspot_settings(captive_portal=True)["restart_required"] is False + def test_rejects_invalid_protocol(self, mock_config_path, reset_runtime_config): """Only wpa2 and wpa3 are valid protocols.""" with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): @@ -897,6 +937,9 @@ class TestDockerModeErrors: assert "Docker" in error +HOTSPOT_IP_JSON = '[{"ifname": "wlan0", "addr_info": [{"family": "inet", "local": "10.42.0.1"}]}]' + + class TestStartHotspot: """Tests for start_hotspot function.""" @@ -924,8 +967,10 @@ class TestStartHotspot: device_ready = mock.Mock(returncode=0, stdout="wlan1:disconnected\n", stderr="") ok = mock.Mock(returncode=0, stdout="", stderr="") + # The final call reads the hotspot's address for the captive portal. + ip_addr = mock.Mock(returncode=0, stdout=HOTSPOT_IP_JSON, stderr="") with mock.patch("subprocess.run", - side_effect=[radio_on, device_ready, ok, ok, ok]) as mock_subprocess: + side_effect=[radio_on, device_ready, ok, ok, ok, ip_addr]) as mock_subprocess: with mock.patch("time.sleep"): start_hotspot() @@ -938,15 +983,143 @@ class TestStartHotspot: assert "TestSSID" in cmd_args assert "testpassword123" in cmd_args + @staticmethod + def _start_with_nmcli(ip_json=HOTSPOT_IP_JSON): + """Run start_hotspot with nmcli mocked; returns the subprocess mock and the include path.""" + radio_on = mock.Mock(returncode=0, stdout="", stderr="") + device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="") + ok = mock.Mock(returncode=0, stdout="", stderr="") + ip_addr = mock.Mock(returncode=0, stdout=ip_json, stderr="") + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False), \ + mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=False), \ + mock.patch("subprocess.run", side_effect=[radio_on, device_ready, ok, ok, ok, ip_addr, ok]) as run, \ + mock.patch("time.sleep"): + result = start_hotspot() + return result, run + + def test_writes_captive_portal_config_before_creating_hotspot(self, reset_runtime_config): + """dnsmasq reads the include at activation, so it must exist before `nmcli ... hotspot`.""" + include = captive_portal.INCLUDE_FILE + seen = {} + results = iter([ + mock.Mock(returncode=0, stdout="", stderr=""), # radio on + mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr=""), # device ready + mock.Mock(returncode=0, stdout="", stderr=""), # nmcli device wifi hotspot + mock.Mock(returncode=0, stdout="", stderr=""), # modify + mock.Mock(returncode=0, stdout="", stderr=""), # up + mock.Mock(returncode=0, stdout=HOTSPOT_IP_JSON, stderr=""), # ip addr + ]) + + def run(cmd, **kwargs): + if 'hotspot' in cmd: + # Observe the include at the moment NetworkManager (and its dnsmasq) start. + seen['include_at_create'] = include.read_text() if include.exists() else None + return next(results) + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False), \ + mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=False), \ + mock.patch("subprocess.run", side_effect=run), \ + mock.patch("time.sleep"): + result = start_hotspot() + + assert result["success"] is True + assert seen['include_at_create'] is not None + assert 'address=/captive.apple.com/10.42.0.1' in seen['include_at_create'] + + def test_disabled_removes_captive_portal_config(self, reset_runtime_config): + include = captive_portal.INCLUDE_FILE + include.write_text('address=/captive.apple.com/10.42.0.1\n') + with mock.patch("controller.lib.captive_portal.get_config_value", return_value=False): + result, run = self._start_with_nmcli() + assert result["success"] is True + assert not include.exists() + # No `ip` read and no extra re-activation when the portal is off. + assert not [c for c in run.call_args_list if c[0][0][0] == 'ip'] + + def test_rewrites_config_when_networkmanager_picked_another_subnet(self, reset_runtime_config): + """10.42.1.1 is used when another shared connection holds 10.42.0.1.""" + other = '[{"ifname": "wlan0", "addr_info": [{"family": "inet", "local": "10.42.1.1"}]}]' + result, run = self._start_with_nmcli(ip_json=other) + assert result["success"] is True + assert 'address=/captive.apple.com/10.42.1.1' in captive_portal.INCLUDE_FILE.read_text() + # Re-activated once more so dnsmasq re-reads the corrected include. + ups = [c for c in run.call_args_list if c[0][0][:3] == ['nmcli', 'connection', 'up']] + assert len(ups) == 2 + + def test_reactivation_failure_is_logged(self, reset_runtime_config, caplog): + """dnsmasq keeps the old address if the corrective `up` fails; say so.""" + other = '[{"ifname": "wlan0", "addr_info": [{"family": "inet", "local": "10.42.1.1"}]}]' + ok = mock.Mock(returncode=0, stdout="", stderr="") + device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="") + ip_addr = mock.Mock(returncode=0, stdout=other, stderr="") + failed_up = mock.Mock(returncode=4, stdout="", stderr="Error: Connection activation failed") + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False), \ + mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=False), \ + mock.patch("subprocess.run", side_effect=[ok, device_ready, ok, ok, ok, ip_addr, failed_up]), \ + mock.patch("time.sleep"): + result = start_hotspot() + assert result["success"] is True + assert "Could not re-activate hotspot for captive portal" in caplog.text + assert "Connection activation failed" in caplog.text + + def test_wpa3_reverted_hotspot_still_gets_captive_portal_ip(self, reset_runtime_config): + """A WPA3 failure that reverts to WPA2 leaves the hotspot up; it still needs the right address.""" + other = '[{"ifname": "wlan0", "addr_info": [{"family": "inet", "local": "10.42.1.1"}]}]' + ok = mock.Mock(returncode=0, stdout="", stderr="") + device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="") + sae_failed = mock.Mock(returncode=1, stdout="", stderr="Error: sae unsupported") + ip_addr = mock.Mock(returncode=0, stdout=other, stderr="") + + def mock_get_config(key, default=None): + return {'hotspot.protocol': 'wpa3'}.get(key, default) + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False), \ + mock.patch("controller.lib.captive_portal.is_docker_mode", return_value=False), \ + mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config), \ + mock.patch("subprocess.run", side_effect=[ + ok, device_ready, ok, # radio, ready, hotspot + sae_failed, # modify to sae fails + ok, ok, # revert modify, revert up + ip_addr, ok, # captive portal: read address, re-activate + ]) as run, \ + mock.patch("time.sleep"): + result = start_hotspot() + assert result["success"] is False + assert "reverted to WPA2" in result["error"] + assert 'address=/captive.apple.com/10.42.1.1' in captive_portal.INCLUDE_FILE.read_text() + assert run.call_args_list[-1][0][0] == ["nmcli", "connection", "up", "Hotspot"] + + def test_stop_forgets_acknowledged_clients(self): + """A later joiner reusing the lease must see the portal page, not a silent success.""" + captive_portal.acknowledge_client('10.42.0.210') + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False), \ + mock.patch("subprocess.run", return_value=mock.Mock(returncode=0, stdout="", stderr="")): + assert stop_hotspot()["success"] is True + assert not captive_portal.is_client_acknowledged('10.42.0.210') + + def test_start_forgets_acknowledged_clients(self, reset_runtime_config): + """A new hotspot session shows every device the portal page again.""" + captive_portal.acknowledge_client('10.42.0.210') + result, _ = self._start_with_nmcli() + assert result["success"] is True + assert not captive_portal.is_client_acknowledged('10.42.0.210') + + def test_no_reactivation_when_ip_matches(self, reset_runtime_config): + result, run = self._start_with_nmcli() + ups = [c for c in run.call_args_list if c[0][0][:3] == ['nmcli', 'connection', 'up']] + assert len(ups) == 1 + @staticmethod def _run_enable(protocol: str, results: list) -> tuple: """Run start_hotspot() with the given hotspot.protocol and subprocess results.""" def mock_get_config(key, default=None): return {'hotspot.protocol': protocol}.get(key, default) + # After a successful start the hotspot's address is read once for the captive portal. + ip_addr = mock.Mock(returncode=0, stdout=HOTSPOT_IP_JSON, stderr="") with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config): - with mock.patch("subprocess.run", side_effect=results) as mock_subprocess: + with mock.patch("subprocess.run", side_effect=results + [ip_addr]) as mock_subprocess: with mock.patch("time.sleep"): result = start_hotspot() return result, mock_subprocess.call_args_list @@ -1028,6 +1201,7 @@ class TestStartHotspot: hotspot_result, # nmcli device wifi hotspot hotspot_result, # nmcli connection modify Hotspot hotspot_result, # nmcli connection up Hotspot + mock.Mock(returncode=0, stdout=HOTSPOT_IP_JSON, stderr=""), # ip -4 -j addr show ]): with mock.patch("time.sleep"): # Don't actually sleep in tests result = start_hotspot() diff --git a/controller/test/test_admin_api.py b/controller/test/test_admin_api.py index 4fffe1ac..8f6ed539 100644 --- a/controller/test/test_admin_api.py +++ b/controller/test/test_admin_api.py @@ -92,7 +92,7 @@ class TestHotspotSettingsEndpoint: response = test_client.get("/api/hotspot/settings") assert response.status_code == 200 assert response.json() == {"device": "wlan0", "ssid": "WROLPi", "password": "wrolpi hotspot", - "protocol": "wpa2"} + "protocol": "wpa2", "captive_portal": True, "restart_required": False} def test_post_settings(self, test_client, mock_config_path): """Should update settings and persist them to controller.yaml.""" @@ -102,10 +102,18 @@ class TestHotspotSettingsEndpoint: ) assert response.status_code == 200 assert response.json() == {"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123", - "protocol": "wpa3"} + "protocol": "wpa3", "captive_portal": True, "restart_required": False} assert "wlp2s0" in mock_config_path.read_text() assert "wpa3" in mock_config_path.read_text() + def test_post_captive_portal(self, test_client, mock_config_path): + """The captive portal toggle round-trips and is persisted.""" + response = test_client.post("/api/hotspot/settings", json={"captive_portal": False}) + assert response.status_code == 200 + assert response.json()["captive_portal"] is False + assert "captive_portal: false" in mock_config_path.read_text() + assert test_client.get("/api/hotspot/settings").json()["captive_portal"] is False + def test_post_rejects_short_password(self, test_client, mock_config_path): """Should reject a password shorter than 8 characters.""" response = test_client.post("/api/hotspot/settings", json={"password": "short"}) diff --git a/controller/test/test_captive_portal.py b/controller/test/test_captive_portal.py new file mode 100644 index 00000000..58a64443 --- /dev/null +++ b/controller/test/test_captive_portal.py @@ -0,0 +1,264 @@ +""" +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 'Success' 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' diff --git a/controller/test/test_defaults.py b/controller/test/test_defaults.py index ae62b601..960721d3 100644 --- a/controller/test/test_defaults.py +++ b/controller/test/test_defaults.py @@ -111,6 +111,7 @@ class TestHotspotConfig: ("device", "wlan0"), ("ssid", "WROLPi"), ("password", "wrolpi hotspot"), + ("captive_portal", True), ]) def test_hotspot_defaults(self, key, expected): """Hotspot should have expected default values.""" diff --git a/docker/help/wrolpi-help b/docker/help/wrolpi-help index a6cace93..662a4dd7 160000 --- a/docker/help/wrolpi-help +++ b/docker/help/wrolpi-help @@ -1 +1 @@ -Subproject commit a6cace93f8ebb00554d94dfee9116d2667b7c1ff +Subproject commit 662a4dd7b1cf66bb3ec517c69e99f70cb7a4941b diff --git a/repair.sh b/repair.sh index 607044a7..6fc20375 100755 --- a/repair.sh +++ b/repair.sh @@ -50,6 +50,9 @@ mkdir -p /etc/caddy cp /opt/wrolpi/etc/raspberrypios/Caddyfile /etc/caddy/Caddyfile mkdir -p /var/www cp /opt/wrolpi/etc/raspberrypios/50x.html /var/www/50x.html +# The Controller writes the hotspot captive-portal dnsmasq include here when the hotspot starts. +# Never copy a file into it: the include only exists while the setting is enabled. +mkdir -p /etc/NetworkManager/dnsmasq-shared.d # Generate certificate for HTTPS. /opt/wrolpi/scripts/generate_certificates.sh