Files
LEDMatrix/web_interface/templates/v3/captive_setup.html
Chuck ceb4c4105f fix(wifi): reliable open AP with captive portal — tested on Trixie Pi (#320)
* 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>

* 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>

* 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>

* 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>

* fix(wifi): use _find_command_path for iptables/sysctl; harden ip_forward save/restore

Add _find_command_path() helper that extends _check_command()'s sbin-aware lookup to
return the absolute binary path rather than a boolean. Use it in
_setup_iptables_redirect and _teardown_iptables_redirect so iptables and sysctl are
resolved via /sbin or /usr/sbin even when those directories are absent from PATH in
systemd service environments.

Also harden the ip_forward save/restore logic:
- Read ip_forward from /proc/sys/net/ipv4/ip_forward (no subprocess, no PATH
  dependency) instead of spawning sysctl -n
- Skip the sysctl -w ip_forward=1 write when the value is already "1" to avoid
  mutating state owned by another service (VPN, NM shared mode, bridge)
- Track save success via presence of the save file: if the /proc read or file write
  fails, leave the file absent so teardown knows not to restore
- In _teardown_iptables_redirect, only restore ip_forward when the save file exists;
  if absent, leave the current value untouched rather than forcing "0"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): check _setup_iptables_redirect return; fix hostapd LED SSID; teardown on exception

- Both AP startup paths (hostapd and nmcli) now check the bool returned by
  _setup_iptables_redirect() and treat False as a hard failure: the hostapd
  path stops hostapd/dnsmasq and returns an error tuple; the nmcli path brings
  down and deletes the LEDMatrix-Setup-AP profile and clears the LED message

- _enable_ap_mode_hostapd's LED message now calls _validate_ap_config() to get
  the same sanitized SSID that _create_hostapd_config() uses, so the displayed
  name always matches the AP actually broadcast by hostapd

- _setup_iptables_redirect's outer except block now calls
  _teardown_iptables_redirect() before returning False so partial iptables/
  ip_forward state is always cleaned up on unexpected exceptions; cleanup
  exceptions are caught and logged separately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(wifi): add unit tests for AP mode — open network, iptables, LED, cleanup ordering

Six pytest unit tests covering the five review scenarios. All subprocess and
filesystem side-effects are mocked so the tests run without root, hardware, or
a Pi OS environment.

1. test_nmcli_ap_profile_has_no_security_params — asserts the nmcli connection
   add command has no key-mgmt / psk / WPA arguments and sets mode=ap.
2. test_iptables_nat_rules_added_on_ap_start — verifies _setup_iptables_redirect
   emits a PREROUTING REDIRECT 80→5000 rule and an INPUT ACCEPT rule for port
   5000 (not 80, which never hits INPUT after PREROUTING rewrites it).
3. test_iptables_rules_and_ip_forward_reverted_on_teardown — verifies the -D
   PREROUTING/-D INPUT calls and that sysctl restores the saved ip_forward value
   and removes the save file.
4. test_ip_forward_not_restored_when_save_file_absent — verifies teardown skips
   sysctl when the save file was never written, preventing blind ip_forward=0 on
   systems using ip_forward for VPNs or NM shared mode.
5. test_led_message_shows_ssid_no_password_and_url — asserts the LED message
   includes the SSID, 'No password', and the 192.168.4.1:5000 setup URL.
6. test_existing_ap_profiles_deleted_before_new_profile_created — asserts all
   known profile names are targeted for deletion before 'nmcli connection add'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(wifi): adopt adsb-feeder-image hotspot patterns — DNS spoofing, connectivity check, idle timeout, wrong-password UX, watchdog escalation

Inspired by the production-proven approach in dirkhh/adsb-feeder-image.

1. DNS spoofing for automatic captive-portal popup (Change 1 — Critical)
   Write /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf with
   address=/#/192.168.4.1 before nmcli connection up so NM's built-in
   dnsmasq (ipv4.method=shared) resolves every hostname to the AP IP.
   This triggers the OS captive-portal popup automatically on iOS / Android /
   Windows / macOS — no manual navigation to 192.168.4.1:5000/setup required.
   New helpers: _write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf.
   New constants: NM_DNSMASQ_SHARED_DIR / NM_DNSMASQ_SHARED_CONF.

2. Real internet connectivity check (Change 2 — High)
   Add _check_internet_connectivity() (ping 8.8.8.8 + HTTP fallback).
   check_and_manage_ap_mode() now considers a device "disconnected" when nmcli
   shows connected but no real internet reachability, matching adsb-feeder's
   multi-method gateway/DNS/HTTP test approach.

3. AP idle timeout (Change 3 — Medium)
   Track _ap_enabled_at timestamp in enable_ap_mode(). Add _has_ap_clients()
   using 'iw dev <iface> station dump'. check_and_manage_ap_mode() auto-disables
   AP after ap_idle_timeout_minutes (default 15) with no associated clients.

4. Wrong-password error feedback (Change 4 — Medium)
   _connect_nmcli() detects "Secrets were required" / "authentication rejected"
   in nmcli stderr and prefixes the message with "wrong_password: ".
   The /api/v3/wifi/connect route propagates error_type="wrong_password" in the
   JSON response. captive_setup.html shows "Incorrect password — try again"
   (keeping the form active) instead of the generic failure message.

5. Escalating watchdog NM restart (Change 5 — Low)
   wifi_monitor_daemon.py tracks _consecutive_internet_failures. After
   _nm_restart_threshold (5) consecutive checks where nmcli shows connected but
   internet is unreachable, restart NetworkManager as a recovery step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): restore safe AP-enable trigger; decouple internet check from AP logic

The previous commit introduced _check_internet_connectivity() into
check_and_manage_ap_mode(), which shared the same _disconnected_checks counter
that triggers AP enable. This created a false-positive risk: 90 seconds of
packet loss on working WiFi would enable AP mode and kick off the connection.

Fix: restore nmcli association state as the sole AP-enable trigger (original,
safe behaviour). The internet connectivity check is now used only in the daemon
watchdog for the NM-restart escalation — matching how adsb-feeder-image actually
structures the two concerns (initial setup detection vs. ongoing monitoring).

Also clarify daemon comment: the connectivity check runs once per cycle in the
watchdog block, not inside check_and_manage_ap_mode, so there is no double-call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* 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>

* 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>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 08:25:20 -04:00

253 lines
9.3 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LEDMatrix WiFi Setup</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f3f4f6;color:#1f2937;padding:16px;max-width:480px;margin:0 auto}
h1{font-size:20px;margin-bottom:4px}
.subtitle{color:#6b7280;font-size:13px;margin-bottom:20px}
.card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:13px;font-weight:600;color:#374151;margin-bottom:6px}
select,input[type=text],input[type=password]{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:15px;background:#fff;-webkit-appearance:none;appearance:none}
select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
.btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;text-align:center;transition:background .15s}
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
.btn-scan{background:#e5e7eb;color:#374151}.btn-scan:hover{background:#d1d5db}
.btn:disabled{background:#d1d5db;color:#9ca3af;cursor:not-allowed}
.row{display:flex;gap:8px;margin-bottom:16px}
.row>*:first-child{flex:1}
.msg{padding:12px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none}
.msg-ok{background:#d1fae5;color:#065f46;display:block}
.msg-err{background:#fee2e2;color:#991b1b;display:block}
.msg-info{background:#dbeafe;color:#1e40af;display:block}
.step{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.sep{margin:16px 0;border:none;border-top:1px solid #e5e7eb}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.footer{text-align:center;margin-top:20px;font-size:12px;color:#9ca3af}
.footer a{color:#3b82f6;text-decoration:none}
.success-box{text-align:center;padding:24px}
.success-box .icon{font-size:48px;margin-bottom:12px}
.success-box .ip{font-size:18px;font-weight:700;color:#2563eb;word-break:break-all}
.hidden{display:none}
.or-divider{text-align:center;color:#9ca3af;font-size:12px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:40%;height:1px;background:#e5e7eb}
.or-divider::before{left:0}
.or-divider::after{right:0}
</style>
</head>
<body>
<h1>LEDMatrix WiFi Setup</h1>
<p class="subtitle">Connect your device to a WiFi network</p>
<div id="msg" class="msg"></div>
<div id="setup-form">
<div class="card">
<div class="step">Step 1 &mdash; Choose Network</div>
<label for="net-select">Available Networks</label>
<div class="row">
<select id="net-select" onchange="onSelectNetwork()">
<option value="">-- Scan to find networks --</option>
</select>
<button class="btn btn-scan" id="btn-scan" onclick="doScan()" style="width:auto;padding:10px 16px">
Scan
</button>
</div>
<div class="or-divider">or enter manually</div>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" oninput="onManualInput()">
</div>
<div class="card">
<div class="step">Step 2 &mdash; Password</div>
<label for="password">WiFi Password</label>
<input type="password" id="password" placeholder="Leave empty for open networks">
</div>
<div class="card">
<button class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
Connect
</button>
</div>
</div>
<div id="success-view" class="card hidden">
<div class="success-box">
<div class="icon">&#10003;</div>
<p style="font-size:16px;font-weight:600;margin-bottom:8px">Connected!</p>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Your device is now on the network. Access the full interface at:</p>
<p class="ip" id="new-ip"></p>
<p style="font-size:12px;color:#9ca3af;margin-top:12px">You may need to reconnect your phone to the same WiFi network.</p>
</div>
</div>
<div class="footer">
<a href="/v3">Open Full Interface</a>
</div>
<script>
var selectedSSID = '';
var scanning = false;
var connecting = false;
function $(id) { return document.getElementById(id); }
function showMsg(text, type) {
var el = $('msg');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') setTimeout(function() { el.style.display = 'none'; }, 8000);
}
function clearMsg() { $('msg').className = 'msg'; }
function updateConnectBtn() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
$('btn-connect').disabled = !ssid || connecting;
}
function onSelectNetwork() {
$('manual-ssid').value = '';
selectedSSID = $('net-select').value;
updateConnectBtn();
}
function onManualInput() {
$('net-select').value = '';
selectedSSID = '';
updateConnectBtn();
}
function doScan() {
if (scanning) return;
scanning = true;
var btn = $('btn-scan');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Scanning';
clearMsg();
fetch('/api/v3/wifi/scan')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = $('net-select');
sel.innerHTML = '<option value="">-- Select a network --</option>';
if (data.status === 'success' && Array.isArray(data.data)) {
var nets = data.data;
for (var i = 0; i < nets.length; i++) {
var n = nets[i];
var opt = document.createElement('option');
opt.value = n.ssid;
opt.textContent = n.ssid + ' (' + n.signal + '% - ' + n.security + ')';
sel.appendChild(opt);
}
if (nets.length > 0) {
var msg = 'Found ' + nets.length + ' network' + (nets.length > 1 ? 's' : '');
if (data.cached) {
msg += ' \u2014 Showing cached networks. Connect to see the latest.';
}
showMsg(msg, data.cached ? 'info' : 'ok');
} else {
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again.'), 'info');
}
} else {
showMsg(data.message || 'Scan failed', 'err');
}
})
.catch(function(e) {
showMsg('Scan failed: ' + e.message, 'err');
})
.finally(function() {
scanning = false;
btn.disabled = false;
btn.innerHTML = 'Scan';
updateConnectBtn();
});
}
function doConnect() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
if (!ssid || connecting) return;
connecting = true;
var btn = $('btn-connect');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Connecting...';
clearMsg();
showMsg('Connecting to ' + ssid + '... This may take 15-30 seconds.', 'info');
fetch('/api/v3/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: $('password').value || '' })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
clearMsg();
// Poll for the new IP
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else {
var msg = data.error_type === 'wrong_password'
? 'Incorrect password — please try again'
: (data.message || 'Connection failed');
showMsg(msg, 'err');
connecting = false;
btn.disabled = false;
btn.innerHTML = 'Connect';
}
})
.catch(function(e) {
// Connection may drop if AP mode was disabled — that's expected
clearMsg();
showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info');
setTimeout(function() { showSuccessFallback(ssid); }, 5000);
});
}
var MAX_IP_RETRIES = 20;
function checkNewIP(ssid, retriesLeft) {
if (retriesLeft === undefined) retriesLeft = MAX_IP_RETRIES;
if (retriesLeft <= 0) {
showSuccessFallback(ssid);
return;
}
fetch('/api/v3/wifi/status')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) {
showSuccess(data.data.ip_address);
} else {
setTimeout(function() { checkNewIP(ssid, retriesLeft - 1); }, 3000);
}
})
.catch(function() {
// AP likely down — show fallback
showSuccessFallback(ssid);
});
}
function showSuccess(ip) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'http://' + ip + ':5000';
$('msg').className = 'msg';
}
function showSuccessFallback(ssid) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'Check your router for the device IP';
$('msg').className = 'msg';
}
// Auto-scan on load
doScan();
</script>
</body>
</html>