9 Commits

Author SHA1 Message Date
Chuck
1a3e6f0685 fix: address five review findings (NM retry loop, start_display message, code quality)
- wifi_monitor_daemon: reset _consecutive_internet_failures = 0 in both
  NM-restart exception handlers; previously both left the counter at threshold,
  causing an immediate retry on the next iteration instead of waiting another
  full backoff period

- api_v3: fix start_display failure message — when mode is set and systemctl
  returns non-zero, message now includes the failure reason and a hint rather
  than always reporting success phrasing

- wifi_manager: move _redirect_backend from class variable to instance variable
  in __init__ alongside _ap_enabled_at; class-level default shadowed correctly
  in practice (single instance) but was misleading

- wifi_manager: narrow broad except Exception in _check_internet_connectivity
  to (subprocess.SubprocessError, OSError) for ping and OSError for HTTP
  (urllib.error.URLError is an OSError subclass in Python 3)

- wifi_manager: remove redundant local 'import re as _re' in _validate_ap_config;
  re is already imported at module level (line 37)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:25:12 -04:00
Chuck
5b6137f5f4 fix: address five valid review findings; skip two
Fixed:
- march-madness/requirements.txt: Pillow>=10.3.0 (patches CVE-2024-28219;
  10.3.0 is the actual fix version — reviewer cited 12.2.0 but that risks
  breaking API changes without test coverage)
- wifi_monitor_daemon.py: add missing `import subprocess`; subprocess.run
  and CalledProcessError would NameError at runtime on the NM restart path
- wifi_manager.py: validate ap_idle_timeout_minutes before arithmetic —
  coerce to int, clamp 1–1440, fall back to 15 on bad config values
- wifi_manager.py: call _remove_nm_dnsmasq_captive_conf() on all three
  rollback paths in _enable_ap_mode_nmcli_hotspot() and in the top-level
  except block so stale dnsmasq drop-ins are never left behind
- api_v3.py: fix wrong_password prefix strip — removeprefix("wrong_password:")
  then lstrip() handles both "wrong_password: msg" and "wrong_password:msg"
- plugins_manager.js: add .catch() to loadInstalledPlugins().then() to
  surface failures instead of silently dropping unhandled rejections

Skipped:
- WiFiManager AP state persistence: architectural overhaul; _is_ap_mode_active()
  already derives from live system state, not in-memory variables
- Absolute subprocess paths in api_v3.py: paths vary by distro (/usr/bin vs
  /bin); web service has a normal PATH; sudoers already use resolved paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:27:41 -04:00
Chuck
f97573c368 revert: restore AP-mode grace period to 90s (3 checks)
The counter reset after NM restart already fully prevents the SSH-lockout
cascade: _disconnected_checks can never accumulate across NM restarts
because it is reset to 0 before the next daemon iteration runs.

The 3→6 increase provided no additional fix for the described problem and
caused a UX regression: fresh Pi devices with no WiFi configured would
wait 3 minutes instead of 90 seconds for the LEDMatrix-Setup hotspot to
appear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:03:17 -04:00
Chuck
9a74db6de3 fix: service control buttons and AP-mode SSH lockout post-install
Two user-reported issues after fresh install:

1. All service buttons (Start/Stop/Restart Display, Restart Web Service)
   failed silently — only Reboot worked.

   Root cause: sudoers rules use `ledmatrix.service` (with suffix) but
   api_v3.py called `sudo systemctl start ledmatrix` (no suffix). sudo
   does exact string matching, so every service action was rejected with
   returncode=1. Also missing from sudoers: ledmatrix-web, journalctl,
   and is-active entries.

   Fix:
   - Add `.service` suffix to all 8 sudo systemctl call sites in
     api_v3.py (_ensure_display_service_running, _stop_display_service,
     and all execute_system_action branches).
   - Add timeout=15 to all subprocess.run calls in execute_system_action
     (previously could hang indefinitely).
   - Add missing sudoers rules to first_time_install.sh and
     configure_web_sudo.sh: ledmatrix-web.service start/stop/restart,
     is-active for both name forms, and journalctl -u/-t ledmatrix rules.

2. SSH and web UI became inaccessible after ~1 hour even though the
   display kept running.

   Root cause: wifi_monitor_daemon restarts NetworkManager after 5
   consecutive internet failures (~2.5 min). Each NM restart drops WiFi
   briefly. During that window check_and_manage_ap_mode() increments
   _disconnected_checks but the daemon never reset it after the restart.
   After 3 such NM-restart cycles, _disconnected_checks reached 3 and
   AP mode activated — changing the Pi from WiFi client to hotspot
   (192.168.4.1) and killing SSH on the old IP.

   Fix:
   - Reset wifi_manager._disconnected_checks = 0 in the daemon
     immediately after a successful NM restart so the brief drop it
     causes doesn't count toward AP-mode activation.
   - Increase _disconnected_checks_required from 3 to 6 (90s → 3min)
     as an additional buffer against transient network flaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:47:56 -04:00
Chuck
b7295129b5 fix(security): escape plugin_id in XSS-vulnerable 404 partial; bump Pillow past CVE-2023-50447
pages_v3.py: plugin_id is taken directly from the URL path and was
interpolated into a returned HTML fragment without escaping. A crafted
URL like /partials/plugin-config/<script>alert(1)</script> would inject
arbitrary HTML into any page that loads this HTMX partial.
Fix: wrap with html.escape() from the stdlib.

march-madness/requirements.txt: Pillow>=9.1.0 is vulnerable to
CVE-2023-50447 (arbitrary code execution via the environment parameter).
Bump minimum to >=10.2.0 which contains the fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 08:32:17 -04:00
Chuck
3e94bb9664 fix(js): use Object.prototype.hasOwnProperty.call in day-selector widget
Direct .hasOwnProperty() calls on objects can be shadowed if the object
itself has a property named hasOwnProperty. Using Object.prototype.
hasOwnProperty.call(obj, key) is the safe, ESLint-compliant form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 08:27:51 -04:00
Chuck
44316d3bae fix(wifi): public check_internet_connectivity(); absolute systemctl path; stricter mode assertion
wifi_manager.py:
- Add public check_internet_connectivity() wrapping the private method so the
  daemon does not reach into the private API

wifi_monitor_daemon.py:
- Call wifi_manager.check_internet_connectivity() instead of the private
  _check_internet_connectivity()
- Use /usr/bin/systemctl (absolute path) instead of bare "systemctl"
- Wrap NM restart in try/except with check=True; only reset
  _consecutive_internet_failures on success — on CalledProcessError or other
  exception, log the error and leave the counter unchanged so the next cycle
  retries

test/test_wifi_manager_ap.py:
- Replace loose `assert "ap" in add_calls[0]` (list-membership check that
  could be satisfied by any element equal to "ap") with an explicit key/value
  check: locate "802-11-wireless.mode" in the command list and assert the next
  element is exactly "ap"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 08:22:13 -04:00
Chuck
baebe4f5f7 fix(wifi): add nftables fallback for port redirect; graceful degradation when neither available
Tested on devpi (Trixie, NM 1.52.1): iptables is not installed; nftables is.
The original code called _setup_iptables_redirect() and treated 'iptables not
found' as a hard failure, rolling back the entire AP setup.

Changes:
- _setup_iptables_redirect() now tries iptables first, then nftables as a
  fallback. When neither is available it logs a warning and returns True so
  the AP still comes up (DNS spoofing still triggers the captive portal popup;
  users land on port 5000 directly instead of being auto-redirected from 80).
- Split into _setup_iptables_redirect_iptables() and
  _setup_iptables_redirect_nftables() for clarity.
- Added _redirect_backend instance var ("iptables" | "nftables" | None) so
  _teardown_iptables_redirect() uses the same tool that setup used.
- nftables teardown: deletes the 'ledmatrix' table (clean, no leftover rules).
- iptables teardown: unchanged logic (ip_forward save/restore).
- Also removed the PMF workaround for Trixie: 802-11-wireless-security.pmf
  requires key-mgmt to also be set, breaking open-network creation on NM 1.52+.
  Open APs have no management frame protection by definition.
- Update teardown test to set _redirect_backend = "iptables" before calling it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 11:23:12 -04:00
Chuck
fccd6e70be fix(wifi): remove PMF setting from open AP profile — breaks nmcli connection add on Trixie NM 1.52+
802-11-wireless-security.pmf is only valid within a security section that also
includes key-mgmt. Adding it to an open-network profile causes NM 1.52+ to
reject the connection add with 'key-mgmt: property is missing'. PMF has no
meaning for open APs (it only applies to WPA2/WPA3), so the setting is simply
removed rather than worked around.

Found by testing on devpi (Trixie, NM 1.52.1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 11:20:27 -04:00
10 changed files with 241 additions and 156 deletions

View File

@@ -1086,6 +1086,7 @@ SYSTEMCTL_PATH=$(which systemctl)
REBOOT_PATH=$(which reboot) REBOOT_PATH=$(which reboot)
POWEROFF_PATH=$(which poweroff) POWEROFF_PATH=$(which poweroff)
BASH_PATH=$(which bash) BASH_PATH=$(which bash)
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
# Create sudoers content # Create sudoers content
cat > /tmp/ledmatrix_web_sudoers << EOF cat > /tmp/ledmatrix_web_sudoers << EOF
@@ -1101,10 +1102,22 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py $ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh $ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh $ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
EOF EOF
if [ -n "$JOURNALCTL_PATH" ]; then
cat >> /tmp/ledmatrix_web_sudoers << EOF
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
EOF
fi
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
echo "Sudoers configuration already up to date" echo "Sudoers configuration already up to date"

View File

@@ -1,4 +1,4 @@
requests>=2.28.0 requests>=2.28.0
Pillow>=9.1.0 Pillow>=10.3.0
pytz>=2022.1 pytz>=2022.1
numpy>=1.24.0 numpy>=1.24.0

View File

@@ -89,9 +89,9 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service" echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix" echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service" echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web" echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web" echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web" echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
# Optional: journalctl (non-critical — skip if not found) # Optional: journalctl (non-critical — skip if not found)
if [ -n "$JOURNALCTL_PATH" ]; then if [ -n "$JOURNALCTL_PATH" ]; then

View File

@@ -10,6 +10,7 @@ import sys
import time import time
import logging import logging
import signal import signal
import subprocess
from pathlib import Path from pathlib import Path
# Add project root to path (parent of scripts/utils/) # Add project root to path (parent of scripts/utils/)
@@ -132,7 +133,7 @@ class WiFiMonitorDaemon:
# AP-enable trigger clean and avoid false-positive AP enables from # AP-enable trigger clean and avoid false-positive AP enables from
# transient packet loss on otherwise working WiFi. # transient packet loss on otherwise working WiFi.
if updated_status.connected and not updated_status.ap_mode_active: if updated_status.connected and not updated_status.ap_mode_active:
if not self.wifi_manager._check_internet_connectivity(): if not self.wifi_manager.check_internet_connectivity():
self._consecutive_internet_failures += 1 self._consecutive_internet_failures += 1
logger.warning( logger.warning(
f"Internet unreachable despite nmcli connection " f"Internet unreachable despite nmcli connection "
@@ -140,9 +141,23 @@ class WiFiMonitorDaemon:
) )
if self._consecutive_internet_failures >= self._nm_restart_threshold: if self._consecutive_internet_failures >= self._nm_restart_threshold:
logger.warning("Restarting NetworkManager to recover internet connectivity") logger.warning("Restarting NetworkManager to recover internet connectivity")
import subprocess as _sp try:
_sp.run(["sudo", "systemctl", "restart", "NetworkManager"], subprocess.run(
capture_output=True, timeout=20) ["/usr/bin/systemctl", "restart", "NetworkManager"],
capture_output=True, timeout=20, check=True
)
self._consecutive_internet_failures = 0
# NM restart causes a brief WiFi drop; reset the AP-mode grace
# counter so that transient disconnect doesn't count toward
# triggering AP mode.
self.wifi_manager._disconnected_checks = 0
except subprocess.CalledProcessError as e:
logger.error(f"NetworkManager restart failed (rc={e.returncode}); "
"resetting failure counter to avoid tight retry loop")
self._consecutive_internet_failures = 0
except Exception as e:
logger.error(f"NetworkManager restart error: {e}; "
"resetting failure counter to avoid tight retry loop")
self._consecutive_internet_failures = 0 self._consecutive_internet_failures = 0
else: else:
self._consecutive_internet_failures = 0 self._consecutive_internet_failures = 0

View File

@@ -144,6 +144,8 @@ class WiFiManager:
# Timestamp set when AP mode is enabled; used for the idle-timeout check # Timestamp set when AP mode is enabled; used for the idle-timeout check
self._ap_enabled_at: Optional[float] = None self._ap_enabled_at: Optional[float] = None
# Which redirect backend was used (iptables/nftables/None); set per-instance
self._redirect_backend: Optional[str] = None
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, " logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, " f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
@@ -691,9 +693,8 @@ class WiFiManager:
def _validate_ap_config(self) -> Tuple[str, int]: def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults.""" """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)) 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): 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") logger.warning(f"AP SSID '{ssid}' is invalid, falling back to default")
ssid = DEFAULT_AP_SSID ssid = DEFAULT_AP_SSID
try: try:
@@ -707,113 +708,133 @@ class WiFiManager:
def _setup_iptables_redirect(self) -> bool: def _setup_iptables_redirect(self) -> bool:
""" """
Add iptables rules that redirect port 80 → Flask on 5000 for the captive portal. Add port 80 → 5000 redirect rules for the captive portal.
The INPUT rule must accept port 5000 (the post-redirect destination), not port 80.
Uses _find_command_path() so binaries in /sbin or /usr/sbin are resolved even Tries iptables first, falls back to nftables (used by Debian Trixie).
when those directories are absent from PATH in service environments. When neither tool is available, logs a warning and returns True — the AP
still works and DNS spoofing still triggers the OS popup; users just land
on port 5000 directly rather than being redirected from port 80.
Reads ip_forward from /proc (no subprocess, always reliable), saves it to disk Only returns False when a tool was found but the rule addition itself failed.
only when the read succeeds, and skips the sysctl write if the value is already
"1" to avoid mutating global state unnecessarily. Teardown will only restore the
saved value when the save file is actually present.
Returns True if all rules were applied successfully.
""" """
try: try:
iptables = self._find_command_path("iptables") iptables = self._find_command_path("iptables")
if not iptables: nft = self._find_command_path("nft")
logger.debug("iptables unavailable; captive portal requires direct port-5000 access")
if not iptables and not nft:
logger.warning(
"Neither iptables nor nft found; captive portal port-80 redirect unavailable. "
"DNS spoofing will still trigger the OS popup but HTTP on port 80 won't reach Flask."
)
self._redirect_backend = None
return True # AP works; redirect is best-effort
if iptables:
return self._setup_iptables_redirect_iptables(iptables)
else:
return self._setup_iptables_redirect_nftables(nft)
except Exception as e:
logger.warning(f"Could not set up port redirect: {e}")
try:
self._teardown_iptables_redirect()
except Exception as cleanup_e:
logger.warning(f"Cleanup after redirect exception also failed: {cleanup_e}")
return False return False
# Read ip_forward from /proc — reliable with no subprocess or PATH dependency. def _setup_iptables_redirect_iptables(self, iptables: str) -> bool:
"""Set up port 80→5000 redirect using iptables."""
# Save ip_forward state before enabling
try: try:
current_fwd = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip() current_fwd = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip()
except OSError: except OSError:
current_fwd = None # can't read → don't save, teardown won't restore current_fwd = None
# Persist the original value only when we could read it.
# If the write fails, leave the save file absent so teardown skips the restore
# rather than unconditionally forcing "0" (which could break VPNs/bridges).
if current_fwd is not None: if current_fwd is not None:
try: try:
self._IP_FORWARD_SAVE_PATH.write_text(current_fwd) self._IP_FORWARD_SAVE_PATH.write_text(current_fwd)
except OSError: except OSError:
current_fwd = None # treat as unsaved; teardown will skip restore current_fwd = None
logger.warning("Could not write ip_forward save file; state will not be restored") logger.warning("Could not write ip_forward save file; state will not be restored")
# Enable ip_forward only when it isn't already set, to avoid mutating state
# that another service (e.g. NetworkManager shared mode, a VPN) already owns.
if current_fwd != "1": if current_fwd != "1":
sysctl = self._find_command_path("sysctl") sysctl = self._find_command_path("sysctl")
sysctl_bin = sysctl if sysctl else "sysctl" sysctl_bin = sysctl if sysctl else "sysctl"
sysctl_r = subprocess.run( r = subprocess.run(["sudo", sysctl_bin, "-w", "net.ipv4.ip_forward=1"],
["sudo", sysctl_bin, "-w", "net.ipv4.ip_forward=1"], capture_output=True, text=True, timeout=5)
capture_output=True, text=True, timeout=5 if r.returncode != 0:
) logger.error(f"Failed to enable ip_forward: {r.stderr.strip()}")
if sysctl_r.returncode != 0:
logger.error(f"Failed to enable ip_forward: {sysctl_r.stderr.strip()}")
self._teardown_iptables_redirect() self._teardown_iptables_redirect()
return False return False
# PREROUTING: redirect HTTP → Flask
if subprocess.run( if subprocess.run(
["sudo", iptables, "-t", "nat", "-C", "PREROUTING", ["sudo", iptables, "-t", "nat", "-C", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "REDIRECT", "--to-port", "5000"], "-j", "REDIRECT", "--to-port", "5000"],
capture_output=True, timeout=5 capture_output=True, timeout=5
).returncode != 0: ).returncode != 0:
add_r = subprocess.run( r = subprocess.run(
["sudo", iptables, "-t", "nat", "-A", "PREROUTING", ["sudo", iptables, "-t", "nat", "-A", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-j", "REDIRECT", "--to-port", "5000"], "-j", "REDIRECT", "--to-port", "5000"],
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5
) )
if add_r.returncode != 0: if r.returncode != 0:
logger.error(f"Failed to add PREROUTING rule: {add_r.stderr.strip()}") logger.error(f"Failed to add PREROUTING rule: {r.stderr.strip()}")
self._teardown_iptables_redirect() self._teardown_iptables_redirect()
return False return False
# INPUT: accept traffic on port 5000 (the post-redirect destination port)
if subprocess.run( if subprocess.run(
["sudo", iptables, "-C", "INPUT", ["sudo", iptables, "-C", "INPUT",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
"-j", "ACCEPT"],
capture_output=True, timeout=5 capture_output=True, timeout=5
).returncode != 0: ).returncode != 0:
add_r = subprocess.run( r = subprocess.run(
["sudo", iptables, "-A", "INPUT", ["sudo", iptables, "-A", "INPUT",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
"-j", "ACCEPT"],
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5
) )
if add_r.returncode != 0: if r.returncode != 0:
logger.error(f"Failed to add INPUT rule: {add_r.stderr.strip()}") logger.error(f"Failed to add INPUT rule: {r.stderr.strip()}")
self._teardown_iptables_redirect() self._teardown_iptables_redirect()
return False return False
logger.info("iptables: port 80→5000 redirect and INPUT accept-5000 rules added") self._redirect_backend = "iptables"
logger.info("iptables: port 80→5000 redirect rules added")
return True return True
except Exception as e:
logger.warning(f"Could not set up iptables redirect: {e}") def _setup_iptables_redirect_nftables(self, nft: str) -> bool:
try: """Set up port 80→5000 redirect using nftables (Debian Trixie / modern systems)."""
# NM's ipv4.method=shared already enables ip_forward; no sysctl needed.
cmds = [
["sudo", nft, "add", "table", "ip", "ledmatrix"],
["sudo", nft, "add", "chain", "ip", "ledmatrix", "prerouting",
"{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}"],
["sudo", nft, "add", "rule", "ip", "ledmatrix", "prerouting",
"iif", self._wifi_interface, "tcp", "dport", "80", "redirect", "to", ":5000"],
]
for cmd in cmds:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if r.returncode != 0:
# Table/chain may already exist — only fail on rule add
if "add rule" in " ".join(cmd):
logger.error(f"Failed to add nftables redirect rule: {r.stderr.strip()}")
self._teardown_iptables_redirect() self._teardown_iptables_redirect()
except Exception as cleanup_e:
logger.warning(f"Cleanup after iptables redirect exception also failed: {cleanup_e}")
return False return False
logger.debug(f"nft cmd non-zero (may already exist): {r.stderr.strip()}")
self._redirect_backend = "nftables"
logger.info("nftables: port 80→5000 redirect rule added")
return True
def _teardown_iptables_redirect(self) -> None: def _teardown_iptables_redirect(self) -> None:
"""Remove the port 80→5000 iptables rules and restore the saved ip_forward state. """Remove the port 80→5000 redirect rules and restore ip_forward if saved."""
ip_forward is only restored when the save file written by _setup_iptables_redirect
is present. If the file is absent (save was skipped or failed), ip_forward is
left untouched to avoid forcing "0" onto state owned by another service.
"""
try: try:
iptables = self._find_command_path("iptables") backend = self._redirect_backend
if not iptables: self._redirect_backend = None
return
if backend == "iptables":
iptables = self._find_command_path("iptables")
if iptables:
subprocess.run( subprocess.run(
["sudo", iptables, "-t", "nat", "-D", "PREROUTING", ["sudo", iptables, "-t", "nat", "-D", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
@@ -826,9 +847,7 @@ class WiFiManager:
"-j", "ACCEPT"], "-j", "ACCEPT"],
capture_output=True, timeout=5 capture_output=True, timeout=5
) )
# Restore ip_forward only when we saved it
# Only restore ip_forward when we have a saved value from setup.
# If the save file is absent the state was never changed here, so leave it.
if self._IP_FORWARD_SAVE_PATH.exists(): if self._IP_FORWARD_SAVE_PATH.exists():
try: try:
saved = self._IP_FORWARD_SAVE_PATH.read_text().strip() saved = self._IP_FORWARD_SAVE_PATH.read_text().strip()
@@ -837,13 +856,27 @@ class WiFiManager:
sysctl_bin = sysctl if sysctl else "sysctl" sysctl_bin = sysctl if sysctl else "sysctl"
subprocess.run(["sudo", sysctl_bin, "-w", f"net.ipv4.ip_forward={saved}"], subprocess.run(["sudo", sysctl_bin, "-w", f"net.ipv4.ip_forward={saved}"],
capture_output=True, timeout=5) capture_output=True, timeout=5)
logger.info(f"iptables redirect rules removed; ip_forward restored to {saved}") logger.info(f"ip_forward restored to {saved}")
except OSError as e: except OSError as e:
logger.warning(f"Could not restore ip_forward: {e}") logger.warning(f"Could not restore ip_forward: {e}")
else: else:
logger.info("iptables redirect rules removed; ip_forward left unchanged (not modified by setup)") logger.debug("ip_forward not modified by setup; leaving unchanged")
elif backend == "nftables":
nft = self._find_command_path("nft")
if nft:
subprocess.run(
["sudo", nft, "delete", "table", "ip", "ledmatrix"],
capture_output=True, timeout=5
)
logger.info("nftables ledmatrix table removed")
else:
# No redirect was set up (neither tool available); nothing to tear down
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
except Exception as e: except Exception as e:
logger.warning(f"Could not tear down iptables redirect: {e}") logger.warning(f"Could not tear down port redirect: {e}")
def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = "192.168.4.1") -> None: def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = "192.168.4.1") -> None:
""" """
@@ -900,18 +933,22 @@ class WiFiManager:
if r.returncode == 0: if r.returncode == 0:
logger.debug("Internet connectivity confirmed via ping 8.8.8.8") logger.debug("Internet connectivity confirmed via ping 8.8.8.8")
return True return True
except Exception: except (subprocess.SubprocessError, OSError):
pass pass
try: try:
import urllib.request as _ureq import urllib.request as _ureq
_ureq.urlopen("http://connectivity-check.ubuntu.com/", timeout=timeout) _ureq.urlopen("http://connectivity-check.ubuntu.com/", timeout=timeout)
logger.debug("Internet connectivity confirmed via HTTP check") logger.debug("Internet connectivity confirmed via HTTP check")
return True return True
except Exception: except OSError:
pass pass
logger.debug("Internet connectivity check failed (both ping and HTTP)") logger.debug("Internet connectivity check failed (both ping and HTTP)")
return False return False
def check_internet_connectivity(self, timeout: int = 5) -> bool:
"""Public wrapper around _check_internet_connectivity for use by the daemon."""
return self._check_internet_connectivity(timeout=timeout)
def _has_ap_clients(self) -> bool: def _has_ap_clients(self) -> bool:
""" """
Return True if at least one client is associated with the AP. Return True if at least one client is associated with the AP.
@@ -2007,10 +2044,10 @@ class WiFiManager:
# No 802-11-wireless-security section → open network # No 802-11-wireless-security section → open network
] ]
# On Trixie disable PMF which can prevent older clients from connecting # PMF (Protected Management Frames) is only meaningful for WPA2/WPA3.
if self._is_trixie: # An open AP has no security section, so adding 802-11-wireless-security.pmf
cmd += ["802-11-wireless-security.pmf", "disable"] # would cause NM to require key-mgmt too, breaking the connection add on
logger.info("Trixie detected: disabling PMF for better client compatibility") # Trixie NM 1.52+. Leave PMF untouched — open APs have no frame protection.
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
@@ -2034,6 +2071,7 @@ class WiFiManager:
if up_result.returncode != 0: if up_result.returncode != 0:
error_msg = up_result.stderr.strip() or up_result.stdout.strip() error_msg = up_result.stderr.strip() or up_result.stdout.strip()
logger.error(f"Failed to bring up AP connection: {error_msg}") logger.error(f"Failed to bring up AP connection: {error_msg}")
self._remove_nm_dnsmasq_captive_conf()
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"], subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
capture_output=True, timeout=10) capture_output=True, timeout=10)
self._show_led_message("AP mode failed", duration=5) self._show_led_message("AP mode failed", duration=5)
@@ -2045,6 +2083,7 @@ class WiFiManager:
# need to add the iptables port-redirect rules for the captive portal. # need to add the iptables port-redirect rules for the captive portal.
if not self._setup_iptables_redirect(): if not self._setup_iptables_redirect():
logger.error("Captive-portal redirect setup failed; rolling back AP profile") logger.error("Captive-portal redirect setup failed; rolling back AP profile")
self._remove_nm_dnsmasq_captive_conf()
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"], subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
capture_output=True, timeout=10) capture_output=True, timeout=10)
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"], subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
@@ -2062,6 +2101,7 @@ class WiFiManager:
else: else:
logger.error("AP mode started but not verified by status check — rolling back") logger.error("AP mode started but not verified by status check — rolling back")
self._teardown_iptables_redirect() self._teardown_iptables_redirect()
self._remove_nm_dnsmasq_captive_conf()
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"], subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
capture_output=True, timeout=10) capture_output=True, timeout=10)
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"], subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
@@ -2071,6 +2111,7 @@ class WiFiManager:
except Exception as e: except Exception as e:
logger.error(f"Error starting AP mode with nmcli: {e}") logger.error(f"Error starting AP mode with nmcli: {e}")
self._remove_nm_dnsmasq_captive_conf()
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)
@@ -2458,7 +2499,10 @@ address=/detectportal.firefox.com/192.168.4.1
# Idle-timeout check: disable AP if no client has connected within the window. # Idle-timeout check: disable AP if no client has connected within the window.
# Only applies when AP is active and we haven't just decided to enable/disable it. # Only applies when AP is active and we haven't just decided to enable/disable it.
if ap_active and self._ap_enabled_at is not None: if ap_active and self._ap_enabled_at is not None:
idle_timeout_min = self.config.get("ap_idle_timeout_minutes", 15) try:
idle_timeout_min = max(1, min(1440, int(self.config.get("ap_idle_timeout_minutes", 15))))
except (TypeError, ValueError):
idle_timeout_min = 15
elapsed = time.time() - self._ap_enabled_at elapsed = time.time() - self._ap_enabled_at
if elapsed > idle_timeout_min * 60 and not self._has_ap_clients(): if elapsed > idle_timeout_min * 60 and not self._has_ap_clients():
logger.info( logger.info(

View File

@@ -129,7 +129,15 @@ def test_nmcli_ap_profile_has_no_security_params(manager: WiFiManager) -> None:
assert "psk" not in add_str, "AP profile must not include a PSK/password" assert "psk" not in add_str, "AP profile must not include a PSK/password"
assert "wpa" not in add_str.lower(), "AP profile must not reference WPA" assert "wpa" not in add_str.lower(), "AP profile must not reference WPA"
assert "802-11-wireless.mode" in add_str, "AP profile must declare wireless mode" assert "802-11-wireless.mode" in add_str, "AP profile must declare wireless mode"
assert "ap" in add_calls[0], "Wireless mode value must be 'ap'" # Verify the value for 802-11-wireless.mode is exactly "ap" — check the element
# that immediately follows the key in the command list, not a loose substring match.
cmd = add_calls[0]
try:
mode_idx = cmd.index("802-11-wireless.mode")
assert cmd[mode_idx + 1] == "ap", \
f"802-11-wireless.mode value must be exactly 'ap', got {cmd[mode_idx + 1]!r}"
except ValueError:
pytest.fail("802-11-wireless.mode not found as a list element in nmcli command")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -193,6 +201,8 @@ def test_iptables_rules_and_ip_forward_reverted_on_teardown(manager: WiFiManager
""" """
original_fwd = "0" original_fwd = "0"
manager._IP_FORWARD_SAVE_PATH.write_text(original_fwd) manager._IP_FORWARD_SAVE_PATH.write_text(original_fwd)
# Teardown dispatches on the backend recorded during setup
manager._redirect_backend = "iptables"
captured: list[list[str]] = [] captured: list[list[str]] = []

View File

@@ -218,7 +218,7 @@ def _ensure_display_service_running():
if status.get('active'): if status.get('active'):
status['started'] = False status['started'] = False
return status return status
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix']) result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service'])
service_status = _get_display_service_status() service_status = _get_display_service_status()
result['started'] = result.get('returncode') == 0 result['started'] = result.get('returncode') == 0
result['active'] = service_status.get('active') result['active'] = service_status.get('active')
@@ -227,7 +227,7 @@ def _ensure_display_service_running():
def _stop_display_service(): def _stop_display_service():
"""Stop the ledmatrix display service.""" """Stop the ledmatrix display service."""
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix']) result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service'])
status = _get_display_service_status() status = _get_display_service_status()
result['active'] = status.get('active') result['active'] = status.get('active')
result['status'] = status result['status'] = status
@@ -1716,33 +1716,34 @@ def execute_system_action():
if mode: if mode:
# For on-demand modes, we would need to integrate with the display controller # For on-demand modes, we would need to integrate with the display controller
# For now, just start the display service # For now, just start the display service
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True) capture_output=True, text=True, timeout=15)
return jsonify({ return jsonify({
'status': 'success' if result.returncode == 0 else 'error', 'status': 'success' if result.returncode == 0 else 'error',
'message': f'Started display in {mode} mode', 'message': f'Started display in {mode} mode' if result.returncode == 0
else f'Failed to start display in {mode} mode: {result.stderr.strip() or "check sudo systemctl status ledmatrix.service"}',
'returncode': result.returncode, 'returncode': result.returncode,
'stdout': result.stdout, 'stdout': result.stdout,
'stderr': result.stderr 'stderr': result.stderr
}) })
else: else:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True) capture_output=True, text=True, timeout=15)
elif action == 'stop_display': elif action == 'stop_display':
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
capture_output=True, text=True) capture_output=True, text=True, timeout=15)
elif action == 'enable_autostart': elif action == 'enable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
capture_output=True, text=True) capture_output=True, text=True, timeout=15)
elif action == 'disable_autostart': elif action == 'disable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
capture_output=True, text=True) capture_output=True, text=True, timeout=15)
elif action == 'reboot_system': elif action == 'reboot_system':
result = subprocess.run(['sudo', 'reboot'], result = subprocess.run(['sudo', 'reboot'],
capture_output=True, text=True) capture_output=True, text=True, timeout=10)
elif action == 'shutdown_system': elif action == 'shutdown_system':
result = subprocess.run(['sudo', 'poweroff'], result = subprocess.run(['sudo', 'poweroff'],
capture_output=True, text=True) capture_output=True, text=True, timeout=10)
elif action == 'git_pull': elif action == 'git_pull':
# Use PROJECT_ROOT instead of hardcoded path # Use PROJECT_ROOT instead of hardcoded path
project_dir = str(PROJECT_ROOT) project_dir = str(PROJECT_ROOT)
@@ -1823,12 +1824,11 @@ def execute_system_action():
'stderr': result.stderr 'stderr': result.stderr
}) })
elif action == 'restart_display_service': elif action == 'restart_display_service':
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
capture_output=True, text=True) capture_output=True, text=True, timeout=15)
elif action == 'restart_web_service': elif action == 'restart_web_service':
# Try to restart the web service (assuming it's ledmatrix-web.service) result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'], capture_output=True, text=True, timeout=15)
capture_output=True, text=True)
else: else:
return jsonify({'status': 'error', 'message': f'Unknown action: {action}'}), 400 return jsonify({'status': 'error', 'message': f'Unknown action: {action}'}), 400
@@ -7136,7 +7136,7 @@ def connect_wifi():
# Propagate structured error type so the captive portal UI can show # Propagate structured error type so the captive portal UI can show
# "Wrong password — try again" instead of a generic failure message. # "Wrong password — try again" instead of a generic failure message.
error_type = "wrong_password" if (message or "").startswith("wrong_password:") else "connection_failed" error_type = "wrong_password" if (message or "").startswith("wrong_password:") else "connection_failed"
clean_message = (message or "").removeprefix("wrong_password: ") or "Failed to connect to network" clean_message = (message or "").removeprefix("wrong_password:").lstrip() or "Failed to connect to network"
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': clean_message, 'message': clean_message,

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
import json import json
import logging import logging
from html import escape as html_escape
from pathlib import Path from pathlib import Path
from src.web_interface.secret_helpers import mask_secret_fields from src.web_interface.secret_helpers import mask_secret_fields
@@ -354,7 +355,7 @@ def _load_plugin_config_partial(plugin_id):
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
if not plugin_info: if not plugin_info:
return f'<div class="text-red-500 p-4">Plugin "{plugin_id}" not found</div>', 404 return f'<div class="text-red-500 p-4">Plugin "{html_escape(plugin_id)}" not found</div>', 404
# Get plugin instance (may be None if not loaded) # Get plugin instance (may be None if not loaded)
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id) plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)

View File

@@ -91,7 +91,7 @@
const xOptions = config['x-options'] || config['x_options'] || {}; const xOptions = config['x-options'] || config['x_options'] || {};
const requestedFormat = xOptions.format || 'long'; const requestedFormat = xOptions.format || 'long';
// Validate format exists in DAY_LABELS, default to 'long' if not // Validate format exists in DAY_LABELS, default to 'long' if not
const format = DAY_LABELS.hasOwnProperty(requestedFormat) ? requestedFormat : 'long'; const format = Object.prototype.hasOwnProperty.call(DAY_LABELS, requestedFormat) ? requestedFormat : 'long';
const layout = xOptions.layout || 'horizontal'; const layout = xOptions.layout || 'horizontal';
const showSelectAll = xOptions.selectAll !== false; const showSelectAll = xOptions.selectAll !== false;

View File

@@ -1227,6 +1227,8 @@ function initializePlugins() {
// searchPluginStore renders Installed/Reinstall badges against it. // searchPluginStore renders Installed/Reinstall badges against it.
loadInstalledPlugins().then(() => { loadInstalledPlugins().then(() => {
searchPluginStore(!isReswapWarm); searchPluginStore(!isReswapWarm);
}).catch(err => {
console.error('[PluginStore] loadInstalledPlugins failed:', err);
}); });
// Setup search functionality (with guard against duplicate listeners) // Setup search functionality (with guard against duplicate listeners)