fix(wifi): create truly open AP via nmcli connection add; add captive portal to nmcli path

nmcli device wifi hotspot always attaches a WPA2 PSK on Bookworm/Trixie
and silently ignores post-creation security modifications, causing users
to be prompted for an unknown password. Switch to nmcli connection add
with 802-11-wireless.mode ap and no security section — NM cannot auto-add
a password to a profile that has no 802-11-wireless-security block.

Also:
- Remove dead DEFAULT_AP_PASSWORD / ap_password config field (stored but
  never passed to hostapd or nmcli, causing user confusion)
- Add iptables port 80→5000 redirect to the nmcli AP path so captive portal
  auto-popup works on phones without hostapd (previously only worked on
  the hostapd path)
- Clean up iptables rules on disable for the nmcli path
- Improve LED message on AP enable: show SSID, "No password", and IP:port
  on both paths so users know exactly how to connect
- Fix systemd template: replace hardcoded /home/ledpi/LEDMatrix/ with
  __PROJECT_ROOT_DIR__ placeholder (install script already writes correct path)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-04-30 13:52:13 -04:00
parent 5e6c40ad55
commit e8afd23c98
2 changed files with 160 additions and 226 deletions

View File

@@ -65,7 +65,6 @@ DNSMASQ_SERVICE = "dnsmasq"
# Default AP settings # Default AP settings
DEFAULT_AP_SSID = "LEDMatrix-Setup" DEFAULT_AP_SSID = "LEDMatrix-Setup"
DEFAULT_AP_PASSWORD = "ledmatrix123"
DEFAULT_AP_CHANNEL = 7 DEFAULT_AP_CHANNEL = 7
# LED status message file (for display_controller integration) # LED status message file (for display_controller integration)
@@ -303,7 +302,6 @@ class WiFiManager:
else: else:
self.config = { self.config = {
"ap_ssid": DEFAULT_AP_SSID, "ap_ssid": DEFAULT_AP_SSID,
"ap_password": DEFAULT_AP_PASSWORD,
"ap_channel": DEFAULT_AP_CHANNEL, "ap_channel": DEFAULT_AP_CHANNEL,
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period) "auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
"saved_networks": [] "saved_networks": []
@@ -1705,7 +1703,10 @@ class WiFiManager:
# Continue anyway - port 5000 will still work # Continue anyway - port 5000 will still work
logger.info("AP mode enabled successfully") logger.info("AP mode enabled successfully")
self._show_led_message("Setup Mode Active", duration=5) ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
self._show_led_message(
f"WiFi Setup\n{ap_ssid}\nNo password\n192.168.4.1:5000", duration=10
)
return True, "AP mode enabled" return True, "AP mode enabled"
except Exception as e: except Exception as e:
logger.error(f"Error starting AP services: {e}") logger.error(f"Error starting AP services: {e}")
@@ -1716,245 +1717,155 @@ class WiFiManager:
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]: def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
""" """
Enable AP mode using nmcli hotspot. Enable AP mode using nmcli as an open (passwordless) access point.
This method is optimized for both Bookworm and Trixie: Uses 'nmcli connection add type wifi 802-11-wireless.mode ap' instead of
- Trixie: Uses Netplan, connections stored in /run/NetworkManager/system-connections 'nmcli device wifi hotspot' because the hotspot subcommand always creates a
- Bookworm: Traditional NetworkManager, connections in /etc/NetworkManager/system-connections WPA2-protected network on Bookworm/Trixie and silently ignores attempts to
strip security after creation.
On Trixie, we also disable PMF (Protected Management Frames) which can cause Tested for both Bookworm and Trixie (Netplan-based NetworkManager).
connection issues with certain WiFi adapters and clients.
""" """
try: try:
# Stop any existing connection # Stop any existing connection
self.disconnect_from_network() self.disconnect_from_network()
time.sleep(1) time.sleep(1)
# Delete any existing hotspot connections (more thorough cleanup)
# First, list all connections to find any with the same SSID or hotspot-related ones
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
result = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if ':' in line:
parts = line.split(':')
if len(parts) >= 2:
conn_name = parts[0].strip()
conn_type = parts[1].strip().lower() if len(parts) > 1 else ""
conn_ssid = parts[2].strip() if len(parts) > 2 else ""
# Delete if:
# 1. It's a hotspot type
# 2. It has the same SSID as our AP
# 3. It matches our known connection names
should_delete = (
'hotspot' in conn_type or
conn_ssid == ap_ssid or
'hotspot' in conn_name.lower() or
conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]
)
if should_delete:
logger.info(f"Deleting existing connection: {conn_name} (type: {conn_type}, SSID: {conn_ssid})")
# First disconnect it if active
subprocess.run(
["nmcli", "connection", "down", conn_name],
capture_output=True,
timeout=5
)
# Then delete it
subprocess.run(
["nmcli", "connection", "delete", conn_name],
capture_output=True,
timeout=10
)
# Also explicitly delete known connection names (in case they weren't caught above)
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
subprocess.run(
["nmcli", "connection", "down", conn_name],
capture_output=True,
timeout=5
)
subprocess.run(
["nmcli", "connection", "delete", conn_name],
capture_output=True,
timeout=10
)
# Wait a moment for deletions to complete
time.sleep(1)
# Get AP settings from config
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID) ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL) ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
# Use nmcli hotspot command (simpler, works with Broadcom chips) # Clean up any pre-existing AP connection profiles
# Open network (no password) for easy setup access for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
logger.info(f"Creating open hotspot with nmcli: {ap_ssid} on {self._wifi_interface} (no password)") subprocess.run(["nmcli", "connection", "down", conn_name],
capture_output=True, timeout=5)
subprocess.run(["nmcli", "connection", "delete", conn_name],
capture_output=True, timeout=10)
# Note: Some NetworkManager versions add a default password to hotspots # Also delete any connection whose SSID matches ours or is hotspot type
# We'll create it and then immediately remove all security settings result = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
parts = line.split(':')
if len(parts) >= 2:
conn_name = parts[0].strip()
conn_type = parts[1].strip().lower() if len(parts) > 1 else ""
conn_ssid = parts[2].strip() if len(parts) > 2 else ""
if ('hotspot' in conn_type or conn_ssid == ap_ssid or
'hotspot' in conn_name.lower()):
logger.info(f"Deleting existing AP connection: {conn_name}")
subprocess.run(["nmcli", "connection", "down", conn_name],
capture_output=True, timeout=5)
subprocess.run(["nmcli", "connection", "delete", conn_name],
capture_output=True, timeout=10)
time.sleep(1)
# Create an open AP connection profile from scratch.
# Using 'connection add' instead of 'device wifi hotspot' because the
# hotspot subcommand always attaches a WPA2 PSK on Bookworm/Trixie and
# ignores post-creation security modifications.
logger.info(f"Creating open AP with nmcli connection add: {ap_ssid} on "
f"{self._wifi_interface} (no password)")
cmd = [ cmd = [
"nmcli", "device", "wifi", "hotspot", "nmcli", "connection", "add",
"ifname", self._wifi_interface, "type", "wifi",
"con-name", "LEDMatrix-Setup-AP", "con-name", "LEDMatrix-Setup-AP",
"ifname", self._wifi_interface,
"ssid", ap_ssid, "ssid", ap_ssid,
"band", "bg", # 2.4GHz for maximum compatibility "802-11-wireless.mode", "ap",
"channel", str(ap_channel), "802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
# Don't pass password parameter - we'll remove security after creation "802-11-wireless.channel", str(ap_channel),
"ipv4.method", "shared",
"ipv4.addresses", "192.168.4.1/24",
# No 802-11-wireless-security section → open network
] ]
result = subprocess.run( # On Trixie disable PMF which can prevent older clients from connecting
cmd, if self._is_trixie:
capture_output=True, cmd += ["802-11-wireless-security.pmf", "disable"]
text=True, logger.info("Trixie detected: disabling PMF for better client compatibility")
timeout=30
)
if result.returncode == 0: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
# Always explicitly remove all security settings to ensure open network
# NetworkManager sometimes adds default security even when not specified
logger.info("Ensuring hotspot is open (no password)...")
time.sleep(2) # Give it a moment to create
# Remove all possible security settings if result.returncode != 0:
security_settings = [
("802-11-wireless-security.key-mgmt", "none"),
("802-11-wireless-security.psk", ""),
("802-11-wireless-security.wep-key", ""),
("802-11-wireless-security.wep-key-type", ""),
("802-11-wireless-security.auth-alg", "open"),
]
# On Trixie, also disable PMF (Protected Management Frames)
# This can cause connection issues with certain WiFi adapters and clients
if self._is_trixie:
security_settings.append(("802-11-wireless-security.pmf", "disable"))
logger.info("Trixie detected: disabling PMF for better client compatibility")
for setting, value in security_settings:
result_modify = subprocess.run(
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP", setting, str(value)],
capture_output=True,
text=True,
timeout=5
)
if result_modify.returncode != 0:
logger.debug(f"Could not set {setting} to {value}: {result_modify.stderr}")
# On Trixie, set static IP address for the hotspot (default is 10.42.0.1)
# We want 192.168.4.1 for consistency
subprocess.run(
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
"ipv4.addresses", "192.168.4.1/24",
"ipv4.method", "shared"],
capture_output=True,
text=True,
timeout=5
)
# Verify it's open
verify_result = subprocess.run(
["nmcli", "-t", "-f", "802-11-wireless-security.key-mgmt,802-11-wireless-security.psk", "connection", "show", "LEDMatrix-Setup-AP"],
capture_output=True,
text=True,
timeout=5
)
if verify_result.returncode == 0:
output = verify_result.stdout.strip()
key_mgmt = ""
psk = ""
for line in output.split('\n'):
if 'key-mgmt:' in line:
key_mgmt = line.split(':', 1)[1].strip() if ':' in line else ""
elif 'psk:' in line:
psk = line.split(':', 1)[1].strip() if ':' in line else ""
if key_mgmt != "none" or (psk and psk != ""):
logger.warning(f"Hotspot still has security (key-mgmt={key_mgmt}, psk={'set' if psk else 'empty'}), deleting and recreating...")
# Delete and recreate as last resort
subprocess.run(
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
capture_output=True,
timeout=5
)
subprocess.run(
["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
capture_output=True,
timeout=5
)
time.sleep(1)
# Recreate without any password parameters
cmd_recreate = [
"nmcli", "device", "wifi", "hotspot",
"ifname", self._wifi_interface,
"con-name", "LEDMatrix-Setup-AP",
"ssid", ap_ssid,
"band", "bg",
"channel", str(ap_channel),
]
subprocess.run(cmd_recreate, capture_output=True, timeout=30)
# Set IP address for consistency
subprocess.run(
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
"ipv4.addresses", "192.168.4.1/24",
"ipv4.method", "shared"],
capture_output=True,
timeout=5
)
# Disable PMF on Trixie
if self._is_trixie:
subprocess.run(
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
"802-11-wireless-security.pmf", "disable"],
capture_output=True,
timeout=5
)
logger.info("Recreated hotspot as open network")
else:
logger.info("Hotspot verified as open (no password)")
# Restart the connection to apply all changes
subprocess.run(
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
capture_output=True,
timeout=5
)
time.sleep(1)
subprocess.run(
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
capture_output=True,
timeout=10
)
logger.info("Hotspot restarted with open network settings")
logger.info(f"AP mode started via nmcli hotspot: {ap_ssid}")
time.sleep(2)
# Verify hotspot is running
status = self._get_ap_status_nmcli()
if status.get('active'):
ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip}")
self._show_led_message(f"Setup: {ip}", duration=5)
return True, f"AP mode enabled (hotspot mode) - Access at {ip}:5000"
else:
logger.error("AP mode started but not verified")
return False, "AP mode started but verification failed"
else:
error_msg = result.stderr.strip() or result.stdout.strip() error_msg = result.stderr.strip() or result.stdout.strip()
logger.error(f"Failed to start AP mode via nmcli: {error_msg}") logger.error(f"Failed to create AP connection profile: {error_msg}")
self._show_led_message("AP mode failed", duration=5) self._show_led_message("AP mode failed", duration=5)
return False, f"Failed to start AP mode: {error_msg}" return False, f"Failed to create AP profile: {error_msg}"
logger.info("AP connection profile created, bringing it up...")
up_result = subprocess.run(
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
capture_output=True, text=True, timeout=20
)
if up_result.returncode != 0:
error_msg = up_result.stderr.strip() or up_result.stdout.strip()
logger.error(f"Failed to bring up AP connection: {error_msg}")
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
capture_output=True, timeout=10)
self._show_led_message("AP mode failed", duration=5)
return False, f"Failed to start AP: {error_msg}"
time.sleep(2)
# Set up iptables port forwarding: redirect port 80 → 5000
# This enables the captive portal auto-redirect on phones/laptops.
try:
iptables_check = subprocess.run(["which", "iptables"],
capture_output=True, timeout=2)
if iptables_check.returncode == 0:
subprocess.run(
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=1"],
capture_output=True, timeout=5
)
check_result = subprocess.run(
["sudo", "iptables", "-t", "nat", "-C", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "REDIRECT", "--to-port", "5000"],
capture_output=True, timeout=5
)
if check_result.returncode != 0:
subprocess.run(
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "REDIRECT", "--to-port", "5000"],
capture_output=True, timeout=5
)
logger.info("Added iptables rule to redirect port 80 to 5000")
check_input = subprocess.run(
["sudo", "iptables", "-C", "INPUT",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "ACCEPT"],
capture_output=True, timeout=5
)
if check_input.returncode != 0:
subprocess.run(
["sudo", "iptables", "-A", "INPUT",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "ACCEPT"],
capture_output=True, timeout=5
)
else:
logger.debug("iptables not available; users must access port 5000 directly")
except Exception as e:
logger.warning(f"Could not set up iptables port forwarding: {e}")
# Verify the AP is actually running
status = self._get_ap_status_nmcli()
if status.get('active'):
ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:5000", duration=10)
return True, f"AP mode enabled (open network) - Access at {ip}:5000"
else:
logger.error("AP mode started but not verified by status check")
return False, "AP mode started but verification failed"
except Exception as e: except Exception as e:
logger.error(f"Error starting AP mode with nmcli hotspot: {e}") logger.error(f"Error starting AP mode with nmcli: {e}")
self._show_led_message("Setup mode error", duration=5) self._show_led_message("Setup mode error", duration=5)
return False, str(e) return False, str(e)
@@ -2153,13 +2064,36 @@ class WiFiManager:
except Exception as e: except Exception as e:
logger.error(f"Final WiFi radio unblock attempt failed: {e}") logger.error(f"Final WiFi radio unblock attempt failed: {e}")
else: else:
# nmcli hotspot mode - restart not needed, just ensure WiFi radio is enabled # nmcli AP mode - restart not needed, but clean up iptables rules
logger.info("Skipping NetworkManager restart (nmcli hotspot mode, restart not needed)") # (we add these in _enable_ap_mode_nmcli_hotspot for captive portal)
# Still ensure WiFi radio is enabled (may have been disabled by nmcli operations) logger.info("Skipping NetworkManager restart (nmcli AP mode, restart not needed)")
# Use retries for safety try:
iptables_check = subprocess.run(["which", "iptables"],
capture_output=True, timeout=2)
if iptables_check.returncode == 0:
subprocess.run(
["sudo", "iptables", "-t", "nat", "-D", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "REDIRECT", "--to-port", "5000"],
capture_output=True, timeout=5
)
subprocess.run(
["sudo", "iptables", "-D", "INPUT",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "ACCEPT"],
capture_output=True, timeout=5
)
subprocess.run(
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=0"],
capture_output=True, timeout=5
)
logger.info("Removed iptables port forwarding rules (nmcli path)")
except Exception as e:
logger.warning(f"Could not remove iptables rules (nmcli path): {e}")
# Ensure WiFi radio is enabled after nmcli operations
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3) wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
if not wifi_enabled: if not wifi_enabled:
logger.warning("WiFi radio may be disabled after nmcli hotspot cleanup") logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
logger.info("AP mode disabled successfully") logger.info("AP mode disabled successfully")
return True, "AP mode disabled" return True, "AP mode disabled"

View File

@@ -7,7 +7,7 @@ Wants=network.target
Type=simple Type=simple
User=root User=root
WorkingDirectory=__PROJECT_ROOT_DIR__ WorkingDirectory=__PROJECT_ROOT_DIR__
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/scripts/utils/wifi_monitor_daemon.py --interval 30 ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
StandardOutput=syslog StandardOutput=syslog