release: Mark VIII 1.0
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
# Mk7 "PineAP Open Access Point" Card Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rebuild the Open AP tab (`#/pineap/open`, `views.pineap_open`) into the genuine Mark 7 Pineapple's "PineAP Open Access Point" card — Open SSID / BSSID / Channel / Current Country / Hidden / Respond-to-all-probes toggles, filter notice boxes, and a Save that writes real device config.
|
||||
|
||||
**Architecture:** Backend extends the two existing `wifi/get_ap` + `wifi/set_ap` handlers to expose and persist the Open AP's SSID, BSSID (`macaddr`), hidden, channel, and country (channel/country applied to `wireless.radio0` with a `wifi reload`). Frontend replaces `views.pineap_open` with the Mk7 card; karma ("Respond to all probe requests") saves via the existing `/api/pineap/mimic` and is session-tracked (the daemon cannot report karma). Filter notice boxes reuse the existing `action`-based filter API.
|
||||
|
||||
**Tech Stack:** Python (`server.py`, unittest), vanilla JS (`views.js`, `app.css`), existing `h()`/`btn()`/`PagerAPI`/`App.toast` helpers.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend is UCI-driven (`_uci_wifi_iface`, `_uci_section`, `device_run`); the daemon's `/api/settings/wifi/set_ap` persists `ssid`/`hidden`/`bssid`→`macaddr`/`channel`/`enabled` to `wireless.wlan0open` but its iface-level `channel` write is **inert** for the actual radio — the radio channel/country must be written to `wireless.radio0` + `uci commit wireless` + `wifi reload`.
|
||||
- The daemon exposes **no readable karma/mimic or broadcast/advertise state** — the karma toggle is session-tracked (module-level flag, default off, updated on Save); the info line omits the "Spoofed SSID Pool will be advertised" clause.
|
||||
- Open AP `enabled` is preserved as-is (the Mk7 card has no Enabled control); the frontend always sends `enabled` = value loaded from `get_ap`.
|
||||
- Filter mutations use the existing `action` API: `POST /api/pineap/filters/ssid` `{action:'add'|'delete', value}` and `POST /api/pineap/filters/client` `{action:'set_mode', mode:'deny'}`.
|
||||
- No new routes; no daemon changes. Commit messages follow repo style (`feat:`, `fix:`, `docs:`).
|
||||
- JS verification uses `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available). Python: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: from repo root, `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then `/etc/init.d/pagerwebui restart` over sshpass.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — expose and save Open AP network settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py` (`h_pineap_wifi_get_ap` at ~1479, `h_pineap_wifi_set_ap` at ~1505, add helper `_apply_open_radio` just before `h_pineap_wifi_set_ap`)
|
||||
- Test: `tests/test_pineap_proxy.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `_uci_wifi_iface(name)` (runs `uci show wireless.<name>`, returns dict), `_uci_section(section)`, `device_run(args)`, `daemon_sock_call(method, path, body)`.
|
||||
- Produces: `h_pineap_wifi_get_ap` `open` payload now `{enabled, ssid, bssid, target, hidden, channel, country}`; `h_pineap_wifi_set_ap` accepts `open: {ssid, bssid, hidden, enabled, channel, country}`. Task 2 consumes these exact field names.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (extend `test_wifi_get_ap_reads_uci_wireless`; add a new set_ap test)
|
||||
|
||||
Replace the `test_wifi_get_ap_reads_uci_wireless` body's `fake_run` with the version below and add the new assertions; append `test_wifi_set_ap_open_bssid_channel_and_country` to the `PineapProxyTest` class:
|
||||
|
||||
```python
|
||||
def test_wifi_get_ap_reads_uci_wireless(self):
|
||||
def fake_run(args):
|
||||
cmd = args[0]
|
||||
if cmd == 'uci' and len(args) == 3:
|
||||
sec = args[2]
|
||||
if sec == 'wireless.wlan0wpa':
|
||||
return 0, "wireless.wlan0wpa.ifname='wlan0wpa'\nwireless.wlan0wpa.ssid='Evil1'\nwireless.wlan0wpa.encryption='psk2'\nwireless.wlan0wpa.key='sekret'\nwireless.wlan0wpa.disabled='0'\nwireless.wlan0wpa.hidden='0'\n", ''
|
||||
if sec == 'wireless.wlan0open':
|
||||
return 0, "wireless.wlan0open.disabled='1'\nwireless.wlan0open.ssid='pager-open'\nwireless.wlan0open.macaddr='DE:AD:BE:EF:00:01'\nwireless.wlan0open.hidden='1'\n", ''
|
||||
if sec == 'wireless.radio0':
|
||||
return 0, "wireless.radio0.channel='6'\nwireless.radio0.country='US'\n", ''
|
||||
if sec.startswith('pineapd.@ssidpool'):
|
||||
return 0, "pineapd.@ssidpool[0].bssid='auto'\npineapd.@ssidpool[0].target='broadcast'\n", ''
|
||||
return 0, '', ''
|
||||
|
||||
def fake_sock(method, path, body=None, timeout=10):
|
||||
if path == '/api/pineap/hostapd/get_config':
|
||||
return 200, {'pineape_disabled': False}
|
||||
if path == '/api/pineap/get_config':
|
||||
return 200, {'autossidpool': True}
|
||||
return 200, {}
|
||||
|
||||
server.device_run = fake_run
|
||||
server.daemon_sock_call = fake_sock
|
||||
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['wpa'], {'ssid': 'Evil1', 'passphrase': 'sekret', 'enctype': 'psk2',
|
||||
'hidden': False, 'enabled': True})
|
||||
self.assertEqual(payload['open']['enabled'], False)
|
||||
self.assertEqual(payload['open']['ssid'], 'pager-open')
|
||||
self.assertEqual(payload['open']['bssid'], 'DE:AD:BE:EF:00:01')
|
||||
self.assertEqual(payload['open']['hidden'], True)
|
||||
self.assertEqual(payload['open']['channel'], 6)
|
||||
self.assertEqual(payload['open']['country'], 'US')
|
||||
self.assertEqual(payload['open']['target'], 'broadcast')
|
||||
self.assertEqual(payload['enterprise']['enabled'], True)
|
||||
self.assertEqual(payload['pool']['collecting'], True)
|
||||
|
||||
def test_wifi_set_ap_open_bssid_channel_and_country(self):
|
||||
sock_calls = []
|
||||
run_calls = []
|
||||
|
||||
def fake_sock(method, path, body=None, timeout=10):
|
||||
sock_calls.append((method, path, body))
|
||||
return (200, {'success': True})
|
||||
|
||||
def fake_run(args):
|
||||
run_calls.append(args)
|
||||
if args[0] == 'uci' and args[1] == 'show':
|
||||
return 0, "wireless.radio0.channel='1'\n", ''
|
||||
return 0, '', ''
|
||||
|
||||
server.daemon_sock_call = fake_sock
|
||||
server.device_run = fake_run
|
||||
status, _ = server.h_pineap_wifi_set_ap(ctx({'open': {
|
||||
'ssid': 'Open', 'bssid': 'DE:AD:BE:EF:00:02', 'hidden': True,
|
||||
'channel': 6, 'country': 'US', 'enabled': True}}))
|
||||
self.assertEqual(status, 200)
|
||||
method, path, body = sock_calls[0]
|
||||
self.assertEqual(method, 'PUT')
|
||||
self.assertEqual(path, '/api/settings/wifi/set_ap')
|
||||
conf = body['configs'][0]
|
||||
self.assertEqual(conf['interface'], 'wlan0open')
|
||||
self.assertEqual(conf['ssid'], 'Open')
|
||||
self.assertEqual(conf['bssid'], 'DE:AD:BE:EF:00:02')
|
||||
self.assertEqual(conf['hidden'], True)
|
||||
self.assertEqual(conf['channel'], 6)
|
||||
self.assertEqual(conf['enabled'], True)
|
||||
sets = [a for a in run_calls if a[:2] == ['uci', 'set']]
|
||||
self.assertEqual(sets, [['uci', 'set', 'wireless.radio0.channel=6'],
|
||||
['uci', 'set', 'wireless.radio0.country=US']])
|
||||
self.assertIn(['uci', 'commit', 'wireless'], run_calls)
|
||||
self.assertIn(['wifi', 'reload'], run_calls)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_proxy`
|
||||
Expected: both tests FAIL (get_ap returns no `ssid`/`bssid`/`hidden`/`channel`/`country`; set_ap drops `bssid`/`channel` and never runs `uci set wireless.radio0.*`).
|
||||
|
||||
- [ ] **Step 3: Implement the backend changes**
|
||||
|
||||
In `server.py`:
|
||||
|
||||
`h_pineap_wifi_get_ap` — add the radio read and the new open fields (replace the current `open_cfg = _uci_wifi_iface('wlan0open')` line block and the `'open'` dict):
|
||||
|
||||
```python
|
||||
def h_pineap_wifi_get_ap(ctx):
|
||||
open_cfg = _uci_wifi_iface('wlan0open')
|
||||
radio_cfg = _uci_wifi_iface('radio0')
|
||||
wpa_cfg = _uci_wifi_iface('wlan0wpa')
|
||||
status, data = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
||||
host = data if status == 200 and isinstance(data, dict) else {}
|
||||
status2, data2 = daemon_sock_call('GET', '/api/pineap/get_config')
|
||||
pinecfg = data2 if status2 == 200 and isinstance(data2, dict) else {}
|
||||
pool = _uci_section('pineapd.@ssidpool[0]')
|
||||
channel = radio_cfg.get('channel') or ''
|
||||
try:
|
||||
channel = int(channel)
|
||||
except (TypeError, ValueError):
|
||||
channel = None
|
||||
return 200, {
|
||||
'open': {
|
||||
'enabled': open_cfg.get('disabled') == '0',
|
||||
'ssid': open_cfg.get('ssid') or '',
|
||||
'bssid': open_cfg.get('macaddr') or '',
|
||||
'target': pool.get('target') or None,
|
||||
'hidden': open_cfg.get('hidden') == '1',
|
||||
'channel': channel,
|
||||
'country': radio_cfg.get('country') or '',
|
||||
},
|
||||
'wpa': {
|
||||
'ssid': wpa_cfg.get('ssid') or '',
|
||||
'passphrase': wpa_cfg.get('key') or '',
|
||||
'enctype': wpa_cfg.get('encryption') or '',
|
||||
'hidden': wpa_cfg.get('hidden') == '1',
|
||||
'enabled': wpa_cfg.get('disabled') == '0',
|
||||
},
|
||||
'enterprise': {'enabled': not host.get('pineape_disabled', True)},
|
||||
'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))},
|
||||
}
|
||||
```
|
||||
|
||||
Add this helper immediately before `h_pineap_wifi_set_ap`:
|
||||
|
||||
```python
|
||||
def _apply_open_radio(openap):
|
||||
"""Persist the Open AP's radio channel/country to wireless.radio0. The
|
||||
daemon's iface-level channel write does not affect the actual radio, so
|
||||
apply channel/country here and reload wifi when they change."""
|
||||
changed = False
|
||||
for key in ('channel', 'country'):
|
||||
value = openap.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
current = _uci_wifi_iface('radio0').get(key) or ''
|
||||
if str(value) != current:
|
||||
device_run(['uci', 'set', 'wireless.radio0.%s=%s' % (key, value)])
|
||||
changed = True
|
||||
if changed:
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
device_run(['wifi', 'reload'])
|
||||
```
|
||||
|
||||
`h_pineap_wifi_set_ap` — replace the open branch and add the call after the daemon call (replace the current `'channel': 1` open config and the `return 200, {'ok': True}` line):
|
||||
|
||||
```python
|
||||
if openap.get('ssid') or openap.get('enabled') is not None:
|
||||
configs.append({
|
||||
'interface': 'wlan0open',
|
||||
'ssid': openap.get('ssid', ''),
|
||||
'enctype': 'none',
|
||||
'enabled': bool(openap.get('enabled', True)),
|
||||
'hidden': bool(openap.get('hidden', False)),
|
||||
'channel': int(openap['channel']) if openap.get('channel') is not None else 1,
|
||||
'bssid': openap.get('bssid') or '',
|
||||
})
|
||||
if not configs:
|
||||
return 400, {'error': 'no configuration provided'}
|
||||
status, data = daemon_sock_call('PUT', '/api/settings/wifi/set_ap', body={'configs': configs}, timeout=45)
|
||||
if status != 200:
|
||||
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
||||
_apply_open_radio(openap)
|
||||
return 200, {'ok': True}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_proxy`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full unittest loop**
|
||||
|
||||
Run from repo root:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/server.py tests/test_pineap_proxy.py
|
||||
git commit -m "feat: expose and save Open AP ssid/bssid/hidden/channel/country"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Mk7 "PineAP Open Access Point" card
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (add `OPEN_CHANNELS`/`OPEN_COUNTRIES` constants + `let OPEN_KARMA` before `views.pineap_open`; replace the whole `views.pineap_open` function, currently lines 327-409, just before `const EVIL_ENC`)
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append infobox styles at end)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's `open` fields (`ssid`, `bssid`, `hidden`, `channel`, `country`, `enabled`) from `POST /api/pineap/wifi/get_ap`; `set_ap` `open` body keys; existing `/api/pineap/mimic`, `/api/pineap/get_config`, `GET /api/pineap/filters/{ssid,client}` → `{mode, entries}`; existing `action` filter mutations.
|
||||
- Produces: the Mk7 Open card. No later task consumes these names.
|
||||
|
||||
- [ ] **Step 1: Add the constants and module state before `views.pineap_open`**
|
||||
|
||||
Insert immediately before `views.pineap_open = (root) => {`:
|
||||
|
||||
```js
|
||||
const OPEN_CHANNELS = Array.from({ length: 11 }, (_, i) => {
|
||||
const c = i + 1;
|
||||
return [c, 'Channel ' + c + ' (' + (2412 + (c - 1) * 5) + ' MHz)'];
|
||||
});
|
||||
const OPEN_COUNTRIES = [
|
||||
['US', 'United States'], ['DZ', 'Algeria'], ['AR', 'Argentina'], ['AU', 'Australia'],
|
||||
['AT', 'Austria'], ['BH', 'Bahrain'], ['BM', 'Bermuda'], ['BO', 'Bolivia'], ['BR', 'Brazil'],
|
||||
['BG', 'Bulgaria'], ['CA', 'Canada'], ['CL', 'Chile'], ['CN', 'China'], ['CO', 'Colombia'],
|
||||
['CR', 'Costa Rica'], ['CS', 'Cyprus'], ['CZ', 'Czech Republic'], ['DK', 'Denmark'],
|
||||
['DO', 'Dominican Republic'], ['EC', 'Ecuador'], ['EG', 'Egypt'], ['SV', 'El Salvador'],
|
||||
['EE', 'Estonia'], ['FI', 'Finland'], ['FR', 'France'], ['DE', 'Germany'], ['GR', 'Greece'],
|
||||
['GT', 'Guatemala'], ['HN', 'Honduras'], ['HK', 'Hong Kong'], ['IS', 'Iceland'], ['IN', 'India'],
|
||||
['ID', 'Indonesia'], ['IE', 'Ireland'], ['PK', 'Islamic Republic of Pakistan'], ['IL', 'Israel'],
|
||||
['IT', 'Italy'], ['JM', 'Jamaica'], ['JO', 'Jordan'], ['KE', 'Kenya'], ['KW', 'Kuwait'],
|
||||
['LB', 'Lebanon'], ['LI', 'Liechtenstein'], ['LT', 'Lithuania'], ['LU', 'Luxembourg'],
|
||||
['MU', 'Mauritius'], ['MX', 'Mexico'], ['MA', 'Morocco'], ['NL', 'Netherlands'], ['NZ', 'New Zealand'],
|
||||
['NO', 'Norway'], ['OM', 'Oman'], ['PA', 'Panama'], ['PE', 'Peru'], ['PH', 'Philippines'],
|
||||
['PL', 'Poland'], ['PT', 'Portuagal'], ['PR', 'Puerto Rico'], ['QA', 'Qatar'],
|
||||
['KR', 'Republic of Korea (South Korea)'], ['RO', 'Romania'], ['RU', 'Russia'], ['SA', 'Saudi Arabia'],
|
||||
['SG', 'Singapore'], ['SI', 'Slovenia'], ['SK', 'Slovak Republic'], ['ZA', 'South Africa'],
|
||||
['ES', 'Spain'], ['LK', 'Sri Lanka'], ['SE', 'Sweden'], ['CH', 'Switzerland'], ['TW', 'Taiwan'],
|
||||
['TH', 'Thailand'], ['TT', 'Trinidad and Tobago'], ['TN', 'Tunisia'], ['TR', 'Turkey'],
|
||||
['UA', 'Ukraine'], ['AE', 'United Arab Emirates'], ['GB', 'United Kingdom'], ['UY', 'Uraguay'],
|
||||
['VE', 'Venezuela'], ['VN', 'Vietnam']
|
||||
];
|
||||
let OPEN_KARMA = false;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the whole `views.pineap_open` function**
|
||||
|
||||
Replace everything from `views.pineap_open = (root) => {` through its closing `};` (current lines 327-409, i.e. just before `const EVIL_ENC`) with:
|
||||
|
||||
```js
|
||||
views.pineap_open = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/open');
|
||||
const card = h('div', { class: 'pineap-title-card' });
|
||||
box.appendChild(card);
|
||||
|
||||
card.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAP Open Access Point'));
|
||||
const subtitle = h('div', { class: 'pineap-card-subtitle' });
|
||||
card.appendChild(subtitle);
|
||||
|
||||
const ssidIn = h('input', { id: 'oa-ssid' });
|
||||
const bssidIn = h('input', { id: 'oa-bssid' });
|
||||
const chSel = h('select', { id: 'oa-channel' });
|
||||
OPEN_CHANNELS.forEach(([v, l]) => chSel.appendChild(h('option', { value: v, text: l })));
|
||||
const coSel = h('select', { id: 'oa-country' });
|
||||
OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l })));
|
||||
const hiddenCb = h('input', { type: 'checkbox', id: 'oa-hidden' });
|
||||
const karmaCb = h('input', { type: 'checkbox', id: 'oa-karma' });
|
||||
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'Open SSID', ssidIn)),
|
||||
h('div', {}, h('label', {}, 'BSSID', bssidIn))));
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'Channel', chSel)),
|
||||
h('div', {}, h('label', {}, 'Current Country', coSel))));
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), ' Hidden')),
|
||||
h('div', {}, h('label', { class: 'switch' }, karmaCb, h('span', { class: 'track' }), ' Respond to all probe requests (impersonate all networks)'))));
|
||||
|
||||
const info = h('div', { class: 'muted', style: 'margin-top:10px;font-size:13px' });
|
||||
card.appendChild(info);
|
||||
const boxes = h('div', {});
|
||||
card.appendChild(boxes);
|
||||
card.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
|
||||
h('div', {}, btn('Save', save)),
|
||||
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.')));
|
||||
|
||||
const state = {};
|
||||
|
||||
function cfgLink() {
|
||||
return h('a', { href: '#/pineap/filtering', style: 'color:var(--primary);cursor:pointer', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'filter configuration');
|
||||
}
|
||||
function filterBtn() {
|
||||
return h('a', { class: 'btn', href: '#/pineap/filtering', style: 'text-decoration:none;display:inline-block', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'Change Filters');
|
||||
}
|
||||
function infobox(severity, text, ...actions) {
|
||||
return h('div', { class: 'pineap-infobox ' + severity },
|
||||
h('span', { text }),
|
||||
h('div', { class: 'pineap-infobox-actions' }, actions));
|
||||
}
|
||||
function filterSentence(sm, cm) {
|
||||
if (sm === 'allow' && cm === 'allow') return 'any client in the filter configuration may connect to any SSID in the filter configuration.';
|
||||
if (sm === 'deny' && cm === 'allow') return 'any client not in the filter configuration may connect to any SSID in the filter configuration.';
|
||||
if (sm === 'allow' && cm === 'deny') return 'any client in the filter configuration may connect to any SSID not in the filter configuration.';
|
||||
return 'any client not in the filter configuration may connect to any SSID not in the filter configuration.';
|
||||
}
|
||||
|
||||
function save() {
|
||||
Promise.allSettled([
|
||||
PagerAPI.post('/api/pineap/wifi/set_ap', {
|
||||
open: {
|
||||
ssid: ssidIn.value,
|
||||
bssid: bssidIn.value.trim(),
|
||||
hidden: hiddenCb.checked,
|
||||
enabled: !!state.enabled,
|
||||
channel: chSel.value ? parseInt(chSel.value, 10) : null,
|
||||
country: coSel.value
|
||||
}
|
||||
}),
|
||||
PagerAPI.post('/api/pineap/mimic', { enable: karmaCb.checked })
|
||||
]).then((results) => {
|
||||
const ok = results.every((r) => r.status === 'fulfilled');
|
||||
OPEN_KARMA = karmaCb.checked;
|
||||
App.toast(ok ? 'Open AP saved' : 'Some settings failed', ok ? '' : 'error');
|
||||
load();
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const sm = state.ssidMode || 'deny';
|
||||
const cm = state.clientMode || 'deny';
|
||||
subtitle.textContent = '';
|
||||
subtitle.appendChild(document.createTextNode('The Open SSID is advertised without encryption. When client association is enabled, '));
|
||||
subtitle.appendChild(cfgLink());
|
||||
subtitle.appendChild(document.createTextNode(' ' + filterSentence(sm, cm)));
|
||||
|
||||
const hidden = hiddenCb.checked;
|
||||
const karma = karmaCb.checked;
|
||||
let t = 'The Open access point will be ' + (hidden ? 'hidden' : 'advertised');
|
||||
if (!karma) {
|
||||
t += '.';
|
||||
} else {
|
||||
if (sm === 'allow' && cm === 'allow') t += ', and clients in the allowed client filter list will be able to connect to any SSID in the allowed SSID filter.';
|
||||
else if (sm === 'allow' && cm === 'deny') t += ', and clients in the allowed client filter list will be able to connect to any SSID not blocked by the SSID filter.';
|
||||
else if (sm === 'deny' && cm === 'allow') t += ', and clients not in the denied client filter list will be able to connect to any SSID in the allowed SSID filter.';
|
||||
else t += ', and clients not in the denied client filter list will be able to connect to any SSID not blocked by the SSID filter.';
|
||||
}
|
||||
info.textContent = t;
|
||||
|
||||
boxes.innerHTML = '';
|
||||
const openSsid = ssidIn.value;
|
||||
const ssidList = state.ssidList || [];
|
||||
const clientList = state.clientList || [];
|
||||
if (state.ssidFetched && sm === 'allow' && openSsid && ssidList.indexOf(openSsid) === -1) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The open SSID "' + openSsid + '" is not included in the filter allow list, clients will not be able to connect.',
|
||||
btn('Add Allowed', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'add', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error')))));
|
||||
}
|
||||
if (state.ssidFetched && sm === 'deny' && openSsid && ssidList.indexOf(openSsid) !== -1) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The open SSID "' + openSsid + '" is included in the filter deny list, clients will not be able to connect.',
|
||||
btn('Remove Filter', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'delete', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error')))));
|
||||
}
|
||||
if (sm === 'allow' && ssidList.length > 0 && karmaCb.checked) {
|
||||
boxes.appendChild(infobox('info',
|
||||
'Remember to add SSIDs you wish to impersonate to the PineAP SSID filter, or change to "Deny" mode to allow responding to all requested networks!',
|
||||
filterBtn()));
|
||||
}
|
||||
if (state.clientFetched && cm === 'allow' && clientList.length === 0) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The PineAP Client filter is set to "allow", but no clients are listed; no clients will be able to connect!',
|
||||
btn('Change Mode', () => PagerAPI.post('/api/pineap/filters/client', { action: 'set_mode', mode: 'deny' }).then(load).catch(() => App.toast('Failed', 'error'))),
|
||||
filterBtn()));
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/filters/ssid').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/filters/client').catch(() => ({ data: {} }))
|
||||
]).then(([ap, cfg, sf, cf]) => {
|
||||
const a = ap.data || {}, c = cfg.data || {};
|
||||
const open = a.open || {};
|
||||
ssidIn.value = open.ssid || '';
|
||||
bssidIn.value = open.bssid || '';
|
||||
if (open.channel != null) chSel.value = String(open.channel);
|
||||
if (open.country) coSel.value = open.country;
|
||||
hiddenCb.checked = !!open.hidden;
|
||||
state.enabled = !!open.enabled;
|
||||
karmaCb.checked = OPEN_KARMA;
|
||||
const sd = sf.data || {}, cd = cf.data || {};
|
||||
state.ssidFetched = !!sd.mode;
|
||||
state.clientFetched = !!cd.mode;
|
||||
state.ssidMode = sd.mode;
|
||||
state.clientMode = cd.mode;
|
||||
state.ssidList = sd.entries || [];
|
||||
state.clientList = cd.entries || [];
|
||||
render();
|
||||
});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Append the infobox CSS to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
/* ---- Open AP: Mk7 filter notice boxes ---- */
|
||||
.pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; }
|
||||
.pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; }
|
||||
.pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; }
|
||||
html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 5: Run the full unittest loop (backend must stay green)**
|
||||
|
||||
Run from repo root:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: Mk7 PineAP Open Access Point card for Open AP tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-2 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up (over sshpass SSH):
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Confirm the deployed files contain the new code**
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "grep -c 'PineAP Open Access Point' /root/payloads/user/general/pager-webui/www/js/views.js; grep -c 'pineap-infobox' /root/payloads/user/general/pager-webui/www/css/app.css; grep -c 'radio0' /root/payloads/user/general/pager-webui/server.py"
|
||||
```
|
||||
Expected: all counts greater than zero.
|
||||
|
||||
- [ ] **Step 3: On-device save-path round-trip (write current values, verify UCI, restore)**
|
||||
|
||||
Record the current `wireless.wlan0open` + `wireless.radio0` state first, then PUT the same values back through `set_ap` (idempotent), then verify and confirm the config is unchanged:
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "uci show wireless.wlan0open; uci show wireless.radio0 | grep -E 'channel|country'"
|
||||
```
|
||||
Then, over sshpass SSH, base64 a script and run it via `echo ... | base64 -d | sh` (avoids shell-quoting mangling) that:
|
||||
1. Logs in to the WebUI (`POST /api/login` with the root credentials, saves cookie).
|
||||
2. `POST /api/pineap/wifi/get_ap` → confirm the response contains `"open"` with `ssid`, `bssid`, `hidden`, `channel`, `country` keys.
|
||||
3. `POST /api/pineap/wifi/set_ap` with `{open:{ssid:<current>, bssid:<current>, hidden:<current>, enabled:true, channel:<current>, country:<current>}}` (the values just read) → expect `{"ok":true}`.
|
||||
4. `uci show wireless.wlan0open` again → confirm ssid/hidden/macaddr unchanged.
|
||||
Expected: get_ap returns the new fields; set_ap returns ok; UCI unchanged (idempotent write).
|
||||
|
||||
- [ ] **Step 4: Report for user UI walk**
|
||||
|
||||
Tell the user the Open AP tab is now the Mk7 "PineAP Open Access Point" card: Open SSID / BSSID / Channel (1-11) / Current Country / Hidden / "Respond to all probe requests (impersonate all networks)" switches, filter notice boxes with Add Allowed / Change Mode / Change Filters actions, and a Save button. Note the two documented limitations: the karma toggle is session-tracked (the daemon cannot report it), and the "SSIDs from the Spoofed SSID Pool will be advertised" clause is omitted (no readable broadcast state). Ask them to refresh `http://172.16.52.1:8080/#/pineap/open`, edit the Open SSID and Save, and confirm the toast + that `uci show wireless.wlan0open` reflects the change.
|
||||
Reference in New Issue
Block a user