Network & Connectivity
The meetingscribe repository manages network connectivity through a centralized state machine in src/meeting_scribe/wifi.py, which acts as the single owner of all WiFi AP lifecycle operations including bring-up, teardown, mode switching, captive portal management, and firewall configuration 1. This module orchestrates boot-time network state and firewall rules via the reconcile_network_state function, which is triggered by CLI/REST transitions and periodic self-healing to ensure idempotent firewall application based on current interface presence 2. The architecture relies on nmcli for AP management, iptables/ipset for firewall gating, and a derived state file to maintain consistency between the radio’s actual broadcast state and the system’s view 1.
WiFi AP Lifecycle and State Management
Section titled “WiFi AP Lifecycle and State Management”The WiFi Access Point (AP) lifecycle is managed by the wifi_up, wifi_down, and wifi_switch public entry points, which are protected by an asyncio.Lock (_wifi_lock) to prevent concurrent modifications. Internal helpers like _bring_up_ap and _teardown_ap do not acquire the lock themselves, allowing them to be composed safely without deadlock risk. The AP configuration is defined by the WifiConfig dataclass, which supports modes such as “admin” (using OWE encryption) and “off” 3.
The system maintains a derived cache of the AP’s actual broadcasting state in a JSON file located at HOTSPOT_STATE_FILE (defaulting to /tmp/meeting-hotspot.json) 1. This file is always written by reading back from nmcli --show-secrets, ensuring that the displayed QR code and system state never diverge from the radio’s actual SSID and PSK. The AP connection name is fixed as DellDemo-AP, and the AP IP is 10.42.0.1.
Captive Portal and Firewall Orchestration
Section titled “Captive Portal and Firewall Orchestration”The captive portal and firewall rules are managed through a reconciliation process that ensures the correct posture for the current interface state 2. The reconcile_network_state function is the single authoritative trigger for re-applying firewall and sysctl rules. It first ensures that the required ipset sets (ms-allowed-admins and ms-allowed-guests) exist; if they do not, it aborts to prevent installing silently failing rules.
The firewall rules are tagged with ms-fw and are removed before re-application to prevent rule drift. The system identifies WAN interfaces by checking for the presence of the wired interface enP7s7, the STA interface wlan_sta, and the Cloudflare WARP interface wgcf-profile. The wired interface is always treated as admin-allowed, while the STA interface is explicitly denied input access. The WARP interface is included as a WAN egress to allow MASQUERADE and FORWARD ACCEPT rules when WARP is active.
Network Daemon and Boot-Time State
Section titled “Network Daemon and Boot-Time State”The network daemon orchestrates boot-time network state by calling reconcile_network_state during the boot lifespan and periodic self-healing cycles. This ensures that firewall rules and sysctl settings are consistent with the current interface state regardless of how the system was brought up. The wifi_status function provides a view of the live WiFi state by querying nmcli and wpa_supplicant rather than relying solely on the state file, ensuring that the admin panel reflects the actual radio state 4.
The system also handles DFS (Dynamic Frequency Selection) channels by adjusting activation timeouts based on the channel type 3. Regular DFS channels have a 120-second timeout, while weather-radar DFS channels have a 660-second timeout to accommodate longer Channel Availability Checks (CAC). This ensures that the AP does not fail fast on channels that require extended silent listening for radar pulses.
"""WiFi hotspot state machine for meeting-scribe.
Single owner of all WiFi AP lifecycle: bring-up, teardown, mode switching,
captive portal, firewall, regdomain, and credentials. ``server.py`` imports
from here - never the reverse.
Locking discipline
------------------
``_wifi_lock`` is an ``asyncio.Lock`` acquired ONLY by the three public
async entry points: ``wifi_up``, ``wifi_down``, ``wifi_switch``. Internal
helpers (``_bring_up_ap``, ``_teardown_ap``, …) never acquire the lock
themselves so they can be freely composed without deadlock risk.
State-file invariant
--------------------
``/tmp/meeting-hotspot.json`` is a *derived cache* of what nmcli reports
is actually broadcasting. It is ALWAYS written by reading back from
``nmcli --show-secrets`` - never from in-memory generated credentials.
This makes it impossible for the displayed QR code to encode SSID/psk
that don't match the radio.
"""
from __future__ import annotations
import asyncio
import copy
import logging
import os
import re
import subprocess
import time # noqa: F401 # patch surface: tests patch ``wifi.time.sleep`` (_wait_for_ap_active)
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Regdomain + settings helpers are imported here for use by the AP
# bring-up path, AND re-exported as ``wifi.X`` for back-compat with
# test_wifi.py's source-grep assertions (e.g. ``wifi._regdomain_modprobe_path``).
from meeting_scribe.server_support.regdomain import ( # noqa: F401
_current_regdomain,
"""Remove any ``/etc/netplan/90-NM-*.yaml`` files that reference
our AP SSID or connection name.
NetworkManager mirrors every connection it manages into a
netplan YAML so netplan can render them at next boot. After
a delete-and-readd of ``DellDemo-AP`` (which we do on every
security-mode rotation - open ↔ sae) the OLD YAML survives.
NM loads both on the next start and chokes on the now-invalid
combinations (e.g. ``pmf=required`` from a prior SAE config
paired with ``key-mgmt=owe`` from the new one), with the
failure surfacing as ``supplicant-timeout``.
Idempotent + best-effort; failures here are non-fatal because
the netplan path is purely a next-boot concern.
"""
import glob
candidates = glob.glob("/etc/netplan/90-NM-*.yaml")
removed: list[str] = []
for path in candidates:
try:
with open(path) as fh:
body = fh.read()
except OSError:
continue
if AP_CON_NAME in body or "Dell Meeting" in body:
try:
subprocess.run(
["sudo", "rm", "-f", path],
capture_output=True,
timeout=5,
check=False,
)
removed.append(path)
except FileNotFoundError, subprocess.TimeoutExpired, OSError:
pass
if removed:
try:
subprocess.run(
["sudo", "netplan", "generate"],
_AP_ACTIVATION_WAIT_SECONDS = 45
_AP_ACTIVATION_POLL_INTERVAL = 1.0
# Channels in the 5 GHz DFS band (5250-5725 MHz) require hostapd to
# perform a Channel Availability Check (silent listen for radar
# pulses) before it can start beaconing. Per IEEE 802.11h / ETSI
# EN 301 893:
#
# - Regular DFS (52-116, 132-144) - CAC is 60 s.
# - Weather-radar slice (120/124/128, 5600-5650 MHz) - CAC is
# 10 minutes; the spectrum is shared with civil aviation
# primary radars and the regulator requires a long silent
# listen to avoid masking radar returns. JP regdomain forbids
# these channels outright (5470-5725 has the 5600-5650 gap),
# so in practice JP customers never see this branch - but we
# run on customer hardware in other regions.
#
# Non-DFS channels keep the original 45 s ceiling so a stuck AP
# fails fast.
_AP_ACTIVATION_WAIT_SECONDS_DFS = 120 # 60 s CAC + slack for slow drivers
_AP_ACTIVATION_WAIT_SECONDS_DFS_WEATHER = 660 # 10 min CAC + 60 s slack
_DFS_5GHZ_CHANNELS: frozenset[int] = frozenset(
{52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144}
)
_DFS_5GHZ_WEATHER_RADAR_CHANNELS: frozenset[int] = frozenset({120, 124, 128})
def _ap_activation_timeout_for(cfg: WifiConfig) -> int:
"""Return the per-channel activation deadline in seconds.
Three regimes - see the module-level constants for the regulatory
background:
- Non-DFS or 2.4 GHz: 45 s (the original ceiling).
- Regular DFS (52-116, 132-144): 120 s.
- Weather-radar DFS (120, 124, 128): 660 s.
"""
if cfg.band != "a":
return _AP_ACTIVATION_WAIT_SECONDS
if cfg.channel in _DFS_5GHZ_WEATHER_RADAR_CHANNELS:
return _AP_ACTIVATION_WAIT_SECONDS_DFS_WEATHER
if cfg.channel in _DFS_5GHZ_CHANNELS:
Does NOT just read the state file - queries the actual radio and
supplicant for authoritative state.
"""
loop = asyncio.get_event_loop()
# Desired mode from settings
settings = _load_settings_override()
desired_mode = settings.get("wifi_mode", "admin")
# Live AP state. This function is the admin-panel + status-bar
# backing store; it polls every few seconds while the panel is
# open. Pass the long TTL (30 s) so we don't burn a sudo nmcli
# round-trip per poll - the cache is still write-invalidated, so
# any operator-triggered change shows up immediately on the next
# tick.
ap_active = await loop.run_in_executor(
None,
lambda: _nmcli_ap_is_active(ttl=_AP_STATE_STATUS_TTL_S),
)
result: dict[str, Any] = {
"desired_mode": desired_mode,
"live_mode": "off",
"ssid": None,
"password": None,
"security": None,
"regdomain": _effective_regdomain(),
"regdomain_live": await loop.run_in_executor(None, _current_regdomain),
"regdomain_drift": False,
"captive_active": _captive_portal_active(),
"client_count": 0,
"ap_ip": AP_IP,
}
target_reg = _effective_regdomain()
live_reg = result["regdomain_live"]
result["regdomain_drift"] = live_reg is not None and live_reg != target_reg
if not ap_active: