4 Commits

Author SHA1 Message Date
Chuck
15fc9003ac fix: address three wifi_manager and one plugins_manager review findings
wifi_manager.py:
- _create_hostapd_config: use _validate_ap_config() for ssid/channel instead
  of raw self.config values; strip newlines from SSID to prevent config-file
  injection via the generated hostapd.conf
- _setup_iptables_redirect: check return codes of sysctl ip_forward enable and
  both iptables -A calls; on any failure log the error output, call
  _teardown_iptables_redirect() to restore state, and return False instead of
  silently succeeding
- _enable_ap_mode_nmcli_hotspot: on AP verification failure roll back fully —
  tear down iptables redirect, delete the LEDMatrix-Setup-AP connection profile,
  clear the LED message — before returning False

plugins_manager.js:
- initializePlugins: chain searchPluginStore(!isReswapWarm) inside
  loadInstalledPlugins().then() so window.installedPlugins is populated before
  the store renders Installed/Reinstall badges (same pattern applied to
  refreshPlugins() in the previous commit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 18:33:53 -04:00
Chuck
55a6a53fca fix(plugins): fix async race in refreshPlugins; use cache TTL to gate re-swap metadata fetch
refreshPlugins() called searchPluginStore(true) and showNotification() immediately
after refreshInstalledPlugins() without awaiting the returned Promise, so
window.installedPlugins could still be stale when the store rendered its
Installed/Reinstall badges. Chain .then() so both run only after the fetch
completes.

In initializePlugins(), the re-swap path always passed fetchCommitInfo=false to
searchPluginStore, skipping GitHub metadata even when the 5-minute cache TTL had
expired. Add storeCacheExpired() helper and compute isReswapWarm = _reswap &&
!storeCacheExpired() so fresh metadata is fetched whenever the cache is cold,
regardless of whether the render is a first load or a tab re-swap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 18:33:53 -04:00
Chuck
c54718af2d fix(wifi): address Codacy review findings in AP mode implementation
- Validate ap_ssid/ap_channel from config before passing to subprocess
  (printable ASCII ≤32 chars; channel 1-14) to prevent command injection

- Fix INPUT iptables rule: PREROUTING redirects port 80→5000 so the INPUT
  chain sees dport=5000, not 80. Old INPUT rule on port 80 was a no-op.

- Refactor iptables setup/teardown into _setup_iptables_redirect() and
  _teardown_iptables_redirect() helpers, eliminating duplicate logic in
  the hostapd and nmcli paths

- Save/restore ip_forward state (via /tmp/ledmatrix_ip_forward_saved)
  instead of forcing it to 0 on cleanup, which could break VPNs or
  bridges already relying on forwarding

- nmcli path skips ip_forward management entirely: NM's ipv4.method=shared
  already manages it for the duration of the connection

- Fix _get_ap_status_nmcli() verification: new 'connection add type wifi'
  profiles have type '802-11-wireless', not 'hotspot', so verification was
  always returning False. Now also matches by our known connection name.

- Remove SSID-based connection deletion: deleting any profile whose SSID
  matched the AP SSID could destroy a user's saved home WiFi profile.
  Now only deletes by our application-managed profile names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 18:33:53 -04:00
Chuck
e8afd23c98 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>
2026-04-30 18:33:53 -04:00
3 changed files with 249 additions and 331 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": []
@@ -659,6 +657,134 @@ class WiFiManager:
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
return False return False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved")
def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
import re as _re
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
if not ssid or len(ssid) > 32 or not _re.match(r'^[\x20-\x7E]+$', ssid):
logger.warning(f"AP SSID '{ssid}' is invalid, falling back to default")
ssid = DEFAULT_AP_SSID
try:
channel = int(self.config.get("ap_channel", DEFAULT_AP_CHANNEL))
if channel < 1 or channel > 14:
raise ValueError
except (TypeError, ValueError):
logger.warning("AP channel out of range, falling back to default")
channel = DEFAULT_AP_CHANNEL
return ssid, channel
def _setup_iptables_redirect(self) -> bool:
"""
Add iptables rules that redirect port 80 → Flask on 5000 for the captive portal.
The INPUT rule must accept port 5000 (post-redirect destination), not port 80.
ip_forward state is saved to disk before enabling; call _teardown_iptables_redirect
to restore it.
Returns True if rules were applied.
"""
try:
if subprocess.run(["which", "iptables"], capture_output=True,
timeout=2).returncode != 0:
logger.debug("iptables unavailable; captive portal requires direct port-5000 access")
return False
# Save current ip_forward state so we can restore it exactly on teardown
fwd = subprocess.run(["sysctl", "-n", "net.ipv4.ip_forward"],
capture_output=True, text=True, timeout=3)
saved = fwd.stdout.strip() if fwd.returncode == 0 else "0"
try:
self._IP_FORWARD_SAVE_PATH.write_text(saved)
except OSError:
pass # non-fatal; restore will fall back to "0"
sysctl_r = subprocess.run(
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=1"],
capture_output=True, text=True, timeout=5
)
if sysctl_r.returncode != 0:
logger.error(f"Failed to enable ip_forward: {sysctl_r.stderr.strip()}")
self._teardown_iptables_redirect()
return False
# PREROUTING: redirect HTTP → Flask
if 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
).returncode != 0:
add_r = subprocess.run(
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "REDIRECT", "--to-port", "5000"],
capture_output=True, text=True, timeout=5
)
if add_r.returncode != 0:
logger.error(f"Failed to add PREROUTING rule: {add_r.stderr.strip()}")
self._teardown_iptables_redirect()
return False
# INPUT: accept traffic on port 5000 (the post-redirect destination port)
if subprocess.run(
["sudo", "iptables", "-C", "INPUT",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
"-j", "ACCEPT"],
capture_output=True, timeout=5
).returncode != 0:
add_r = subprocess.run(
["sudo", "iptables", "-A", "INPUT",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
"-j", "ACCEPT"],
capture_output=True, text=True, timeout=5
)
if add_r.returncode != 0:
logger.error(f"Failed to add INPUT rule: {add_r.stderr.strip()}")
self._teardown_iptables_redirect()
return False
logger.info("iptables: port 80→5000 redirect and INPUT accept-5000 rules added")
return True
except Exception as e:
logger.warning(f"Could not set up iptables redirect: {e}")
return False
def _teardown_iptables_redirect(self) -> None:
"""Remove the port 80→5000 iptables rules and restore the saved ip_forward state."""
try:
if subprocess.run(["which", "iptables"], capture_output=True,
timeout=2).returncode != 0:
return
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", "5000",
"-j", "ACCEPT"],
capture_output=True, timeout=5
)
# Restore ip_forward to whatever it was before we touched it
try:
saved = self._IP_FORWARD_SAVE_PATH.read_text().strip()
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
except OSError:
saved = "0"
subprocess.run(["sudo", "sysctl", "-w", f"net.ipv4.ip_forward={saved}"],
capture_output=True, timeout=5)
logger.info(f"iptables redirect rules removed; ip_forward restored to {saved}")
except Exception as e:
logger.warning(f"Could not tear down iptables redirect: {e}")
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]: def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
""" """
Scan for available WiFi networks. Scan for available WiFi networks.
@@ -1649,63 +1775,14 @@ class WiFiManager:
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5) subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
return False, f"Failed to start dnsmasq: {result.stderr}" return False, f"Failed to start dnsmasq: {result.stderr}"
# Set up iptables port forwarding: redirect port 80 to 5000 # Set up iptables port forwarding (port 80 5000) and save ip_forward state
# This makes the captive portal work on standard HTTP port self._setup_iptables_redirect()
try:
# Check if iptables is available
iptables_check = subprocess.run(
["which", "iptables"],
capture_output=True,
timeout=2
)
if iptables_check.returncode == 0:
# Enable IP forwarding (needed for NAT)
subprocess.run(
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=1"],
capture_output=True,
timeout=5
)
# Add NAT rule to redirect port 80 to 5000 on WiFi interface
# First check if rule already exists
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:
# Rule doesn't exist, add it
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")
# Also allow incoming connections on port 80
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, port forwarding not set up")
logger.info("Note: Port 80 forwarding requires iptables. Users will need to access port 5000 directly.")
except Exception as e:
logger.warning(f"Could not set up iptables port forwarding: {e}")
# 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 +1793,103 @@ 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) ap_ssid, ap_channel = self._validate_ap_config()
# 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: # Delete only the specific application-managed AP profiles by name.
# 1. It's a hotspot type # Never delete by SSID — that would destroy a user's saved home network.
# 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"]: for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
subprocess.run( subprocess.run(["nmcli", "connection", "down", conn_name],
["nmcli", "connection", "down", conn_name], capture_output=True, timeout=5)
capture_output=True, subprocess.run(["nmcli", "connection", "delete", conn_name],
timeout=5 capture_output=True, timeout=10)
)
subprocess.run(
["nmcli", "connection", "delete", conn_name],
capture_output=True,
timeout=10
)
# Wait a moment for deletions to complete
time.sleep(1) time.sleep(1)
# Get AP settings from config # Create an open AP connection profile from scratch.
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID) # Using 'connection add' instead of 'device wifi hotspot' because the
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL) # hotspot subcommand always attaches a WPA2 PSK on Bookworm/Trixie and
# ignores post-creation security modifications.
# Use nmcli hotspot command (simpler, works with Broadcom chips) logger.info(f"Creating open AP with nmcli connection add: {ap_ssid} on "
# Open network (no password) for easy setup access f"{self._wifi_interface} (no password)")
logger.info(f"Creating open hotspot with nmcli: {ap_ssid} on {self._wifi_interface} (no password)")
# Note: Some NetworkManager versions add a default password to hotspots
# We'll create it and then immediately remove all security settings
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,
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
# 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
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: if self._is_trixie:
security_settings.append(("802-11-wireless-security.pmf", "disable")) cmd += ["802-11-wireless-security.pmf", "disable"]
logger.info("Trixie detected: disabling PMF for better client compatibility") logger.info("Trixie detected: disabling PMF for better client compatibility")
for setting, value in security_settings: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
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) if result.returncode != 0:
# We want 192.168.4.1 for consistency error_msg = result.stderr.strip() or result.stdout.strip()
subprocess.run( logger.error(f"Failed to create AP connection profile: {error_msg}")
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP", self._show_led_message("AP mode failed", duration=5)
"ipv4.addresses", "192.168.4.1/24", return False, f"Failed to create AP profile: {error_msg}"
"ipv4.method", "shared"],
capture_output=True,
text=True,
timeout=5
)
# Verify it's open logger.info("AP connection profile created, bringing it up...")
verify_result = subprocess.run( up_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"], ["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
capture_output=True, capture_output=True, text=True, timeout=20
timeout=10
) )
logger.info("Hotspot restarted with open network settings") if up_result.returncode != 0:
logger.info(f"AP mode started via nmcli hotspot: {ap_ssid}") 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) time.sleep(2)
# Verify hotspot is running # NM's ipv4.method=shared manages ip_forward automatically, so we only
# need to add the iptables port-redirect rules for the captive portal.
self._setup_iptables_redirect()
# Verify the AP is actually running
status = self._get_ap_status_nmcli() status = self._get_ap_status_nmcli()
if status.get('active'): if status.get('active'):
ip = status.get('ip', '192.168.4.1') ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip}") logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
self._show_led_message(f"Setup: {ip}", duration=5) self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:5000", duration=10)
return True, f"AP mode enabled (hotspot mode) - Access at {ip}:5000" return True, f"AP mode enabled (open network) - Access at {ip}:5000"
else: else:
logger.error("AP mode started but not verified") logger.error("AP mode started but not verified by status check — rolling back")
self._teardown_iptables_redirect()
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
capture_output=True, timeout=10)
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
capture_output=True, timeout=10)
self._clear_led_message()
return False, "AP mode started but verification failed" return False, "AP mode started but verification failed"
else:
error_msg = result.stderr.strip() or result.stdout.strip()
logger.error(f"Failed to start AP mode via nmcli: {error_msg}")
self._show_led_message("AP mode failed", duration=5)
return False, f"Failed to start AP mode: {error_msg}"
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)
@@ -1976,7 +1911,12 @@ class WiFiManager:
for line in result.stdout.strip().split('\n'): for line in result.stdout.strip().split('\n'):
parts = line.split(':') parts = line.split(':')
if len(parts) >= 2 and 'hotspot' in parts[1].lower(): if len(parts) < 2:
continue
conn_name = parts[0].strip()
conn_type = parts[1].strip().lower()
# Match our known AP profile name OR the legacy nmcli hotspot type
if conn_name == "LEDMatrix-Setup-AP" or 'hotspot' in conn_type:
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config) # Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
ip = '192.168.4.1' ip = '192.168.4.1'
interface = parts[2] if len(parts) > 2 else self._wifi_interface interface = parts[2] if len(parts) > 2 else self._wifi_interface
@@ -2072,45 +2012,9 @@ class WiFiManager:
except Exception as e: except Exception as e:
logger.warning(f"Could not remove dnsmasq drop-in config: {e}") logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode) # Remove iptables redirect rules and restore ip_forward state (hostapd mode only)
if hostapd_active: if hostapd_active:
try: self._teardown_iptables_redirect()
# Check if iptables is available
iptables_check = subprocess.run(
["which", "iptables"],
capture_output=True,
timeout=2
)
if iptables_check.returncode == 0:
# Remove NAT redirect rule
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
)
# Remove INPUT rule
subprocess.run(
["sudo", "iptables", "-D", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
capture_output=True,
timeout=5
)
logger.info("Removed iptables port forwarding rules")
else:
logger.debug("iptables not available, skipping rule removal")
# Disable IP forwarding (restore to default client mode)
subprocess.run(
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=0"],
capture_output=True,
timeout=5
)
logger.info("Disabled IP forwarding")
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
logger.warning(f"Could not remove iptables rules or disable forwarding: {e}")
# Continue anyway
# Clean up WiFi interface IP configuration # Clean up WiFi interface IP configuration
subprocess.run( subprocess.run(
@@ -2153,13 +2057,14 @@ 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 — NM's ipv4.method=shared manages ip_forward automatically,
logger.info("Skipping NetworkManager restart (nmcli hotspot mode, restart not needed)") # so we only need to remove the iptables redirect rules we added.
# 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 self._teardown_iptables_redirect()
# 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"
@@ -2176,8 +2081,10 @@ class WiFiManager:
config_dir = HOSTAPD_CONFIG_PATH.parent config_dir = HOSTAPD_CONFIG_PATH.parent
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID) # Use validated values — strips invalid chars and ensures channel is an int.
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL) # Also strip newlines from SSID to prevent config-file injection.
ap_ssid, ap_channel = self._validate_ap_config()
ap_ssid = ap_ssid.replace('\n', '').replace('\r', '')
# Open network configuration (no password) for easy setup access # Open network configuration (no password) for easy setup access
config_content = f"""interface={self._wifi_interface} config_content = f"""interface={self._wifi_interface}

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

View File

@@ -898,6 +898,10 @@ window.currentPluginConfig = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
let storeFilteredList = []; let storeFilteredList = [];
function storeCacheExpired() {
return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION);
}
// ── Plugin Store Filter State ─────────────────────────────────────────── // ── Plugin Store Filter State ───────────────────────────────────────────
const storeFilterState = { const storeFilterState = {
sort: safeLocalStorage.getItem('storeSort') || 'a-z', sort: safeLocalStorage.getItem('storeSort') || 'a-z',
@@ -1214,12 +1218,16 @@ function initializePlugins() {
} }
// Load both installed plugins and plugin store. // Load both installed plugins and plugin store.
// On HTMX re-swaps use cached store data (fetchCommitInfo=false) to avoid // On HTMX re-swaps with a still-warm cache, skip GitHub metadata to avoid
// re-hitting GitHub on every tab switch; only fetch fresh on first load. // re-hitting the API on every tab switch. If the cache TTL has expired even
const isReswap = !!window.pluginManager._reswap; // during a re-swap, fetch fresh data including GitHub commit/version info.
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
window.pluginManager._reswap = false; window.pluginManager._reswap = false;
loadInstalledPlugins(); // Await the installed-plugins fetch so window.installedPlugins is populated before
searchPluginStore(!isReswap); // searchPluginStore renders Installed/Reinstall badges against it.
loadInstalledPlugins().then(() => {
searchPluginStore(!isReswapWarm);
});
// Setup search functionality (with guard against duplicate listeners) // Setup search functionality (with guard against duplicate listeners)
const searchInput = document.getElementById('plugin-search'); const searchInput = document.getElementById('plugin-search');
@@ -5133,10 +5141,13 @@ function refreshPlugins() {
pluginStoreCache = null; pluginStoreCache = null;
cacheTimestamp = null; cacheTimestamp = null;
refreshInstalledPlugins(); // invalidates cache before fetching // refreshInstalledPlugins() is async (returns a Promise via loadInstalledPlugins).
// Fetch latest metadata from GitHub when refreshing // Only search the store and notify after window.installedPlugins is updated so
// that Installed/Reinstall badges reflect the freshly fetched state.
refreshInstalledPlugins().then(() => {
searchPluginStore(true); searchPluginStore(true);
showNotification('Plugins refreshed with latest metadata from GitHub', 'success'); showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
});
} }
function restartDisplay() { function restartDisplay() {