mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
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>
This commit is contained in:
parent
91f92683ba
commit
d701846254
18 changed files with 1029 additions and 19 deletions
|
|
@ -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 <Panel>
|
||||
<Header as='h4' icon='wifi'>Hotspot Settings</Header>
|
||||
{address && <p style={{margin: '0 0 0.5em', opacity: 0.8}}>
|
||||
Hotspot address: <b>{address}</b>
|
||||
{hotspotStatus.portal_url ? <> · Welcome page: {hotspotStatus.portal_url}</> : ' · Captive portal off'}
|
||||
</p>}
|
||||
<Group grow align='flex-start'>
|
||||
<TextInput
|
||||
label='Hotspot SSID'
|
||||
|
|
@ -1581,6 +1626,14 @@ function HotspotSettingsForm() {
|
|||
onChange={(value) => setForm({...form, protocol: value})}
|
||||
/>
|
||||
</Group>
|
||||
<Checkbox
|
||||
mt='sm'
|
||||
label='Captive portal'
|
||||
description="Show a welcome page with this WROLPi's address to devices that join the hotspot"
|
||||
checked={form.captive_portal}
|
||||
disabled={dockerized || saving}
|
||||
onChange={(e) => setForm({...form, captive_portal: e.currentTarget.checked})}
|
||||
/>
|
||||
<div className='wrolpi-button-row' style={{marginTop: '0.8em'}}>
|
||||
<Button
|
||||
color='violet'
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ jest.mock('../../api/controller', () => ({
|
|||
restartServices: jest.fn().mockResolvedValue({success: true}),
|
||||
getSambaStatus: jest.fn().mockResolvedValue({enabled: false, available: true, shares: []}),
|
||||
getHotspotSettings: jest.fn(),
|
||||
getHotspotStatus: jest.fn(),
|
||||
getHotspotDevices: jest.fn(),
|
||||
getHotspotProtocols: jest.fn(),
|
||||
updateHotspotSettings: jest.fn(),
|
||||
|
|
@ -117,6 +118,12 @@ jest.mock('../Common', () => {
|
|||
};
|
||||
});
|
||||
|
||||
// Toasts render through Mantine notifications, which the test providers do not include.
|
||||
jest.mock('../ui', () => ({
|
||||
...jest.requireActual('../ui'),
|
||||
toast: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Settings.js components
|
||||
jest.mock('./Settings', () => ({
|
||||
RestartButton: () => <button data-testid="restart-button">Restart</button>,
|
||||
|
|
@ -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(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
|
||||
<ThemeContext.Provider value={defaultTheme}>
|
||||
<SettingsContext.Provider value={defaultSettings}>
|
||||
<ControllerPage/>
|
||||
</SettingsContext.Provider>
|
||||
</ThemeContext.Provider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
81
controller/api/captive_portal.py
Normal file
81
controller/api/captive_portal.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
157
controller/lib/captive_portal.py
Normal file
157
controller/lib/captive_portal.py
Normal file
|
|
@ -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
|
||||
'<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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -977,6 +977,7 @@
|
|||
<button id="desktop-btn" class="btn" onclick="toggleSubsystem('desktop')" disabled>Loading...</button>
|
||||
<button id="vnc-btn" class="btn" onclick="toggleSubsystem('vnc')" disabled>Loading...</button>
|
||||
</div>
|
||||
<p id="hotspot-address" style="margin: -0.5rem 0 1rem; color: #aaa; font-size: 0.9rem;" hidden></p>
|
||||
<details style="margin-bottom: 1rem;">
|
||||
<summary style="cursor: pointer;">Hotspot Settings</summary>
|
||||
<div class="modal-form" style="max-width: 24rem; margin-top: 0.5rem;">
|
||||
|
|
@ -996,6 +997,10 @@
|
|||
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 1rem;">
|
||||
</select>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
|
||||
<input type="checkbox" id="hotspot-captive-portal">
|
||||
Captive portal: show a welcome page (with this WROLPi's address) to devices that join
|
||||
</label>
|
||||
<button class="btn btn-primary" onclick="saveHotspotSettings()">Save Hotspot Settings</button>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
79
controller/templates/portal.html
Normal file
79
controller/templates/portal.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Welcome to WROLPi</title>
|
||||
<style>
|
||||
/* Self-contained: shown inside phone sign-in sheets which load nothing external. */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2rem 1.25rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #f0f0f0;
|
||||
text-align: center;
|
||||
}
|
||||
h1 { font-size: 1.6rem; margin: 0 0 0.5rem; }
|
||||
p { margin: 0.5rem 0; line-height: 1.4; }
|
||||
.muted { color: #b0b0c0; font-size: 0.95rem; }
|
||||
.address {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
margin: 1.25rem 0;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
-webkit-user-select: all;
|
||||
}
|
||||
.btn {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
margin: 0.75rem auto;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
background: #6c4fbb;
|
||||
}
|
||||
.btn.secondary { background: #2f2f4a; }
|
||||
.note {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if connected %}
|
||||
<h1>You are connected</h1>
|
||||
<p class="muted">Tap <strong>Done</strong> in the corner of this window to keep using the
|
||||
<strong>{{ ssid }}</strong> hotspot.</p>
|
||||
{% else %}
|
||||
<h1>This is your WROLPi</h1>
|
||||
<p class="muted">You are connected to the <strong>{{ ssid }}</strong> hotspot.</p>
|
||||
{% endif %}
|
||||
|
||||
<p>The address of this WROLPi is</p>
|
||||
<div class="address">{{ ip }}</div>
|
||||
|
||||
{% if connected %}
|
||||
<a class="btn" href="https://{{ ip }}/">Open WROLPi</a>
|
||||
<a class="btn secondary" href="https://{{ ip }}/controller/">Open the Controller</a>
|
||||
{% else %}
|
||||
{# Phones only re-check the network (and offer "Done") after a full-page navigation. #}
|
||||
<a class="btn" href="{{ continue_path }}">Continue</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="note muted">
|
||||
<p>Open your browser to <strong>https://{{ ip }}</strong> to use WROLPi.</p>
|
||||
<p>Your browser will warn about the certificate the first time. That is expected for an
|
||||
offline WROLPi; continue to the site.</p>
|
||||
<p>This hotspot does not provide Internet access.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
264
controller/test/test_captive_portal.py
Normal file
264
controller/test/test_captive_portal.py
Normal file
|
|
@ -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 '<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'
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit a6cace93f8ebb00554d94dfee9116d2667b7c1ff
|
||||
Subproject commit 662a4dd7b1cf66bb3ec517c69e99f70cb7a4941b
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue