Compare commits
40
Commits
86d26d8457
...
a04319dfc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04319dfc8 | ||
|
|
e316450271 | ||
|
|
2ef07a28fc | ||
|
|
501fa455ce | ||
|
|
132cf4d77a | ||
|
|
712d381093 | ||
|
|
107cf17611 | ||
|
|
a1b449c9be | ||
|
|
6b84665347 | ||
|
|
92a2a6d8ee | ||
|
|
55d4beb9d4 | ||
|
|
1133068a09 | ||
|
|
23ca901e82 | ||
|
|
b9a64c6560 | ||
|
|
5625310977 | ||
|
|
61c929d891 | ||
|
|
60db235f0b | ||
|
|
b25c98b7c7 | ||
|
|
ba3e1b1ae0 | ||
|
|
027646c905 | ||
|
|
eae47d99bf | ||
|
|
d954b6e90d | ||
|
|
25bae3b4a2 | ||
|
|
63528ff794 | ||
|
|
1bb15de258 | ||
|
|
aba08e36c7 | ||
|
|
4c1144ab32 | ||
|
|
bace45d6e4 | ||
|
|
15cd3c5eb8 | ||
|
|
78aab64af0 | ||
|
|
a008bb9167 | ||
|
|
7a27218149 | ||
|
|
e329fd2a1c | ||
|
|
7d1eb62d75 | ||
|
|
a05754f908 | ||
|
|
88e6471820 | ||
|
|
1d17704f72 | ||
|
|
496f7c58e3 | ||
|
|
2c24107f42 | ||
|
|
4b03ecb1fd |
@@ -111,6 +111,44 @@ terminal I/O, and reboot persistence.
|
||||
|
||||
## Stability notes (Pager 24.10.1)
|
||||
|
||||
### Reliability Core (v1.4.0)
|
||||
|
||||
The factory `/etc/config/pineapd` ships every crash source below enabled, so
|
||||
guards that only live inside Mark VIII revert on every reset/upgrade. v1.4.0
|
||||
makes the fixes structural (payload-only — no firmware changes):
|
||||
|
||||
- **Boot guard** (`mk8-guard`, START=49): enforces the known-good UCI set
|
||||
before the S50 pineapple stack starts; idempotent, diff-only commits,
|
||||
logged to `/tmp/mk8-guard.log` and the event journal.
|
||||
- **Profiles + rollback watchdogs**: every risky operation (AP deploy,
|
||||
client-mode change, profile restore) first snapshots UCI under
|
||||
`/mmc/mk8/profiles/`, then spawns a detached watchdog probing *local*
|
||||
liveness (`127.0.0.1:8080` + monitor presence). Sustained failure →
|
||||
automatic snapshot restore + `wifi reload`; recovery → promotes
|
||||
`lastknown-good`. Watchdogs self-exit after 120 quiet ticks.
|
||||
- **RF plan**: phy0 is always 2.4GHz ops; phy1 role-switches
|
||||
uplink/attack/idle (`POST /api/rfplan/role`). The uplink STA lives on
|
||||
radio1, so client-mode no longer blinds 2.4GHz recon. Hop governance
|
||||
belongs to the role manager, not the boot guard.
|
||||
- **Supervisor**: passive sampler (pidof/iw//proc only — never pineapd's
|
||||
socket), bounded JSONL journal at `/mmc/mk8/events.log`, boot-marker
|
||||
unexpected-reboot detection, memory watermark alerts. Dashboard shows
|
||||
events feed + reliability counters + guard sync chip.
|
||||
- **Atomic deploys**: `scripts/deploy.sh` stamps `VERSION` into build
|
||||
copies only, sha256-verifies the upload, swaps `/mmc/mk8/releases/<ts>`
|
||||
with a `current` symlink, polls local health, and auto-rolls back to the
|
||||
previous release on failure.
|
||||
- **Smoke suite**: `scripts/smoke.sh` on-device (read-only checks always;
|
||||
`--write` adds reconcile/watchdog drills; `SMOKE_UPLINK_SSID=...` enables
|
||||
the RF-role drill). All checks verified against live hardware including
|
||||
reboot persistence.
|
||||
|
||||
Persistent state lives in `/mmc/mk8/` and survives reboots *and* firmware
|
||||
upgrades (overlay wipes). A pre-reliability config backup is captured at
|
||||
first deploy.
|
||||
|
||||
### pineapd crash sources found and fixed on this firmware
|
||||
|
||||
pineapd crash sources found and fixed on this firmware (verified on-device,
|
||||
zero crashes over sustained watches):
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Mark VIII Field Runbook
|
||||
|
||||
Operational procedures for running Mark VIII v1.4.0+ on a WiFi Pineapple
|
||||
Pager. The reliability subsystem is documented in the README; this file is
|
||||
the how-to-run-it companion.
|
||||
|
||||
## Pre-engagement checklist (2 minutes)
|
||||
|
||||
From the Dashboard health panel (or `scripts/smoke.sh` over SSH):
|
||||
|
||||
- [ ] Health chip green; guard chip shows `GUARD OK` (not PENDING).
|
||||
- [ ] Both monitors present (`wlan0mon`, `wlan1mon`).
|
||||
- [ ] Reliability counters sane: no unexpected boots since last check;
|
||||
rollbacks/restarts at expected values.
|
||||
- [ ] Recent events feed shows a clean `boot` entry for this session.
|
||||
- [ ] RF plan as intended: `PHY0: OPS`, `PHY1:` in the state you want.
|
||||
|
||||
If anything fails: run `sh /tmp/smoke.sh` (re-upload via
|
||||
`scp scripts/smoke.sh root@<ip>:/tmp/` if tmpfs was cleared) and read
|
||||
which check fails before deploying to a target environment.
|
||||
|
||||
## During an engagement
|
||||
|
||||
- **Prefer phy1 for any uplink** (`POST /api/rfplan/role {"role":"uplink",
|
||||
"ssid":...,"psk":...}`). phy0 stays fully hoppable for 2.4 GHz work.
|
||||
Expect 5 GHz recon limited to the uplink's channel while associated.
|
||||
- **Switching back**: `{"role":"attack"}` tears the STA down with a gated
|
||||
reload; verify the RF chip flips and wlan1mon hops again.
|
||||
- **Watch the events feed.** Occasional `guard_fix` entries are the
|
||||
supervisor healing stock-daemon regressions (STA re-parks, UCI
|
||||
re-applies) — normal. A stream of them means the stock UI is fighting
|
||||
you: stop touching the stock PineAP page mid-engagement.
|
||||
- **Rollback watchdogs** arm around risky operations. If the web UI dies
|
||||
after an operation, wait ~60 s: the watchdog restores the pre-op snapshot
|
||||
automatically or promotes the change once healthy. Do NOT power-cycle
|
||||
before that window passes.
|
||||
- **Never** hand-edit `/etc/config/pineapd` mid-engagement; use Mark VIII
|
||||
endpoints. Hand edits race the stock daemon and the reconciler.
|
||||
|
||||
## After an engagement
|
||||
|
||||
1. Stop attacks and switch phy1 to `idle`.
|
||||
2. Export loot (Handshakes export, HTML/CSV reports). Loot lives under
|
||||
`/root/loot/**` and survives reboots — pull it off-box anyway.
|
||||
3. Save a profile (Settings → Reliability), e.g. `post-<site>-<date>`.
|
||||
4. Skim events for `rollback` / `mem_warn` entries worth noting.
|
||||
|
||||
## Recovery procedures
|
||||
|
||||
### Web UI unreachable but device seems alive
|
||||
|
||||
Wait 60 s — a rollback watchdog may be mid-restore. Then SSH:
|
||||
|
||||
curl -fsS http://127.0.0.1:8080/ >/dev/null && echo up
|
||||
|
||||
If still down, check whether a watchdog is running (`ps | grep watchdog`)
|
||||
and let it finish; if it already rolled back but the UI did not return,
|
||||
restart the service: `/etc/init.d/pagerwebui start`.
|
||||
|
||||
### Roll everything back to a known-good state
|
||||
|
||||
Settings → Reliability → restore `lastknown-good` (auto-captured whenever
|
||||
the system has been healthy for 5 minutes), or over SSH:
|
||||
|
||||
python3 /mmc/mk8/releases/current/user/remote_access/pager-webui/server.py \
|
||||
--rollback-snapshot lastknown-good
|
||||
|
||||
### After a firmware upgrade or factory reset
|
||||
|
||||
The overlay is wiped; Mark VIII is gone but `/mmc/mk8/` (profiles,
|
||||
journal, releases) survives. Reinstall:
|
||||
|
||||
./scripts/deploy.sh --password '<device password>'
|
||||
|
||||
The deploy re-installs mk8-guard + service from your release; profiles and
|
||||
history are still there. Verify with `sh /tmp/smoke.sh`.
|
||||
|
||||
### Device totally unreachable
|
||||
|
||||
Power cycle. On boot, mk8-guard re-applies safe PineAP UCI before pineapd
|
||||
starts, so the device comes back crash-free even if the stock daemon had
|
||||
been left in a bad state. If SSH is still refused after full boot, use the
|
||||
Hak5 first-boot recovery for the firmware image you are on.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Role drill in smoke needs a real AP: `SMOKE_UPLINK_SSID=... [--write]`.
|
||||
- While phy1 carries an uplink, 5 GHz recon sees only that channel
|
||||
(radio physics, not a bug).
|
||||
- Idle monitors park on one channel between scans; hopping resumes during
|
||||
recon scans (verified: ch48 -> ch157 -> ch40).
|
||||
- Journal is capped at 4 x 5 MB on /mmc; counters read the newest ~5000
|
||||
entries.
|
||||
@@ -0,0 +1,983 @@
|
||||
# Mark VIII Reliability Core 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:** Make the Pager run reliably and consistently as expected via payload-only hardening: boot-time guard, config profiles with rollback watchdogs, RF role manager (uplink on phy1), integrated supervisor with event journal, atomic deploys, and an on-device smoke suite.
|
||||
|
||||
**Architecture:** Three layers inside the existing payload — `mk8-guard` init script (START=49, before the S50 pineapple stack), new `mk8_*.py` stdlib modules imported by `server.py`, and a passive supervisor thread extending the existing health monitor. Persistent state in `/mmc/mk8/` (survives reboots and overlay wipes).
|
||||
|
||||
**Tech Stack:** Python 3 stdlib only (`python3-light` on device: no urllib/http.server/sqlite3 modules), POSIX sh for device scripts, vanilla JS frontend, Bash + sshpass/scp for deploy tooling.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Spec: `docs/superpowers/specs/2026-08-22-reliability-core-design.md` (approved).
|
||||
- Device constraints: python3-light stdlib only; BusyBox (no `pkill`; use `killall`/`pidof`); never actively ping pineapd's command socket from loops; never run `wifi reload` outside gated operations.
|
||||
- All persistent state under `/mmc/mk8/`. No writes to stock binaries or `/etc/config` outside reconciler/gated ops.
|
||||
- Tests: stdlib `unittest`, one module per process (tests monkeypatch module state); run pattern:
|
||||
`python3 -m unittest tests.test_<name> -v`
|
||||
- Frontend checks: `node --check <file>` after every JS edit.
|
||||
- Device access is **read-only until Task 10** (deploy + smoke). Password auth via `sshpass -p '<pw>' ssh -o StrictHostKeyChecking=no root@172.16.52.1`.
|
||||
- Branch: `feature/reliability`. Commit after every passing step.
|
||||
- Version: single-source `VERSION` file at repo root; next version `1.4.0`.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
payload/user/remote_access/pager-webui/
|
||||
mk8_events.py NEW event journal (JSONL append/rotate/read + counters)
|
||||
mk8_profiles.py NEW UCI snapshot store (/mmc/mk8/profiles)
|
||||
mk8_guard.py NEW known-good invariants + reconcile() + CLI hooks
|
||||
mk8_rfplan.py NEW phy1 RF role manager (uplink/attack/idle)
|
||||
mk8_gate.py NEW risky-op preflight gate + watchdog decision logic
|
||||
mk8-watchdog.sh NEW detached local-liveness rollback watchdog
|
||||
mk8-guard.init NEW START=49 boot guard script (installed to /etc/init.d/mk8-guard)
|
||||
server.py MOD imports, startup hook, h_health extension, /api/reliability/* + /api/rfplan/* routes, gates on risky handlers
|
||||
www/js/views.js MOD health events feed, reliability counters, RF chip, Settings profiles card
|
||||
www/js/app.js MOD nav wiring if needed
|
||||
www/css/app.css MOD styles for new UI elements
|
||||
scripts/deploy.sh MOD VERSION stamping, atomic release swap, post-deploy check, guard install
|
||||
scripts/smoke.sh NEW on-device verification suite
|
||||
VERSION NEW "1.4.0"
|
||||
tests/test_mk8_events.py, test_mk8_profiles.py, test_mk8_guard.py,
|
||||
tests/test_mk8_gate.py, test_mk8_rfplan.py, test_reliability_api.py NEW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Event journal (`mk8_events.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_events.py`
|
||||
- Test: `tests/test_mk8_events.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `log_event(kind, sev='info', msg='', meta=None)`; `read_events(limit=100)` → list of dicts newest-first; `counters()` → dict with keys `boots`, `unexpected_boots`, `rollbacks`, `restarts`, `guard_fixes`; `mark_boot()`; constants `MK8_DIR='/mmc/mk8'`, `EVENTS_PATH`, `MAX_BYTES=5*1024*1024`, `KEEP=4`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```python
|
||||
import json, os, sys, tempfile, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_events
|
||||
|
||||
class EventsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.old = mk8_events.MK8_DIR
|
||||
mk8_events.MK8_DIR = self.dir
|
||||
mk8_events.EVENTS_PATH = os.path.join(self.dir, 'events.log')
|
||||
|
||||
def tearDown(self):
|
||||
mk8_events.MK8_DIR = self.old
|
||||
|
||||
def test_log_and_read_newest_first(self):
|
||||
mk8_events.log_event('boot', msg='first')
|
||||
mk8_events.log_event('rollback', sev='warn', msg='second', meta={'op': 'wifi'})
|
||||
rows = mk8_events.read_events()
|
||||
self.assertEqual(rows[0]['kind'], 'rollback')
|
||||
self.assertEqual(rows[1]['kind'], 'boot')
|
||||
self.assertEqual(rows[0]['meta'], {'op': 'wifi'})
|
||||
|
||||
def test_counters(self):
|
||||
mk8_events.log_event('boot'); mk8_events.log_event('rollback')
|
||||
mk8_events.log_event('restart'); mk8_events.log_event('guard_fix')
|
||||
c = mk8_events.counters()
|
||||
self.assertEqual(c['boots'], 1)
|
||||
self.assertEqual(c['rollbacks'], 1)
|
||||
self.assertEqual(c['restarts'], 1)
|
||||
self.assertEqual(c['guard_fixes'], 1)
|
||||
|
||||
def test_rotation_keeps_recent(self):
|
||||
mk8_events.MAX_BYTES = 200
|
||||
for i in range(20):
|
||||
mk8_events.log_event('tick', msg='x' * 30)
|
||||
self.assertTrue(len(mk8_events.read_events()) >= 15)
|
||||
self.assertFalse(os.path.exists(mk8_events.EVENTS_PATH + '.4'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_mk8_events -v`
|
||||
Expected: FAIL — `No module named 'mk8_events'`
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
"""Mark VIII reliability event journal. JSONL on /mmc, rotated."""
|
||||
import json, os, threading
|
||||
|
||||
MK8_DIR = '/mmc/mk8'
|
||||
EVENTS_PATH = os.path.join(MK8_DIR, 'events.log')
|
||||
MAX_BYTES = 5 * 1024 * 1024
|
||||
KEEP = 4
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
COUNTER_KINDS = ('boot', 'unexpected_boot', 'rollback', 'restart',
|
||||
'guard_fix')
|
||||
|
||||
|
||||
def _ensure_dir():
|
||||
try:
|
||||
os.makedirs(MK8_DIR, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def log_event(kind, sev='info', msg='', meta=None):
|
||||
entry = {'ts': int(__import__('time').time()), 'kind': str(kind),
|
||||
'sev': sev, 'msg': msg[:500]}
|
||||
if meta:
|
||||
entry['meta'] = meta
|
||||
line = json.dumps(entry) + '\n'
|
||||
with _LOCK:
|
||||
_ensure_dir()
|
||||
try:
|
||||
if os.path.exists(EVENTS_PATH) and \
|
||||
os.path.getsize(EVENTS_PATH) > MAX_BYTES:
|
||||
for i in range(KEEP - 1, 0, -1):
|
||||
src = '%s.%d' % (EVENTS_PATH, i)
|
||||
dst = '%s.%d' % (EVENTS_PATH, i + 1)
|
||||
if os.path.exists(src):
|
||||
os.replace(src, dst)
|
||||
if os.path.exists(EVENTS_PATH):
|
||||
os.replace(EVENTS_PATH, EVENTS_PATH + '.1')
|
||||
with open(EVENTS_PATH, 'a') as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def read_events(limit=100):
|
||||
out = []
|
||||
paths = [EVENTS_PATH + '.%d' % i for i in range(KEEP, 0, -1)]
|
||||
paths.append(EVENTS_PATH)
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path) as f:
|
||||
out.extend(json.loads(l) for l in f if l.strip())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
out.sort(key=lambda r: r.get('ts', 0))
|
||||
return out[-limit:][::-1]
|
||||
|
||||
|
||||
def counters():
|
||||
counts = {k: 0 for k in COUNTER_KINDS}
|
||||
for row in read_events(limit=5000):
|
||||
k = row.get('kind')
|
||||
if k in counts:
|
||||
counts[k] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def mark_boot(unexpected=False):
|
||||
log_event('unexpected_boot' if unexpected else 'boot', sev='warn'
|
||||
if unexpected else 'info',
|
||||
msg='service started' + ('' if unexpected else ' cleanly'))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to pass**
|
||||
|
||||
Run: `python3 -m unittest tests.test_mk8_events -v` → PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/mk8_events.py tests/test_mk8_events.py
|
||||
git commit -m "feat(reliability): JSONL event journal with rotation and counters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Profile store (`mk8_profiles.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_profiles.py`
|
||||
- Test: `tests/test_mk8_profiles.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `snapshot(name)` → bool; `list_profiles()` → list of names; `restore(name)` → dict `{ok, restored:[...]}`; `auto_name(op)` → `'pre-<op>-<ts>'`; `promote_lastknown_good()`; `delete(name)`; uses `device_run` injected as module attr `run_cmd(args, timeout=20)` defaulting to `server.device_run` lazily (avoids import cycle: define own `_run` that callers/tests monkeypatch).
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```python
|
||||
import os, sys, tempfile, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_profiles
|
||||
|
||||
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||
|
||||
class ProfilesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
mk8_profiles.PROFILES_DIR = os.path.join(self.dir, 'profiles')
|
||||
self.state = {'pineapd': 'config pineapd\n\toption x y\n',
|
||||
'wireless': 'config wireless\n', 'network': ''}
|
||||
def fake_run(args, timeout=20):
|
||||
a = list(args)
|
||||
if a[:2] == ['uci', 'export']:
|
||||
return (0, self.state.get(a[2], ''), '')
|
||||
if a[:2] == ['uci', 'import'] or a[:2] == ['uci', 'commit']:
|
||||
return (0, '', '')
|
||||
return (0, '', '')
|
||||
self.runs = []
|
||||
mk8_profiles.run_cmd = lambda args, timeout=20: (
|
||||
self.runs.append(list(args)) or fake_run(args, timeout))
|
||||
|
||||
def test_snapshot_and_list(self):
|
||||
self.assertTrue(mk8_profiles.snapshot('testprof'))
|
||||
self.assertIn('testprof', mk8_profiles.list_profiles())
|
||||
|
||||
def test_restore_issues_import_per_config(self):
|
||||
mk8_profiles.snapshot('p1')
|
||||
ok = mk8_profiles.restore('p1')
|
||||
self.assertTrue(ok['ok'])
|
||||
imported = [r for r in self.runs if r[:2] == ['uci', 'import']]
|
||||
self.assertEqual(len(imported), len(CONFIGS))
|
||||
commits = [r for r in self.runs if r[:2] == ['uci', 'commit']]
|
||||
self.assertGreaterEqual(len(commits), 1)
|
||||
|
||||
def test_auto_name_format(self):
|
||||
name = mk8_profiles.auto_name('client_connect')
|
||||
self.assertTrue(name.startswith('pre-client_connect-'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify failure** — `No module named 'mk8_profiles'`
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
"""UCI profile snapshots under /mmc/mk8/profiles/<name>/{pineapd,wireless,network}"""
|
||||
import os, time
|
||||
|
||||
PROFILES_DIR = '/mmc/mk8/profiles'
|
||||
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||
|
||||
|
||||
def run_cmd(args, timeout=20):
|
||||
"""Lazy import avoids a circular import with server.py; tests monkeypatch."""
|
||||
from server import device_run
|
||||
return device_run(args, timeout=timeout)
|
||||
|
||||
|
||||
def _path(name):
|
||||
return os.path.join(PROFILES_DIR, name)
|
||||
|
||||
|
||||
def snapshot(name):
|
||||
dest = _path(name)
|
||||
try:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
wrote = False
|
||||
for cfg in CONFIGS:
|
||||
rc, out, err = run_cmd(['uci', 'export', cfg])
|
||||
if rc != 0 or not (out or '').strip():
|
||||
continue
|
||||
with open(os.path.join(dest, cfg + '.uci'), 'w') as f:
|
||||
f.write(out)
|
||||
wrote = True
|
||||
return wrote
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def auto_name(op):
|
||||
return 'pre-%s-%d' % (op, int(time.time()))
|
||||
|
||||
|
||||
def list_profiles():
|
||||
try:
|
||||
return sorted(d for d in os.listdir(PROFILES_DIR)
|
||||
if os.path.isdir(_path(d)))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def delete(name):
|
||||
import shutil
|
||||
shutil.rmtree(_path(name), ignore_errors=True)
|
||||
|
||||
|
||||
def restore(name):
|
||||
"""Restore configs then commit once per config. Caller runs wifi reload
|
||||
/ service restart as appropriate for the operation."""
|
||||
src = _path(name)
|
||||
restored = []
|
||||
if not os.path.isdir(src):
|
||||
return {'ok': False, 'restored': [], 'error': 'profile not found'}
|
||||
for cfg in CONFIGS:
|
||||
fpath = os.path.join(src, cfg + '.uci')
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
with open(fpath) as f:
|
||||
text = f.read()
|
||||
rc, _, err = run_cmd(['uci', 'import', cfg], input_data=text)
|
||||
if rc != 0:
|
||||
return {'ok': False, 'restored': restored,
|
||||
'error': 'import failed'}
|
||||
run_cmd(['uci', 'commit', cfg])
|
||||
restored.append(cfg)
|
||||
return {'ok': True, 'restored': restored}
|
||||
|
||||
|
||||
LASTKNOWN_GOOD = 'lastknown-good'
|
||||
|
||||
|
||||
def promote_lastknown_good():
|
||||
"""Replace the lastknown-good profile with the live config."""
|
||||
delete(LASTKNOWN_GOOD)
|
||||
return snapshot(LASTKNOWN_GOOD)
|
||||
```
|
||||
|
||||
Update the Step-1 fake to accept `input_data=None` and record imports:
|
||||
|
||||
```python
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a[:2] == ['uci', 'import']:
|
||||
self.imports = getattr(self, 'imports', [])
|
||||
self.imports.append((a[2], input_data))
|
||||
return (0, '', '')
|
||||
if a[:2] == ['uci', 'export']:
|
||||
return (0, self.state.get(a[2], ''), '')
|
||||
if a[:2] == ['uci', 'commit']:
|
||||
return (0, '', '')
|
||||
return (0, '', '')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to pass.**
|
||||
- [ ] **Step 5: Commit** — `feat(reliability): UCI profile snapshot store`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Reconciler (`mk8_guard.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_guard.py`
|
||||
- Test: `tests/test_mk8_guard.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `server.PINEAPD_SAFE_UCI` (dict of safe pineapd UCI values), `server._apply_uci_wanted(wanted)`, `server._monitor_down(name)`, `server._raise_monitors()`.
|
||||
- Produces: `WANTED_EXTRA = {'pineapd.@pineapd[0].autossidpool': '0'}`; `POOL_CLEAR_MAX = 20`; `reconcile(clear_pool=True)` → `{'changed': [...], 'pool_cleared': bool}`; `guard_report()` → dict for `/api/health`.
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
Full file — reuse the exact `fake_run` device-mock pattern from `tests/test_health.py`:
|
||||
|
||||
```python
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
import mk8_guard
|
||||
|
||||
class GuardTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.uci = {}
|
||||
self.mon_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||
server._iface_up = lambda name: self.mon_up.get(name, True)
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
self.calls.append(a)
|
||||
if a[:2] == ['uci', 'get']:
|
||||
key = a[2]
|
||||
if key == 'pineapd.@ssidpool[0].ssid':
|
||||
return (0, ''.join('s%d\n' % i for i in range(self.pool)), '')
|
||||
return (0, self.uci.get(key, '') + '\n', '')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci[k] = v
|
||||
if a[:2] == ['uci', 'delete']:
|
||||
self.pool = 0
|
||||
return (0, '', '')
|
||||
mk8_guard.device_run = fake_run
|
||||
|
||||
def tearDown(self):
|
||||
server._iface_up = lambda name: True
|
||||
|
||||
def test_applies_all_wanted_when_missing(self):
|
||||
self.pool = 0
|
||||
result = mk8_guard.reconcile(clear_pool=False)
|
||||
sets = [c[2] for c in self.calls if c[:2] == ['uci', 'set']]
|
||||
self.assertEqual(len(sets),
|
||||
len(server.PINEAPD_SAFE_UCI) + len(mk8_guard.WANTED_EXTRA))
|
||||
self.assertTrue(result['changed'])
|
||||
|
||||
def test_clears_large_pool_only(self):
|
||||
self.pool = 25
|
||||
result = mk8_guard.reconcile(clear_pool=True)
|
||||
self.assertTrue(result['pool_cleared'])
|
||||
self.assertIn(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'], self.calls)
|
||||
|
||||
def test_small_pool_untouched(self):
|
||||
self.pool = 5
|
||||
result = mk8_guard.reconcile(clear_pool=True)
|
||||
self.assertFalse(result['pool_cleared'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify failure** — no module.
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
"""Boot-time reconciliation of crash-prone PineAP settings."""
|
||||
from server import (_apply_uci_wanted, _monitor_down, _raise_monitors,
|
||||
PINEAPD_SAFE_UCI, device_run)
|
||||
|
||||
WANTED_EXTRA = {'pineapd.@pineapd[0].autossidpool': '0'}
|
||||
POOL_CLEAR_MAX = 20
|
||||
MONITORS = ('wlan0mon', 'wlan1mon')
|
||||
|
||||
|
||||
def _pool_size():
|
||||
rc, out, err = device_run(
|
||||
['uci', 'get', 'pineapd.@ssidpool[0].ssid'])
|
||||
if rc != 0 or not (out or '').strip():
|
||||
return 0
|
||||
return len([s for s in out.strip().split('\\n') if s])
|
||||
|
||||
|
||||
def reconcile(clear_pool=True):
|
||||
changed = _apply_uci_wanted(dict(PINEAPD_SAFE_UCI, **WANTED_EXTRA))
|
||||
pool_cleared = False
|
||||
if clear_pool and _pool_size() > POOL_CLEAR_MAX:
|
||||
device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'])
|
||||
pool_cleared = True
|
||||
if changed or pool_cleared:
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
raised = _raise_monitors() if any(_monitor_down(m) for m in MONITORS) else []
|
||||
return {'changed': changed, 'pool_cleared': pool_cleared,
|
||||
'monitors_raised': raised}
|
||||
|
||||
|
||||
def guard_report():
|
||||
from server import _pending_uci
|
||||
pending = _pending_uci(dict(PINEAPD_SAFE_UCI, **WANTED_EXTRA))
|
||||
return {'in_sync': not pending, 'pending': pending,
|
||||
'pool_size': _pool_size()}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Pass. Commit:** `feat(reliability): boot-time UCI reconciler`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Guard init script + install wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8-guard.init`
|
||||
- Modify: `scripts/deploy.sh` (install block), `payload/user/remote_access/pager-webui/server.py` (CLI flag)
|
||||
|
||||
**Interfaces:** CLI: `python3 server.py --reconcile` runs `mk8_guard.reconcile()` and prints JSON; exit 0 always (boot must not fail).
|
||||
|
||||
- [ ] **Step 1: Write `mk8-guard.init`:**
|
||||
|
||||
```sh
|
||||
#!/bin/sh /etc/rc.common
|
||||
# Mark VIII boot guard: enforce safe PineAP UCI before the S50 stack starts.
|
||||
START=49
|
||||
STOP=90
|
||||
|
||||
GUARD_DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$GUARD_DIR/server.py" ] || GUARD_DIR="/mmc/mk8/releases/current"
|
||||
|
||||
start() {
|
||||
[ -f "$GUARD_DIR/server.py" ] || return 0
|
||||
/usr/bin/python3 "$GUARD_DIR/server.py" --reconcile \
|
||||
>/tmp/mk8-guard.log 2>&1 || true
|
||||
}
|
||||
|
||||
stop() { return 0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add CLI branch in `server.py` `__main__` (after `--release-pager`):**
|
||||
|
||||
```python
|
||||
if '--reconcile' in sys.argv:
|
||||
try:
|
||||
import mk8_guard
|
||||
print(json.dumps(mk8_guard.reconcile()))
|
||||
except Exception as exc: # boot must never fail here
|
||||
print(json.dumps({'error': str(exc)}))
|
||||
sys.exit(0)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: deploy.sh install block (inside REMOTE_COMMAND before EXTRACT_OK echo):**
|
||||
|
||||
```sh
|
||||
cp -f '$DIR/mk8-guard.init' /etc/init.d/mk8-guard
|
||||
chmod 755 /etc/init.d/mk8-guard
|
||||
/etc/init.d/mk8-guard enable
|
||||
```
|
||||
|
||||
(`$DIR` is the existing remote payload dir var used by the unzip step.)
|
||||
|
||||
- [ ] **Step 4: Local verification:** `python3 -m py_compile payload/user/remote_access/pager-webui/server.py && node --check payload/user/remote_access/pager-webui/www/js/app.js` (JS untouched but cheap sanity). `sh -n scripts/deploy.sh`.
|
||||
- [ ] **Step 5: Commit** — `feat(reliability): START=49 boot guard installed by deploy`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Supervisor extension of health monitor
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py` (`health_check`, `_health_loop`, `h_health`, `serve()` boot sequence)
|
||||
- Test: `tests/test_health.py` (extend), `tests/test_reliability_api.py` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `mk8_events`, `mk8_guard.guard_report()`.
|
||||
- Produces in `_health`: `mem_percent`, `events` (last 20), `reliability` counters, `guard` report; boot-marker logic `check_boot_marker()` → bool unexpected; mem sampling `_mem_percent()`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** — add to `tests/test_health.py` (same fake_run pattern already there):
|
||||
|
||||
```python
|
||||
class SupervisorExtrasTest(unittest.TestCase):
|
||||
def runTestWith(self): # helper: reuse existing setUp fake_run
|
||||
pass
|
||||
|
||||
def test_mem_percent_math(self):
|
||||
import tempfile
|
||||
content = 'MemTotal: 250000 kB\nMemAvailable: 100000 kB\n'
|
||||
path = tempfile.mktemp()
|
||||
open(path, 'w').write(content)
|
||||
self.assertEqual(server._mem_percent(path), 60)
|
||||
|
||||
def test_health_reports_events_and_counters(self):
|
||||
import mk8_events
|
||||
mk8_events.log_event('restart', msg='x')
|
||||
status, h = server.h_health(None)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('events', h)
|
||||
self.assertIn('boots', h['reliability'])
|
||||
|
||||
def test_boot_marker_detects_unexpected(self):
|
||||
import mk8_events, tempfile, os
|
||||
marker = tempfile.mktemp()
|
||||
old = server.BOOT_MARKER
|
||||
server.BOOT_MARKER = marker
|
||||
try:
|
||||
open(marker, 'w').write('0')
|
||||
self.assertTrue(server.check_boot_marker())
|
||||
os.unlink(marker)
|
||||
self.assertFalse(server.check_boot_marker())
|
||||
finally:
|
||||
server.BOOT_MARKER = old
|
||||
```
|
||||
|
||||
(`_mem_percent` takes a `path` argument so tests inject a temp file; production call passes no arg.)
|
||||
|
||||
- [ ] **Step 2: Implement** — key code:
|
||||
|
||||
```python
|
||||
def _mem_percent(path='/proc/meminfo'):
|
||||
try:
|
||||
vals = {}
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
k, v = line.split(':')
|
||||
vals[k] = int(v.strip().split()[0])
|
||||
total = vals.get('MemTotal', 0)
|
||||
avail = vals.get('MemAvailable', vals.get('MemFree', 0))
|
||||
return round(100.0 * (total - avail) / total) if total else 0
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
|
||||
MEM_WARN_PERCENT = 85
|
||||
MEM_WARN_STREAK = 5
|
||||
|
||||
# inside health_check(), after monitor repair section:
|
||||
h['mem_percent'] = _mem_percent()
|
||||
if h['mem_percent'] >= MEM_WARN_PERCENT:
|
||||
h['mem_streak'] = h.get('mem_streak', 0) + 1
|
||||
else:
|
||||
h['mem_streak'] = 0
|
||||
if h['mem_streak'] == MEM_WARN_STREAK:
|
||||
mk8_events.log_event('mem_warn', sev='warn',
|
||||
msg='memory above %d%% sustained' % MEM_WARN_PERCENT)
|
||||
|
||||
# restart action gains journaling:
|
||||
h['fixes'] += 1
|
||||
mk8_events.log_event('restart', msg='pineapd restarted by health monitor')
|
||||
```
|
||||
|
||||
Boot marker (called from `startup_env_check` tail):
|
||||
|
||||
```python
|
||||
BOOT_MARKER = '/mmc/mk8/boot.marker'
|
||||
|
||||
def check_boot_marker():
|
||||
import os, mk8_events
|
||||
try:
|
||||
unexpected = os.path.exists(BOOT_MARKER)
|
||||
mk8_events.mark_boot(unexpected=unexpected)
|
||||
with open(BOOT_MARKER, 'w') as f:
|
||||
f.write(str(int(time.time())))
|
||||
return unexpected
|
||||
except OSError:
|
||||
return False
|
||||
```
|
||||
|
||||
`h_health` additions:
|
||||
|
||||
```python
|
||||
import mk8_events, mk8_guard
|
||||
h['reliability'] = mk8_events.counters()
|
||||
h['events'] = mk8_events.read_events(limit=20)
|
||||
h['guard'] = mk8_guard.guard_report()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run full test_health + new api test to pass.**
|
||||
- [ ] **Step 4: Commit** — `feat(reliability): supervisor sampling, event feed, boot marker`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Risky-op gate + rollback watchdog
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_gate.py`, `payload/user/remote_access/pager-webui/mk8-watchdog.sh`
|
||||
- Modify: `server.py` (wrap handlers), `deploy.sh` (ship watchdog script)
|
||||
- Test: `tests/test_mk8_gate.py`
|
||||
|
||||
**Interfaces:**
|
||||
- `watchdog_decision(fails, oks, fail_after=6, healthy_after=6)` → `'rollback'|'promote'|None` (pure).
|
||||
- `gated(op, fn)` decorator/context: snapshots `auto_name(op)`, spawns watchdog via `setsid sh mk8-watchdog.sh <profile> ... &`, runs fn, returns `(result, profile)`.
|
||||
- CLI: `server.py --rollback-snapshot <name>` restores profile + wifi reload; `--promote-snapshot <name>` promotes lastknown-good.
|
||||
|
||||
- [ ] **Step 1: Failing decision-table tests**
|
||||
|
||||
```python
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_gate
|
||||
|
||||
class DecisionTest(unittest.TestCase):
|
||||
def tick(self, state):
|
||||
action, new = mk8_gate.watchdog_decision(state)
|
||||
return action, new
|
||||
|
||||
def test_no_action_below_fail_threshold(self):
|
||||
action, s = self.tick({'fails': 5, 'oks': 0, 'tripped': False})
|
||||
self.assertIsNone(action)
|
||||
self.assertFalse(s['tripped'])
|
||||
|
||||
def test_rollback_at_threshold(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 0, 'tripped': False})
|
||||
self.assertEqual(action, 'rollback')
|
||||
self.assertTrue(s['tripped'])
|
||||
self.assertEqual(s['oks'], 0)
|
||||
|
||||
def test_promote_after_recovery(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 6, 'tripped': True})
|
||||
self.assertEqual(action, 'promote')
|
||||
|
||||
def test_no_promote_before_recovery_threshold(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 5, 'tripped': True})
|
||||
self.assertIsNone(action)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement `mk8_gate.py`**
|
||||
|
||||
```python
|
||||
"""Risky-operation gate: snapshot + detached rollback watchdog."""
|
||||
import subprocess
|
||||
|
||||
WATCHDOG = '/root/payloads/user/remote_access/pager-webui/mk8-watchdog.sh'
|
||||
FAIL_AFTER = 6 # consecutive local-liveness failures -> rollback
|
||||
HEALTHY_AFTER = 6 # consecutive successes after failure -> promote
|
||||
INTERVAL = 5 # seconds between probes
|
||||
|
||||
|
||||
def watchdog_decision(state):
|
||||
"""state: {'fails': int, 'oks': int, 'tripped': bool,
|
||||
'fail_after': 6, 'healthy_after': 6}
|
||||
Returns (action, new_state): action in {'rollback','promote',None}."""
|
||||
s = dict(state)
|
||||
fa = s.get('fail_after', FAIL_AFTER)
|
||||
ha = s.get('healthy_after', HEALTHY_AFTER)
|
||||
if not s['tripped'] and s['fails'] >= fa:
|
||||
return 'rollback', dict(s, tripped=True, oks=0)
|
||||
if s['tripped'] and s['oks'] >= ha:
|
||||
return 'promote', s
|
||||
return None, s
|
||||
|
||||
|
||||
def gated(op, profiles, spawn=None):
|
||||
"""Decorator factory: snapshot config, spawn detached watchdog, run op."""
|
||||
import shlex
|
||||
if spawn is None:
|
||||
def spawn(cmd):
|
||||
subprocess.Popen(cmd, shell=True, start_new_session=True)
|
||||
def deco(fn):
|
||||
def wrapped(*a, **kw):
|
||||
name = profiles.auto_name(op)
|
||||
profiles.snapshot(name)
|
||||
spawn("setsid sh %s %s %d %d %d >/dev/null 2>&1 &" %
|
||||
(shlex.quote(WATCHDOG), shlex.quote(name),
|
||||
INTERVAL, FAIL_AFTER, HEALTHY_AFTER))
|
||||
return fn(*a, **kw)
|
||||
return wrapped
|
||||
return deco
|
||||
```
|
||||
|
||||
(Remove the earlier `NotImplementedError` sketch entirely — this is the final form.)
|
||||
|
||||
`gated` implementation:
|
||||
|
||||
```python
|
||||
def gated(op, profiles, spawn=lambda cmd: subprocess.Popen(
|
||||
cmd, shell=True, start_new_session=True)):
|
||||
"""Decorator factory. profiles = mk8_profiles module."""
|
||||
def deco(fn):
|
||||
def wrapped(*a, **kw):
|
||||
name = profiles.auto_name(op)
|
||||
profiles.snapshot(name)
|
||||
spawn("setsid sh %s %s %d %d %d >/dev/null 2>&1 &"
|
||||
% (WATCHDOG, name, INTERVAL, FAIL_AFTER, HEALTHY_AFTER))
|
||||
return fn(*a, **kw)
|
||||
return wrapped
|
||||
return deco
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `mk8-watchdog.sh`**
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# Usage: mk8-watchdog.sh <profile> <interval> <fail_after> <healthy_after>
|
||||
PROFILE="$1"; IV="${2:-5}"; FA="${3:-6}"; HA="${4:-6}"
|
||||
DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$DIR/server.py" ] || DIR="/mmc/mk8/releases/current"
|
||||
fails=0; oks=0; tripped=0
|
||||
probe() {
|
||||
curl -fsS -m 3 http://127.0.0.1:8080/ >/dev/null 2>&1 &&
|
||||
{ ip link show wlan0mon >/dev/null 2>&1 ||
|
||||
ip link show wlan1mon >/dev/null 2>&1; }
|
||||
}
|
||||
while true; do
|
||||
if probe; then
|
||||
fails=0
|
||||
if [ "$tripped" = "1" ]; then
|
||||
oks=$((oks + 1))
|
||||
if [ "$oks" -ge "$HA" ]; then
|
||||
/usr/bin/python3 "$DIR/server.py" --promote-snapshot "$PROFILE" >/dev/null 2>&1
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fails=$((fails + 1)); oks=0
|
||||
if [ "$tripped" = "0" ] && [ "$fails" -ge "$FA" ]; then
|
||||
tripped=1
|
||||
/usr/bin/python3 "$DIR/server.py" --rollback-snapshot "$PROFILE" >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
sleep "$IV"
|
||||
done
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire gates** — decorate `h_pineap_wifi_set_ap`, `_disable_sta_uplink` call sites, enterprise deploy/stop, and any handler issuing `wifi reload`, with `@mk8_gate.gated('<op>', mk8_profiles)`.
|
||||
- [ ] **Step 5: CLI rollback/promote hooks in `server.py` `__main__`:**
|
||||
|
||||
```python
|
||||
if '--rollback-snapshot' in sys.argv:
|
||||
name = sys.argv[sys.argv.index('--rollback-snapshot') + 1]
|
||||
import mk8_profiles
|
||||
result = mk8_profiles.restore(name)
|
||||
device_run(['wifi', 'reload'], timeout=90)
|
||||
import mk8_events
|
||||
mk8_events.log_event('rollback', sev='warn',
|
||||
msg='watchdog restored %s' % name,
|
||||
meta=result)
|
||||
print(json.dumps(result))
|
||||
sys.exit(0)
|
||||
if '--promote-snapshot' in sys.argv:
|
||||
name = sys.argv[sys.argv.index('--promote-snapshot') + 1]
|
||||
import mk8_profiles
|
||||
print(json.dumps({'promoted': mk8_profiles.promote_lastknown_good()}))
|
||||
sys.exit(0)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Tests pass; `sh -n mk8-watchdog.sh`; commit** — `feat(reliability): risky-op preflight snapshots + detached rollback watchdog`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: RF role manager (`mk8_rfplan.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_rfplan.py`
|
||||
- Modify: `server.py` (routes), UI chip later in Task 8
|
||||
- Test: `tests/test_mk8_rfplan.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `server._pause_hop/_resume_hop/_read_hop`, `server.device_run`, `server._uci_values/_set_uci`, gate from Task 6.
|
||||
- Produces: `current_role()` → `'attack'|'uplink'|'idle'`; `set_role(role, ssid=None, psk=None)` → dict result (gated); ensures exclusivity: attack AP enable paths call `ensure_attack()` which auto-switches uplink→attack first.
|
||||
- Routes: `GET /api/rfplan` , `POST /api/rfplan/role`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (fake device_run capturing uci/iw calls):
|
||||
|
||||
```python
|
||||
def test_uplink_sets_sta_section_and_pauses_hop(self): ...
|
||||
def test_set_role_uplink_requires_ssid(self): ...
|
||||
def test_exclusivity_switch(self): ...
|
||||
def test_current_role_reads_uci(self): ...
|
||||
```
|
||||
|
||||
Assertions: `uci set wireless.wlan1up.mode=sta`, `.disabled=0`, `.ssid=<ssid>`, hop paused via `_pause_hop` mock, `wifi reload` invoked through gated op only.
|
||||
|
||||
- [ ] **Step 2: Implement core:**
|
||||
|
||||
```python
|
||||
ROLE_KEY = 'mk8.rfplan.role'
|
||||
|
||||
def current_role():
|
||||
from server import _uci_values
|
||||
cfg = _uci_values('wireless.wlan1up') or {}
|
||||
if cfg.get('disabled') != '1' and cfg.get('mode') == 'sta':
|
||||
return 'uplink'
|
||||
return 'idle'
|
||||
|
||||
def set_role(role, ssid=None, psk=None):
|
||||
from server import device_run, _pause_hop, _resume_hop
|
||||
if role == 'uplink':
|
||||
if not ssid:
|
||||
return {'ok': False, 'error': 'ssid required'}
|
||||
cmds = [
|
||||
['uci', 'set', 'wireless.wlan1up=wifi-iface'],
|
||||
['uci', 'set', 'wireless.wlan1up.device=radio1'],
|
||||
['uci', 'set', 'wireless.wlan1up.mode=sta'],
|
||||
['uci', 'set', 'wireless.wlan1up.network=cli'],
|
||||
['uci', 'set', 'wireless.wlan1up.ssid=%s' % ssid],
|
||||
['uci', 'set', 'wireless.wlan1up.encryption=%s'
|
||||
% ('psk2' if psk else 'none')],
|
||||
['uci', 'set', 'wireless.wlan1up.disabled=0'],
|
||||
]
|
||||
if psk:
|
||||
cmds.append(['uci', 'set', 'wireless.wlan1up.key=%s' % psk])
|
||||
for c in cmds:
|
||||
device_run(c)
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
_pause_hop()
|
||||
device_run(['wifi', 'reload'], timeout=60)
|
||||
assoc = associated()
|
||||
if not assoc:
|
||||
disable_uplink()
|
||||
_resume_hop()
|
||||
return {'ok': False, 'error': 'association failed; reverted'}
|
||||
return {'ok': True, 'role': 'uplink', 'assoc': assoc}
|
||||
# attack/idle: tear down STA
|
||||
disable_uplink()
|
||||
_resume_hop()
|
||||
return {'ok': True, 'role': role}
|
||||
|
||||
def associated():
|
||||
rc, out, err = device_run(['iw', 'dev', 'wlan1up', 'link'], timeout=10)
|
||||
if rc != 0 or 'Connected' not in (out or ''):
|
||||
return None
|
||||
for line in (out or '').splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('Connected to '):
|
||||
return line.split()[2]
|
||||
return None
|
||||
|
||||
def disable_uplink():
|
||||
from server import device_run
|
||||
device_run(['uci', 'set', 'wireless.wlan1up.disabled=1'])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
|
||||
def ensure_attack():
|
||||
if current_role() == 'uplink':
|
||||
set_role('attack')
|
||||
```
|
||||
|
||||
Handlers in server.py wrap with gate + journal events. `GET /api/rfplan` returns role + assoc + hop-paused state.
|
||||
|
||||
- [ ] **Step 3: Pass; py_compile; commit** — `feat(reliability): phy1 RF role manager with uplink-on-radio1`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: UI additions
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js`, `www/js/app.js` (nav if needed), `www/css/app.css`
|
||||
|
||||
**Steps (no unit tests; verified by `node --check` + live smoke in Task 10):**
|
||||
|
||||
- [ ] Dashboard health panel: events feed list (ts/kind/sev/msg) + counters row (boots/unexpected/rollbacks/restarts/guard fixes) + guard sync chip; render from `/api/health` new fields; CSS classes `mk8-events-feed`, `mk8-counter-row`.
|
||||
- [ ] Top bar RF chip extension: show `PHY1: UPLINK chNN` when rfplan role is uplink (poll `/api/rfplan` with existing status poll).
|
||||
- [ ] Settings: "Reliability" card — profile save input + Save button (`POST /api/reliability/profile` {name}), profile list with Restore buttons (`POST /api/reliability/restore` {name}), RF role control (role select + SSID/PSK inputs → `POST /api/rfplan/role`).
|
||||
- [ ] Add routes in Task 9's API surface before wiring buttons; keep fetch helpers identical to existing patterns (`apiFetch('/api/...')`).
|
||||
- [ ] `node --check` both JS files; bump cache-bust query `?v=` strings as existing convention does.
|
||||
- [ ] Commit — `feat(ui): reliability panel, profiles card, RF plan controls`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: API routes + atomic deploys + VERSION
|
||||
|
||||
**Files:**
|
||||
- Modify: `server.py` ROUTER block (~line 6804), `scripts/deploy.sh`, create `VERSION`
|
||||
- Test: `tests/test_reliability_api.py`
|
||||
|
||||
- [ ] **Routes:**
|
||||
|
||||
```python
|
||||
ROUTER.add('GET', r'/api/rfplan', h_rfplan_get)
|
||||
ROUTER.add('POST', r'/api/rfplan/role', h_rfplan_post)
|
||||
ROUTER.add('GET', r'/api/reliability/profiles', h_profiles_get)
|
||||
ROUTER.add('POST', r'/api/reliability/profile', h_profile_save)
|
||||
ROUTER.add('POST', r'/api/reliability/restore', h_profile_restore)
|
||||
```
|
||||
|
||||
Handlers thin-wrape `mk8_rfplan` / `mk8_profiles`; restore handler runs inside `mk8_gate.gated('restore_profile', ...)`. All auth-gated automatically by existing middleware.
|
||||
|
||||
- [ ] **VERSION file:** `1.4.0`
|
||||
- [ ] **deploy.sh rework:**
|
||||
1. Read `VERSION` → stamp build copies of `_hak5_manifest.json` (`version`), `payload.sh` header comment, and inject `SERVER_VERSION = 'X'` into staged `server.py` (build dir only, never source tree).
|
||||
2. Remote flow: scp zip to `/tmp/mk8-stage/` → verify sha256 of uploaded zip matches local → stop service → extract to `/mmc/mk8/releases/<ts>/` → repoint `current` symlink atomically (`ln -sfn`) → install/update `/etc/init.d/mk8-guard` + copy `mk8-watchdog.sh` → start → poll `http://127.0.0.1:8080/api/api_ping` (via SSH-local curl) ≤60 s → compare served banner/version → on failure: `ln -sfn` back to previous release + start + exit 1.
|
||||
3. Keep legacy overlay payload dir as symlink target for portal compatibility: `/root/payloads/user/.../pager-webui` → real dir stays, contains pointer script or bind; simplest: leave legacy install untouched and have `current` be canonical (init scripts already fall back to `/mmc/mk8/releases/current`).
|
||||
- [ ] **Tests:** unit-test stamping function `stamp_version(build_dir)` extracted into `scripts/build_common.py` (new) so it is importable: asserts manifest/payload/server contain version; sha256 check tested with tmpfiles.
|
||||
- [ ] **Commit** — `feat(deploy): atomic releases, VERSION single-source, post-deploy verification`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: On-device smoke suite + live verification
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/smoke.sh`
|
||||
|
||||
- [ ] **smoke.sh checks (each prints PASS/FAIL, non-destructive unless `--write` given):**
|
||||
1. Service up: `curl :8080/api/api_ping`.
|
||||
2. Guard installed: `[ -x /etc/init.d/mk8-guard ]` and enabled symlink exists.
|
||||
3. Invariants: `uci get` each wanted key equals expected; pool size ≤ 20.
|
||||
4. Journal writable + has boot event: tail events.log on device.
|
||||
5. Monitors up: `ip link show wlan0mon/wlan1mon`.
|
||||
6. (`--write`) Bad-value drill: set `pineapd.wlan1mon.bands='2,5'` → run `--reconcile` → expect `'5'`; set pool of 25 SSIDs → reconcile → cleared.
|
||||
7. (`--write`) Role drill: set_role uplink to lab AP → expect assoc; set_role attack → expect monitors hopping again.
|
||||
8. Deploy version match: `/api/health` version == `cat VERSION`.
|
||||
- [ ] **Execution order:** full local unit suite (every `tests/test_*.py` individually) → deploy via `./scripts/deploy.sh --password '<pw>'` → reboot device via SSH → wait for SSH return → rerun smoke.sh → confirm guards survived boot → report.
|
||||
- [ ] **Commit** — `test(smoke): on-device reliability suite` ; final tag `v1.4.0` after user confirmation.
|
||||
|
||||
---
|
||||
|
||||
## Verification matrix (spec → tasks)
|
||||
|
||||
| Spec requirement | Task |
|
||||
|---|---|
|
||||
| Boot guard before S50 | 4 |
|
||||
| Reconciler invariants (5 crash sources) | 3 |
|
||||
| Profiles + lastknown-good | 2 |
|
||||
| Knock-off rollback watchdog (local liveness) | 6 |
|
||||
| RF roles, uplink→phy1, hop pause/resume | 7 |
|
||||
| Supervisor sampling + hysteresis + alerts | 5 |
|
||||
| Event journal on /mmc + boot detection | 1, 5 |
|
||||
| Atomic deploy + version single-source | 9 |
|
||||
| UI health/events/profiles/RF | 8, 9 |
|
||||
| Unit + smoke tests | all, 10 |
|
||||
@@ -0,0 +1,202 @@
|
||||
# Mark VIII Reliability Core + Integrated Supervisor — Design
|
||||
|
||||
Date: 2026-08-22
|
||||
Branch: `feature/reliability`
|
||||
Status: Approved by user (design sections 1–7)
|
||||
|
||||
## Problem
|
||||
|
||||
Mark VIII works, but the device does not run reliably or consistently as
|
||||
expected. Evidence from 79 prior opencode sessions, the repository history,
|
||||
the factory firmware image, and the live device:
|
||||
|
||||
1. **Factory defaults are themselves unstable.** The stock firmware image
|
||||
(`pineapplepager-firmware-1.1.0-signed.bin`, OpenWrt 24.10.1,
|
||||
ramips/mt76x8, kernel 6.6.86) ships `/etc/config/pineapd` with every
|
||||
verified crash source enabled: `wlan1mon` bands `'2,5'` fast-hop,
|
||||
`wlan2mon` enabled+hop on a nonexistent interface, SSID pool without an
|
||||
explicit disable, pool target `broadcast`. Any reset, upgrade, or stock-UI
|
||||
reconvergence reintroduces pineapd SIGSEGV crash loops.
|
||||
2. **Fixes revert.** Crash-guard UCI values applied at runtime were observed
|
||||
reverting to unsafe defaults after service restarts and deploys.
|
||||
3. **Knock-offs.** Enabling client-mode uplink / `wifi reload` mid-operation
|
||||
repeatedly killed management reachability (SSH/HTTP), forcing power
|
||||
cycles and losing engagement state.
|
||||
4. **2.4 GHz blindness.** The client uplink STA on phy0 pins the radio's
|
||||
channel; wlan0mon cannot hop, so 2.4 GHz recon goes quiet while appearing
|
||||
"green" in older UI logic.
|
||||
5. **Reboot fragility.** After reboots, stale configs and refilled pools
|
||||
produced broken states until v1.3.x added startup checks; ordering is
|
||||
still wrong: Mark VIII starts at S99, *after* pineapd (S50).
|
||||
6. **Deploy fragility.** Non-atomic deploys, version confusion across three
|
||||
files, portal refresh failures, and one secret-leak incident.
|
||||
|
||||
User decisions: payload-only hardening (no firmware flashing); uplink moves
|
||||
to phy1; Reliability Core plus an integrated lightweight supervisor;
|
||||
experimental work on a branch.
|
||||
|
||||
## Goals
|
||||
|
||||
A device that: survives reboot/firmware-upgrade with safe PineAP state;
|
||||
never loses management reachability from a UI-initiated operation; keeps
|
||||
2.4 GHz operations fully available during engagements; reports radio truth;
|
||||
and self-heals known failure modes without human intervention.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Firmware repacking/flashing (parked as future experiment), new standalone
|
||||
processes/daemons, external databases, metrics graphing beyond counters,
|
||||
`:1471` takeover.
|
||||
|
||||
## Architecture
|
||||
|
||||
All changes live inside the existing payload. Three layers, one process:
|
||||
|
||||
- **Guard** — `mk8-guard` init script installed by `payload.sh` at START=49
|
||||
(before the S50 pineapple stack that launches `pineapd`): enforces the
|
||||
known-good UCI profile before crash-prone daemons start. Idempotent;
|
||||
commits only differences; logs to syslog and the event journal once Mark
|
||||
VIII is up.
|
||||
- **Core** — backend modules in `server.py` (pure stdlib, python3-light
|
||||
compatible): config reconciler, profile store, RF role manager,
|
||||
preflight/rollback gates.
|
||||
- **Supervisor** — passive sampler thread inside `server.py`, capped JSONL
|
||||
event journal, UI health panel.
|
||||
|
||||
Persistent state lives in `/mmc/mk8/` (ext4, 3.3 GB free) which survives
|
||||
reboots *and* firmware upgrades (overlay wipe):
|
||||
|
||||
```
|
||||
/mmc/mk8/
|
||||
profiles/<name>/{pineapd,wireless,network}.uci # named snapshots
|
||||
releases/{current,previous}/ # atomic deploy dirs
|
||||
events.log # rotated JSONL journal
|
||||
boot.marker # boot counter / clean-shutdown flag
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Boot-time reconciler
|
||||
|
||||
Runs on every service start (and `mk8-guard` runs it early at boot).
|
||||
Compares live UCI against the built-in known-good profile; commits only
|
||||
differences; logs each action to the journal.
|
||||
|
||||
Enforced invariants (the five verified crash sources plus v1.3.x rules):
|
||||
- `pineapd.@ssidpool[0].disable='1'` and empty `ssid` list
|
||||
- `pineapd.wlan2mon.disable='1'`, `hop='0'`
|
||||
- `pineapd.wlan1mon.bands='5'`
|
||||
- `pineapd.@pineapd[0].autossidpool='0'`
|
||||
- `wireless.dummy_radio0` parked per v1.3.1 semantics (disabled unless a
|
||||
scan explicitly borrows it)
|
||||
- monitor interfaces present and administratively up
|
||||
|
||||
The reconciler never touches AP sections owned by the user (evil twins),
|
||||
client sections, or network/firewall config.
|
||||
|
||||
### 2. Profiles + knock-off protection
|
||||
|
||||
- **Profile store**: `uci export` snapshots under `/mmc/mk8/profiles/`.
|
||||
Save/restore from Settings UI; restore = write files + `wifi reload` +
|
||||
guard re-run. One profile is auto-captured as `lastknown-good` whenever
|
||||
all health checks pass for ≥5 minutes.
|
||||
- **Preflight gate** wraps every risky operation: client-mode connect or
|
||||
disconnect, `wifi reload`, any AP enable/disable, enterprise engine
|
||||
start/stop, any UCI commit touching `wireless`/`network`. Sequence:
|
||||
auto-snapshot `pre-<op>-<ts>` → apply → spawn detached watchdog.
|
||||
- **Rollback watchdog**: a small POSIX sh script started via `setsid` so it
|
||||
survives SSH/UI death. It probes **local** liveness only — HTTP GET to
|
||||
`127.0.0.1:8080/api/health` and presence/state of the management
|
||||
interface — deliberately ignoring workstation-side reachability, which
|
||||
historically caused false assumptions. If local probes fail on N
|
||||
consecutive checks (default 6 × 5 s), it restores the pre-op snapshot,
|
||||
runs `wifi reload`, writes a `ROLLBACK` journal entry, and exits. Success
|
||||
path: after M consecutive healthy checks it promotes the snapshot to
|
||||
`lastknown-good` and exits.
|
||||
|
||||
### 3. RF role manager (uplink on phy1)
|
||||
|
||||
Declarative, mutually exclusive radio plan enforced server-side:
|
||||
- **phy0 = OPS, always**: monitor hop + PineAP + 2.4 GHz evil twins. Never
|
||||
carries the uplink again.
|
||||
- **phy1 ∈ {attack, uplink, idle}**: role switch API + UI control.
|
||||
|
||||
`set-role(uplink)`: snapshot config → create/enable a `wifi-iface` STA
|
||||
section on `radio1` → pause `wlan1mon` hop (reusing the existing pause/
|
||||
resume mechanism used by radio1 APs) → verify association truthfully
|
||||
(iw + daemon state). Failure at any step → rollback snapshot + event.
|
||||
`set-role(attack)`: STA disabled → monitor/AP stack restored.
|
||||
|
||||
Honest tradeoff surfaced in UI text: while the phy1 uplink associates, phy1
|
||||
is pinned to the uplink channel — 5 GHz recon is limited to that channel;
|
||||
2.4 GHz remains fully hoppable. The dashboard RF chip shows
|
||||
`PHY0: OPS · PHY1: UPLINK ch36` style state.
|
||||
|
||||
### 4. Supervisor
|
||||
|
||||
A sampler thread inside the existing backend process:
|
||||
- Every 30 s, passive reads only (`pidof`, `iw dev`, `/proc/meminfo`,
|
||||
interface flags) — no pineapd socket pings (crash source 5).
|
||||
- SIGSEGV trend via throttled `logread | grep -c` scan every 5 min.
|
||||
- Hysteresis actions: pineapd absent for 2 consecutive samples →
|
||||
`/etc/init.d/pineapd restart` + guard verify; monitor dropped → re-raise
|
||||
(`ip link set <iface> up`); memory >85% sustained 5 samples → alert only
|
||||
(no aggressive action).
|
||||
- Event journal: JSONL entries `{ts, kind, sev, msg, meta}` rotated at
|
||||
5 MB × 4 files.
|
||||
- Unexpected-reboot detection via `/mmc/mk8/boot.marker` (clean shutdown
|
||||
clears it; boot increments counter when present).
|
||||
- UI: Dashboard health panel gains recent-events feed + reliability
|
||||
counters (boots, unexpected boots, rollbacks, restarts, guard fixes);
|
||||
`/api/health` extended accordingly.
|
||||
|
||||
### 5. Deploy hardening
|
||||
|
||||
- Single-source version: top-level `VERSION` file consumed by build step to
|
||||
stamp `_hak5_manifest.json`, `payload.sh`, and `server.py` banner; no more
|
||||
hand-synced numbers.
|
||||
- Atomic deploys in `scripts/deploy.sh`: stage upload to `/tmp/mk8-stage`
|
||||
→ sha256 manifest verification → stop service → swap into
|
||||
`/mmc/mk8/releases/current` (previous kept) → start → post-deploy
|
||||
self-check (version match + local health probe). Failed self-check →
|
||||
previous release restored automatically.
|
||||
- Payload install continues to work from overlay paths for compatibility;
|
||||
release dir on `/mmc` is symlinked as the service target.
|
||||
|
||||
### 6. Testing & verification
|
||||
|
||||
Unit tests (existing pattern: stdlib unittest, mocks, one module per
|
||||
process): reconciler diff-only idempotence; profile save/restore roundtrip;
|
||||
role exclusivity + hop pause/resume; watchdog decision table (probe
|
||||
outcomes × thresholds); deploy staging flow with mocked SSH; supervisor
|
||||
sampling parsers and hysteresis.
|
||||
|
||||
New `scripts/smoke.sh` (on-device, read-only unless explicitly flagged):
|
||||
boot persistence of guards, guard enforcement after writing factory-default
|
||||
bad values (then restoring), role-switch cycle uplink↔attack, rollback
|
||||
watchdog trigger against a deliberately stopped port (safe variant), deploy
|
||||
version match, journal integrity.
|
||||
|
||||
## Failure modes & handling
|
||||
|
||||
| Failure | Handling |
|
||||
|---|---|
|
||||
| Factory-default bad UCI at boot | Guard fixes before pineapd starts |
|
||||
| pineapd crash-loop despite guards | procd respawn + supervisor restart w/ backoff + alert |
|
||||
| Risky op kills management plane | Local-liveness rollback watchdog restores snapshot |
|
||||
| Deploy uploads corrupt payload | sha256 gate before swap |
|
||||
| New payload fails health check | Auto-rollback to previous release |
|
||||
| Overlay wiped by firmware upgrade | Reinstall payload; profiles/journal/history survive on /mmc |
|
||||
| Memory exhaustion | Sustained-watermark alerts; no destructive automation |
|
||||
|
||||
## Security
|
||||
|
||||
No new network exposure; all new endpoints behind existing auth; watchdog
|
||||
and guard scripts are root-owned, written atomically; no credentials stored
|
||||
in repo or journal metadata (SSIDs/BSSIDs only).
|
||||
|
||||
## Out-of-scope notes
|
||||
|
||||
Custom firmware remains a documented future experiment (extraction recipe
|
||||
captured in session history: uImage kernel @0, squashfs-xz rootfs
|
||||
@0x2615dc; bootloader signature behavior unverified).
|
||||
@@ -0,0 +1,46 @@
|
||||
# PineAP Dashboard: Remove Mode Card, Move RF Role Card
|
||||
|
||||
Date: 2026-08-23
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the "Passive / Active / Advanced" mode card from the PineAP dashboard and move the "RF Role (radio1)" card into its position, cleaning up all supporting code that becomes dead as a result.
|
||||
|
||||
## Layout
|
||||
|
||||
Current PineAP dashboard rows:
|
||||
|
||||
1. `[Passive/Active/Advanced, Quick Settings]`
|
||||
2. `[RF Role (radio1)]`
|
||||
3. `[Karma, Open Network, Evil WPA]`
|
||||
|
||||
New layout:
|
||||
|
||||
1. `[RF Role (radio1), Quick Settings]`
|
||||
2. `[Karma, Open Network, Evil WPA]`
|
||||
|
||||
## Changes
|
||||
|
||||
### `payload/user/remote_access/pager-webui/www/js/views.js` (`views.pineap`)
|
||||
|
||||
- Remove mode-card UI: `mode` badge, `segBtns`/`modeBar`, `modeInfo`, `saveModeBtn`, `modeCard`.
|
||||
- Append `rfCard` to `modeRow` (first position, before Quick Settings); delete `rfRow`.
|
||||
- Remove functions `selectMode()`, `saveMode()`, `renderModeInfo()`.
|
||||
- Remove state `selectedMode`, `modeDirty`, `modePending`.
|
||||
- Remove `PINEAP_SESSION.mode` and the `'advanced'` write in `rememberAdvanced()` (Quick Settings toggles keep working).
|
||||
- In `load()`: drop the `GET /api/pineap/mode` call, `PINEAP_SESSION.mode` tracking, and the badge-class update on the removed `mode` element.
|
||||
|
||||
### `payload/user/remote_access/pager-webui/www/css/app.css`
|
||||
|
||||
- Remove mode-card-only rules: `.seg`, `.seg-btn`, `.seg-btn + .seg-btn`, `.seg-btn.active`, `.seg-btn:disabled`, `.pineap-card-button-group .seg`, `.pineap-card-button-group .seg-btn`, `.pineap-mode-save`, `.pineap-mode-features`.
|
||||
- Keep `.pineap-card-title-flex` (used by other views).
|
||||
|
||||
## Untouched
|
||||
|
||||
- Backend `/api/pineap/mode` endpoint (still exercised by `tests/test_pineap_modes.py`).
|
||||
- RF role logic, all other views, `build/` (regenerated by `scripts/deploy.sh`).
|
||||
|
||||
## Verification
|
||||
|
||||
- `node --check` on `views.js`.
|
||||
- Manual browser check of the PineAP dashboard layout.
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# Mark VIII boot guard: enforce safe PineAP UCI before the S50 stack starts.
|
||||
START=49
|
||||
STOP=90
|
||||
|
||||
GUARD_DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$GUARD_DIR/server.py" ] || GUARD_DIR="/mmc/mk8/releases/current"
|
||||
|
||||
start() {
|
||||
[ -f "$GUARD_DIR/server.py" ] || return 0
|
||||
/usr/bin/python3 "$GUARD_DIR/server.py" --reconcile \
|
||||
>/tmp/mk8-guard.log 2>&1 || true
|
||||
}
|
||||
|
||||
stop() { return 0; }
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Usage: mk8-watchdog.sh <profile> <interval> <fail_after> <healthy_after> [max_ticks]
|
||||
# Exits quietly after max_ticks healthy ticks so sentinels cannot accumulate.
|
||||
PROFILE="$1"; IV="${2:-5}"; FA="${3:-6}"; HA="${4:-6}"; MT="${5:-120}"
|
||||
DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$DIR/server.py" ] || DIR="/mmc/mk8/releases/current"
|
||||
fails=0; oks=0; tripped=0; ticks=0
|
||||
probe() {
|
||||
curl -fsS -m 3 http://127.0.0.1:8080/ >/dev/null 2>&1 &&
|
||||
{ ip link show wlan0mon >/dev/null 2>&1 ||
|
||||
ip link show wlan1mon >/dev/null 2>&1; }
|
||||
}
|
||||
while true; do
|
||||
if probe; then
|
||||
fails=0
|
||||
ticks=$((ticks + 1))
|
||||
if [ "$tripped" = "0" ] && [ "$ticks" -ge "$MT" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$tripped" = "1" ]; then
|
||||
oks=$((oks + 1))
|
||||
if [ "$oks" -ge "$HA" ]; then
|
||||
/usr/bin/python3 "$DIR/server.py" --promote-snapshot "$PROFILE" >/dev/null 2>&1
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fails=$((fails + 1)); oks=0
|
||||
if [ "$tripped" = "0" ] && [ "$fails" -ge "$FA" ]; then
|
||||
tripped=1
|
||||
/usr/bin/python3 "$DIR/server.py" --rollback-snapshot "$PROFILE" >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
sleep "$IV"
|
||||
done
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Mark VIII reliability event journal. JSONL on /mmc, rotated."""
|
||||
import json, os, threading, time
|
||||
from collections import deque
|
||||
|
||||
MK8_DIR = '/mmc/mk8'
|
||||
EVENTS_PATH = os.path.join(MK8_DIR, 'events.log')
|
||||
MAX_BYTES = 5 * 1024 * 1024
|
||||
KEEP = 4
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
_COUNTER_KEYS = {'boot': 'boots', 'unexpected_boot': 'unexpected_boots',
|
||||
'rollback': 'rollbacks', 'restart': 'restarts',
|
||||
'guard_fix': 'guard_fixes'}
|
||||
COUNTER_KINDS = tuple(_COUNTER_KEYS)
|
||||
|
||||
|
||||
def _ensure_dir():
|
||||
try:
|
||||
os.makedirs(MK8_DIR, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def log_event(kind, sev='info', msg='', meta=None):
|
||||
"""Append one journal entry. Never raises: a reliability journal that
|
||||
can crash its caller would defeat its purpose. Single-writer per
|
||||
process is assumed; there is no inter-process lock."""
|
||||
try:
|
||||
entry = {'ts': int(time.time()), 'kind': str(kind), 'sev': sev,
|
||||
'msg': str(msg)[:500]}
|
||||
if meta is not None:
|
||||
json.dumps(meta)
|
||||
entry['meta'] = meta
|
||||
line = json.dumps(entry) + '\n'
|
||||
except Exception:
|
||||
try:
|
||||
line = json.dumps({'ts': int(time.time()), 'kind': str(kind),
|
||||
'sev': sev, 'msg': str(msg)[:500],
|
||||
'meta_repr': repr(meta)[:500]}) + '\n'
|
||||
except Exception:
|
||||
return
|
||||
with _LOCK:
|
||||
_ensure_dir()
|
||||
try:
|
||||
if os.path.exists(EVENTS_PATH) and \
|
||||
os.path.getsize(EVENTS_PATH) > MAX_BYTES:
|
||||
for i in range(KEEP - 1, 0, -1):
|
||||
src = '%s.%d' % (EVENTS_PATH, i)
|
||||
dst = '%s.%d' % (EVENTS_PATH, i + 1)
|
||||
if os.path.exists(src):
|
||||
os.replace(src, dst)
|
||||
if os.path.exists(EVENTS_PATH):
|
||||
os.replace(EVENTS_PATH, EVENTS_PATH + '.1')
|
||||
with open(EVENTS_PATH, 'a') as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def read_events(limit=100):
|
||||
out = []
|
||||
paths = [EVENTS_PATH + '.%d' % i for i in range(KEEP, 0, -1)]
|
||||
paths.append(EVENTS_PATH)
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path) as f:
|
||||
for l in f:
|
||||
if not l.strip():
|
||||
continue
|
||||
try:
|
||||
row = json.loads(l)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
out.append(row)
|
||||
except OSError:
|
||||
continue
|
||||
out.sort(key=lambda r: r.get('ts', 0))
|
||||
return out[-limit:][::-1]
|
||||
|
||||
|
||||
def counters():
|
||||
counts = {v: 0 for v in _COUNTER_KEYS.values()}
|
||||
for row in read_events(limit=5000):
|
||||
k = row.get('kind')
|
||||
if k in _COUNTER_KEYS:
|
||||
counts[_COUNTER_KEYS[k]] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def snapshot(event_limit=20, scan=2000):
|
||||
"""Newest-first events (up to event_limit) plus kind counters computed
|
||||
over at most `scan` most-recent entries, in ONE parse pass. Bounded so a
|
||||
large rotated journal cannot spike memory/CPU on every health poll."""
|
||||
events = []
|
||||
counts = {v: 0 for v in _COUNTER_KEYS.values()}
|
||||
scanned = 0
|
||||
paths = [EVENTS_PATH]
|
||||
paths.extend(EVENTS_PATH + '.%d' % i for i in range(KEEP, 0, -1))
|
||||
tail_len = max(scan, event_limit)
|
||||
for path in paths:
|
||||
if len(events) >= event_limit and scanned >= scan:
|
||||
break
|
||||
try:
|
||||
with open(path) as f:
|
||||
tail = deque((l for l in f if l.strip()), maxlen=tail_len)
|
||||
except OSError:
|
||||
continue
|
||||
for line in reversed(tail):
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
scanned += 1
|
||||
kind = row.get('kind')
|
||||
if kind in _COUNTER_KEYS:
|
||||
counts[_COUNTER_KEYS[kind]] += 1
|
||||
if len(events) < event_limit:
|
||||
events.append(row)
|
||||
if len(events) >= event_limit and scanned >= scan:
|
||||
break
|
||||
events.sort(key=lambda r: r.get('ts', 0), reverse=True)
|
||||
return {'events': events[:event_limit], 'reliability': counts}
|
||||
|
||||
|
||||
def mark_boot(unexpected=False):
|
||||
log_event('unexpected_boot' if unexpected else 'boot', sev='warn'
|
||||
if unexpected else 'info',
|
||||
msg='service started' + ('' if unexpected else ' cleanly'))
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Risky-operation gate: preflight config snapshot + detached rollback watchdog."""
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
WATCHDOG = '/root/payloads/user/remote_access/pager-webui/mk8-watchdog.sh'
|
||||
FAIL_AFTER = 6 # consecutive local-liveness failures -> rollback
|
||||
HEALTHY_AFTER = 6 # consecutive successes after failure -> promote
|
||||
INTERVAL = 5 # seconds between probes
|
||||
MAX_TICKS = 120 # watchdog self-exits after this many quiet ticks
|
||||
_ENTER_LOCK = threading.Lock()
|
||||
|
||||
# Dormant until an entrypoint (serve() / CLI ops) flips it on, so importing
|
||||
# this module never snapshots or spawns anything.
|
||||
ENABLED = False
|
||||
|
||||
|
||||
def watchdog_decision(state):
|
||||
"""state: {'fails': int, 'oks': int, 'tripped': bool,
|
||||
'fail_after': 6, 'healthy_after': 6}
|
||||
Returns (action, new_state): action in {'rollback','promote',None}."""
|
||||
s = dict(state)
|
||||
fa = s.get('fail_after', FAIL_AFTER)
|
||||
ha = s.get('healthy_after', HEALTHY_AFTER)
|
||||
if not s['tripped'] and s['fails'] >= fa:
|
||||
return 'rollback', dict(s, tripped=True, oks=0)
|
||||
if s['tripped'] and s['oks'] >= ha:
|
||||
return 'promote', s
|
||||
return None, s
|
||||
|
||||
|
||||
def _spawn_watchdog(name):
|
||||
cmd = ('setsid sh %s %s %d %d %d %d >/dev/null 2>&1 &'
|
||||
% (shlex.quote(WATCHDOG), shlex.quote(name),
|
||||
INTERVAL, FAIL_AFTER, HEALTHY_AFTER, MAX_TICKS))
|
||||
return subprocess.Popen(cmd, shell=True, start_new_session=True)
|
||||
|
||||
|
||||
def enter(op):
|
||||
"""Snapshot + spawn watchdog. Returns profile name or None when disabled.
|
||||
Serialized so concurrent gated ops cannot interleave snapshots or spawn
|
||||
racing watchdogs."""
|
||||
if not ENABLED:
|
||||
return None
|
||||
with _ENTER_LOCK:
|
||||
import mk8_profiles
|
||||
name = mk8_profiles.auto_name(op)
|
||||
mk8_profiles.snapshot(name)
|
||||
_spawn_watchdog(name)
|
||||
try:
|
||||
import mk8_events
|
||||
mk8_events.log_event('gate', msg='preflight snapshot %s' % name)
|
||||
except Exception:
|
||||
pass
|
||||
return name
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Boot-time reconciliation of crash-prone PineAP settings."""
|
||||
import time
|
||||
|
||||
from server import (_apply_uci_wanted, _monitor_down, _raise_monitors,
|
||||
PINEAPD_SAFE_UCI, device_run)
|
||||
|
||||
WANTED_EXTRA = {'pineapd.@pineapd[0].autossidpool': '0'}
|
||||
POOL_CLEAR_MAX = 20
|
||||
MONITORS = ('wlan0mon', 'wlan1mon')
|
||||
# pineapd.wlan1mon.hop is intentionally NOT part of the applied set: channel
|
||||
# hopping is owned by the RF role manager (_pause_hop/_resume_hop). Hop=1 is
|
||||
# the healthy recon baseline, so reconciling it here would silently disable a
|
||||
# pager-enabled setting at every boot and latch GUARD PENDING after any
|
||||
# attack-role switch.
|
||||
GR_TTL_SECONDS = 30
|
||||
_GR_CACHE = {'t': 0.0, 'data': None}
|
||||
|
||||
|
||||
def _wanted():
|
||||
wanted = {k: v for k, v in PINEAPD_SAFE_UCI.items()
|
||||
if k != 'pineapd.wlan1mon.hop'}
|
||||
wanted.update(WANTED_EXTRA)
|
||||
return wanted
|
||||
|
||||
|
||||
def _pool_size():
|
||||
rc, out, err = device_run(
|
||||
['uci', 'get', 'pineapd.@ssidpool[0].ssid'])
|
||||
if rc != 0 or not (out or '').strip():
|
||||
return 0
|
||||
return len(out.split())
|
||||
|
||||
|
||||
def _ensure_pineapd_section():
|
||||
"""Stock daemon rewrites and profile restores can drop the whole
|
||||
`config pineapd` section; every @pineapd[0] option write fails with
|
||||
'Invalid argument' until it exists again. Probes SECTION existence
|
||||
(`uci -q show @pineapd[0]`) — never an option, which may legitimately
|
||||
be absent from a rewritten section."""
|
||||
rc, out, err = device_run(['uci', '-q', 'show', 'pineapd.@pineapd[0]'])
|
||||
if rc == 0:
|
||||
return False
|
||||
device_run(['uci', 'add', 'pineapd', 'pineapd'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
return True
|
||||
|
||||
|
||||
def reconcile(clear_pool=True):
|
||||
changed = []
|
||||
if _ensure_pineapd_section():
|
||||
changed.append('pineapd.@pineapd[0] (section recreated)')
|
||||
changed += _apply_uci_wanted(_wanted())
|
||||
pool_cleared = False
|
||||
if clear_pool and _pool_size() > POOL_CLEAR_MAX:
|
||||
device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'])
|
||||
pool_cleared = True
|
||||
if changed or pool_cleared:
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
raised = _raise_monitors() if any(_monitor_down(m) for m in MONITORS) else []
|
||||
_GR_CACHE['data'] = None
|
||||
return {'changed': changed, 'pool_cleared': pool_cleared,
|
||||
'monitors_raised': raised}
|
||||
|
||||
|
||||
def guard_report():
|
||||
now = time.time()
|
||||
cached = _GR_CACHE['data']
|
||||
if cached is not None and now - _GR_CACHE['t'] < GR_TTL_SECONDS:
|
||||
return cached
|
||||
from server import _pending_uci
|
||||
_ensure_pineapd_section()
|
||||
pending = _pending_uci(_wanted())
|
||||
report = {'in_sync': not pending, 'pending': pending,
|
||||
'pool_size': _pool_size()}
|
||||
_GR_CACHE['t'] = now
|
||||
_GR_CACHE['data'] = report
|
||||
return report
|
||||
@@ -0,0 +1,96 @@
|
||||
"""UCI profile snapshots under /mmc/mk8/profiles/<name>/{pineapd,wireless,network}"""
|
||||
import os, re, time
|
||||
|
||||
PROFILES_DIR = '/mmc/mk8/profiles'
|
||||
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||
NAME_RE = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
|
||||
|
||||
|
||||
def run_cmd(args, timeout=20, input_data=None):
|
||||
"""Lazy import avoids a circular import with server.py; tests monkeypatch."""
|
||||
from server import device_run
|
||||
return device_run(args, timeout=timeout, input_data=input_data)
|
||||
|
||||
|
||||
def _path(name):
|
||||
"""Resolve a profile name to its directory. HTTP-supplied names are never
|
||||
trusted: reject anything but [A-Za-z0-9._-]{1,64} and explicitly refuse
|
||||
'.'/'..' so traversal can never escape PROFILES_DIR."""
|
||||
if not isinstance(name, str) or not NAME_RE.fullmatch(name) \
|
||||
or name in ('.', '..'):
|
||||
raise ValueError('invalid profile name')
|
||||
return os.path.join(PROFILES_DIR, name)
|
||||
|
||||
|
||||
def snapshot(name):
|
||||
dest = _path(name)
|
||||
try:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
wrote = False
|
||||
for cfg in CONFIGS:
|
||||
rc, out, err = run_cmd(['uci', 'export', cfg])
|
||||
if rc != 0 or not (out or '').strip():
|
||||
continue
|
||||
with open(os.path.join(dest, cfg + '.uci'), 'w') as f:
|
||||
f.write(out)
|
||||
wrote = True
|
||||
return wrote
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def auto_name(op):
|
||||
return 'pre-%s-%d' % (op, int(time.time()))
|
||||
|
||||
|
||||
def list_profiles():
|
||||
try:
|
||||
out = []
|
||||
for d in os.listdir(PROFILES_DIR):
|
||||
try:
|
||||
if os.path.isdir(_path(d)):
|
||||
out.append(d)
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(out)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def delete(name):
|
||||
import shutil
|
||||
shutil.rmtree(_path(name), ignore_errors=True)
|
||||
|
||||
|
||||
def restore(name):
|
||||
"""Restore configs then commit once per config. Caller runs wifi reload
|
||||
/ service restart as appropriate for the operation."""
|
||||
src = _path(name)
|
||||
restored = []
|
||||
if not os.path.isdir(src):
|
||||
return {'ok': False, 'restored': [], 'error': 'profile not found'}
|
||||
for cfg in CONFIGS:
|
||||
fpath = os.path.join(src, cfg + '.uci')
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
with open(fpath) as f:
|
||||
text = f.read()
|
||||
rc, _, err = run_cmd(['uci', 'import', cfg], input_data=text)
|
||||
if rc != 0:
|
||||
return {'ok': False, 'restored': restored,
|
||||
'error': 'import failed'}
|
||||
crc, _, cerr = run_cmd(['uci', 'commit', cfg])
|
||||
if crc != 0:
|
||||
return {'ok': False, 'restored': restored,
|
||||
'error': 'commit failed: %s' % (cerr or cfg)}
|
||||
restored.append(cfg)
|
||||
return {'ok': True, 'restored': restored}
|
||||
|
||||
|
||||
LASTKNOWN_GOOD = 'lastknown-good'
|
||||
|
||||
|
||||
def promote_lastknown_good():
|
||||
"""Replace the lastknown-good profile with the live config."""
|
||||
delete(LASTKNOWN_GOOD)
|
||||
return snapshot(LASTKNOWN_GOOD)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Mark VIII RF role manager: radio1/phy1 is shared between an uplink STA
|
||||
(``wlan1up``) and attack work, so the roles are made mutually exclusive.
|
||||
Uplink pauses channel hopping; attack/idle resumes it."""
|
||||
import time
|
||||
|
||||
ROLE_KEY = 'mk8.rfplan.role'
|
||||
IFACE = 'wlan1up'
|
||||
|
||||
# wifi reload returns while wpa_supplicant is still scanning/authenticating;
|
||||
# poll instead of checking once or every real uplink would false-fail.
|
||||
ASSOC_ATTEMPTS = 5
|
||||
# Security modes tried in order for PSK uplinks. sae-mixed covers
|
||||
# WPA2/WPA3 transition APs; plain SAE covers WPA3-only (PMF required);
|
||||
# psk2 covers legacy WPA2-PSK. ieee80211w matches each mode's PMF need.
|
||||
PSK_MODE_CHAIN = (('sae-mixed', '1'), ('sae', '2'), ('psk2', '0'))
|
||||
ASSOC_WAIT_SECONDS = 2
|
||||
|
||||
|
||||
def current_role():
|
||||
from server import _uci_values
|
||||
cfg = _uci_values('wireless.%s' % IFACE) or {}
|
||||
if cfg.get('disabled') != '1' and cfg.get('mode') == 'sta':
|
||||
return 'uplink'
|
||||
return 'idle'
|
||||
|
||||
|
||||
def _sta_netdev():
|
||||
"""Actual netdev carrying the radio1 STA. OpenWrt ignores a requested
|
||||
ifname for mac80211 STA ifaces (comes up as phy1-sta0), so resolve by
|
||||
phy membership + managed type instead of by name."""
|
||||
from server import device_run
|
||||
rc, out, err = device_run(['iw', 'dev'], timeout=10)
|
||||
if rc != 0:
|
||||
return None
|
||||
current = None
|
||||
managed = []
|
||||
for line in (out or '').splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('Interface '):
|
||||
current = line.split()[1]
|
||||
elif line.startswith('type managed') and current:
|
||||
if not current.startswith('wlan0'):
|
||||
managed.append(current)
|
||||
current = None
|
||||
for name in managed:
|
||||
rc2, o2, _ = device_run(
|
||||
['readlink', '/sys/class/net/%s/phy80211' % name], timeout=10)
|
||||
if rc2 == 0 and 'phy1' in (o2 or ''):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def associated():
|
||||
"""BSSID of the uplink AP when the radio1 STA is associated, else None."""
|
||||
from server import device_run
|
||||
dev = _sta_netdev()
|
||||
if not dev:
|
||||
return None
|
||||
rc, out, err = device_run(['iw', 'dev', dev, 'link'], timeout=10)
|
||||
if rc != 0 or 'Connected' not in (out or ''):
|
||||
return None
|
||||
for line in (out or '').splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('Connected to '):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return None
|
||||
|
||||
|
||||
def hop_paused():
|
||||
from server import _read_hop
|
||||
return _read_hop() == '0'
|
||||
|
||||
|
||||
def _ensure_cli_network():
|
||||
"""Make network 'cli' usable for the STA. Returns True when a network
|
||||
change was staged and still needs ``uci commit network``. Stock firmware
|
||||
ships 'cli' present but disabled; create a minimal DHCP interface when it
|
||||
is missing entirely so netifd can bring wlan1up up either way."""
|
||||
from server import device_run
|
||||
rc, _, _ = device_run(['uci', '-q', 'get', 'network.cli'])
|
||||
if rc != 0:
|
||||
device_run(['uci', 'set', 'network.cli=interface'])
|
||||
device_run(['uci', 'set', 'network.cli.proto=dhcp'])
|
||||
return True
|
||||
rc, out, _ = device_run(['uci', '-q', 'get', 'network.cli.disabled'])
|
||||
if rc == 0 and out.strip() == '1':
|
||||
device_run(['uci', 'set', 'network.cli.disabled=0'])
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def set_role(role, ssid=None, psk=None):
|
||||
from server import device_run, _pause_hop, _resume_hop
|
||||
if role not in ('uplink', 'attack', 'idle'):
|
||||
return {'ok': False, 'error': 'role must be uplink, attack or idle'}
|
||||
if role == 'uplink':
|
||||
if not ssid:
|
||||
return {'ok': False, 'error': 'ssid required'}
|
||||
base_cmds = [
|
||||
['uci', 'set', 'wireless.wlan1up=wifi-iface'],
|
||||
['uci', 'set', 'wireless.wlan1up.device=radio1'],
|
||||
['uci', 'set', 'wireless.wlan1up.mode=sta'],
|
||||
['uci', 'set', 'wireless.wlan1up.network=cli'],
|
||||
['uci', 'set', 'wireless.wlan1up.ssid=%s' % ssid],
|
||||
['uci', 'set', 'wireless.wlan1up.disabled=0'],
|
||||
]
|
||||
if psk:
|
||||
base_cmds.append(['uci', 'set',
|
||||
'wireless.wlan1up.key=%s' % psk])
|
||||
for c in base_cmds:
|
||||
device_run(c)
|
||||
if _ensure_cli_network():
|
||||
# netifd consumes committed config only; staging without commit
|
||||
# would leave the STA with no L3 attachment.
|
||||
device_run(['uci', 'commit', 'network'])
|
||||
_pause_hop()
|
||||
assoc = None
|
||||
used_mode = None
|
||||
modes = PSK_MODE_CHAIN if psk else [('none', None)]
|
||||
for enc, pmf in modes:
|
||||
device_run(['uci', 'set', 'wireless.wlan1up.encryption=%s' % enc])
|
||||
if pmf is not None:
|
||||
device_run(['uci', 'set',
|
||||
'wireless.wlan1up.ieee80211w=%s' % pmf])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
device_run(['wifi', 'reload'], timeout=60)
|
||||
for _ in range(ASSOC_ATTEMPTS):
|
||||
time.sleep(ASSOC_WAIT_SECONDS)
|
||||
assoc = associated()
|
||||
if assoc:
|
||||
break
|
||||
if assoc:
|
||||
used_mode = enc
|
||||
break
|
||||
if not assoc or not used_mode:
|
||||
disable_uplink()
|
||||
# UCI alone does not converge runtime: without a reload wlan1up
|
||||
# keeps scanning/authenticating and pins phy1 until some unrelated
|
||||
# future reload, while current_role() already reports idle.
|
||||
# Converge now like the idle branch, then reapply the hop policy.
|
||||
device_run(['wifi', 'reload'], timeout=60)
|
||||
_resume_hop()
|
||||
return {'ok': False,
|
||||
'error': 'association failed; reverted',
|
||||
'tried_modes': [m for m, _ in modes]}
|
||||
return {'ok': True, 'role': 'uplink', 'assoc': assoc,
|
||||
'mode': used_mode}
|
||||
# attack/idle: tear down the STA so radio1 is free again.
|
||||
disable_uplink()
|
||||
_resume_hop()
|
||||
if role == 'attack':
|
||||
# The deploy path performs its own wifi reload right after; teardown
|
||||
# converges there without a second reload churn on this phy.
|
||||
try:
|
||||
import mk8_events
|
||||
mk8_events.log_event(
|
||||
'rfplan', msg='rfplan role attack applied; STA teardown '
|
||||
'applies at next wifi reload')
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# idle has no guaranteed follow-up reload anywhere else, so converge
|
||||
# now while the gated watchdog is still armed.
|
||||
device_run(['wifi', 'reload'], timeout=60)
|
||||
return {'ok': True, 'role': role}
|
||||
|
||||
|
||||
def disable_uplink():
|
||||
from server import device_run
|
||||
device_run(['uci', 'set', 'wireless.wlan1up.disabled=1'])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
|
||||
|
||||
def ensure_attack():
|
||||
"""Exclusivity hook for radio1 attack-AP enable paths: switch the
|
||||
uplink off first so one phy never carries STA + AP at once."""
|
||||
if current_role() == 'uplink':
|
||||
return set_role('attack')
|
||||
return None
|
||||
@@ -107,6 +107,8 @@ SELF_PAYLOAD_KEY = os.environ.get('PAGER_SELF_PAYLOAD_KEY', 'user~remote_access~
|
||||
|
||||
def device_run(args, timeout=20, input_data=None):
|
||||
try:
|
||||
if isinstance(input_data, str):
|
||||
input_data = input_data.encode('utf-8')
|
||||
p = subprocess.run(args, input=input_data, capture_output=True, timeout=timeout)
|
||||
return p.returncode, p.stdout.decode('utf-8', 'replace'), p.stderr.decode('utf-8', 'replace')
|
||||
except FileNotFoundError:
|
||||
@@ -2279,9 +2281,10 @@ def _recon_html_download(scan_id, data, client_count, archive=None):
|
||||
body_parts.append(_html_table(['Band', 'Channel', 'Access Points'], chan_rows))
|
||||
else:
|
||||
body_parts.append('<p class="empty">No access points with a known channel.</p>')
|
||||
# AP table with color-coded signal.
|
||||
# AP table with color-coded signal, strongest signal first.
|
||||
ap_rows = []
|
||||
for a in aps:
|
||||
for a in sorted(aps, key=lambda row: row.get('signal')
|
||||
if row.get('signal') is not None else -200, reverse=True):
|
||||
ap_rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
|
||||
a.get('band') or '--',
|
||||
a.get('channel') if a.get('channel') is not None else '--',
|
||||
@@ -3443,11 +3446,16 @@ def _read_hop():
|
||||
return out.strip()
|
||||
|
||||
|
||||
_HOP_PAUSE_HELD = False # True while a Mark VIII role/attack holds hop off
|
||||
|
||||
|
||||
def _pause_hop():
|
||||
global _HOP_PAUSE_HELD
|
||||
if _read_hop() != '0':
|
||||
device_run(['uci', 'set', 'pineapd.wlan1mon.hop=0'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
device_run(['/etc/init.d/pineapd', 'reload'])
|
||||
_HOP_PAUSE_HELD = True
|
||||
|
||||
|
||||
def _resume_hop():
|
||||
@@ -3466,6 +3474,8 @@ def _resume_hop():
|
||||
device_run(['uci', 'set', 'pineapd.wlan1mon.hop=%s' % wanted])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
device_run(['/etc/init.d/pineapd', 'reload'])
|
||||
global _HOP_PAUSE_HELD
|
||||
_HOP_PAUSE_HELD = False
|
||||
|
||||
|
||||
def _remove_radio1_ap():
|
||||
@@ -3526,6 +3536,8 @@ def _apply_radio1_ap(openap, wpa):
|
||||
|
||||
|
||||
def h_pineap_wifi_set_ap(ctx):
|
||||
import mk8_gate
|
||||
mk8_gate.enter('ap_change')
|
||||
body = ctx.body or {}
|
||||
wpa = body.get('wpa') or {}
|
||||
openap = body.get('open') or {}
|
||||
@@ -3533,6 +3545,13 @@ def h_pineap_wifi_set_ap(ctx):
|
||||
open_band = channel_band(openap.get('channel')) if openap.get('channel') is not None else None
|
||||
use_radio1 = wpa_band in (BAND_5G, BAND_6G) or open_band in (BAND_5G, BAND_6G)
|
||||
if use_radio1:
|
||||
# Exclusivity: an uplink STA on radio1 must go down before any
|
||||
# radio1 AP change so one phy never carries STA + AP at once.
|
||||
import mk8_rfplan
|
||||
try:
|
||||
mk8_rfplan.ensure_attack()
|
||||
except Exception:
|
||||
pass
|
||||
wpa_active = (wpa_band in (BAND_5G, BAND_6G) and bool(wpa.get('enabled', True))
|
||||
and wpa.get('channel') is not None)
|
||||
open_active = (open_band in (BAND_5G, BAND_6G) and bool(openap.get('enabled', True))
|
||||
@@ -4350,6 +4369,14 @@ def _enterprise_boot_recover():
|
||||
|
||||
|
||||
def h_attacks_deploy(ctx):
|
||||
import mk8_gate
|
||||
import mk8_rfplan
|
||||
mk8_gate.enter('attack_deploy')
|
||||
# Exclusivity: tear an active uplink down before deploying attack APs.
|
||||
try:
|
||||
mk8_rfplan.ensure_attack()
|
||||
except Exception:
|
||||
pass
|
||||
body = ctx.body or {}
|
||||
kind = (body.get('kind') or '').strip().lower()
|
||||
if kind not in ('wpa', 'open', 'enterprise'):
|
||||
@@ -4439,6 +4466,8 @@ def _radio1_ap_active():
|
||||
|
||||
|
||||
def h_attacks_stop(ctx):
|
||||
import mk8_gate
|
||||
mk8_gate.enter('attack_stop')
|
||||
kind = ((ctx.body or {}).get('kind') or '')
|
||||
stopped = []
|
||||
if kind in ('wpa', 'open'):
|
||||
@@ -4622,12 +4651,16 @@ PINEAPD_RUNTIME_UCI.add('pineapd.@ssidpool[0].disable')
|
||||
|
||||
|
||||
def _apply_uci_wanted(wanted):
|
||||
"""Idempotently apply a wanted UCI key/value set. Returns changed keys."""
|
||||
"""Idempotently apply a wanted UCI key/value set. Returns changed keys.
|
||||
A failed `uci set` (e.g. missing anchor section) is NOT reported as
|
||||
applied."""
|
||||
actions = []
|
||||
for key, value in wanted.items():
|
||||
rc, out, err = device_run(['uci', 'get', key])
|
||||
if rc != 0 or out.strip() != value:
|
||||
device_run(['uci', 'set', '%s=%s' % (key, value)])
|
||||
src_rc, s_out, s_err = device_run(
|
||||
['uci', 'set', '%s=%s' % (key, value)])
|
||||
if src_rc == 0:
|
||||
actions.append(key)
|
||||
return actions
|
||||
|
||||
@@ -4682,6 +4715,25 @@ def _monitor_down(name):
|
||||
return not _iface_up(name)
|
||||
|
||||
|
||||
def _mem_percent(path='/proc/meminfo'):
|
||||
try:
|
||||
vals = {}
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
k, v = line.split(':')
|
||||
vals[k] = int(v.strip().split()[0])
|
||||
total = vals.get('MemTotal', 0)
|
||||
avail = vals.get('MemAvailable', vals.get('MemFree', 0))
|
||||
return round(100.0 * (total - avail) / total) if total else 0
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
MEM_WARN_PERCENT = 85
|
||||
MEM_WARN_STREAK = 5
|
||||
HEALTH_STA_PARK_INTERVAL = 4 # every Nth health tick (~1/min at 15s poll)
|
||||
|
||||
|
||||
def health_check():
|
||||
"""One health pass. Returns the health dict. Fix actions are
|
||||
rate-limited by HEALTH_FIX_COOLDOWN.
|
||||
@@ -4690,6 +4742,8 @@ def health_check():
|
||||
socket every 15s collides with the stock daemon's own socket writes
|
||||
('[PineAp] Error writing' -> daemon watchdog SIGTERMs pineapd).
|
||||
"""
|
||||
import mk8_events
|
||||
|
||||
h = _health
|
||||
rc, out, err = device_run(['pidof', 'pineapd'], timeout=10)
|
||||
h['pineap_up'] = rc == 0 and bool((out or '').strip())
|
||||
@@ -4708,11 +4762,67 @@ def health_check():
|
||||
h['sigsegv_last'] = _sigsegv_count()
|
||||
h['last_fix'] = now
|
||||
h['fixes'] += 1
|
||||
mk8_events.log_event('restart', msg='pineapd restarted by health monitor')
|
||||
return dict(h)
|
||||
# pineapd is healthy, but wifi reloads still drop the monitors (pineapd
|
||||
# does not bring secondary monitors back). Repair them without cooldown.
|
||||
if _monitor_down('wlan1mon') or _monitor_down('wlan0mon'):
|
||||
_bring_monitors_up(h)
|
||||
# The stock daemon re-enables its dummy STA during its own config
|
||||
# reconvergence (e.g. after wifi reloads), silently pinning phy0 again.
|
||||
# Re-park periodically — parking never runs wifi reload, so it is safe
|
||||
# to repeat.
|
||||
h['ticks'] = h.get('ticks', 0) + 1
|
||||
if h['ticks'] % HEALTH_STA_PARK_INTERVAL == 0:
|
||||
if _sta_uplink_enabled():
|
||||
try:
|
||||
_park_dummy_sta()
|
||||
h['last_action'] = 'dummy STA re-parked'
|
||||
import mk8_events
|
||||
mk8_events.log_event('guard_fix',
|
||||
msg='re-parked dummy_radio0 STA '
|
||||
'(stock reconvergence)')
|
||||
except Exception:
|
||||
pass
|
||||
# Continuous enforcement of the known-good set (boot-guard keys can
|
||||
# be dropped or flipped by stock reconvergence/profile restores).
|
||||
# clear_pool=False mid-run: never wipe collected SSIDs live.
|
||||
try:
|
||||
import mk8_guard
|
||||
rep = mk8_guard.reconcile(clear_pool=False)
|
||||
if rep['changed']:
|
||||
import mk8_events
|
||||
mk8_events.log_event(
|
||||
'guard_fix', sev='warn',
|
||||
msg='reconciler re-applied: %s' % ','.join(rep['changed']))
|
||||
except Exception:
|
||||
pass
|
||||
# Restore the hopping baseline unless a Mark VIII role/attack holds
|
||||
# the pause intentionally. Deliberately bypasses _resume_hop(): its
|
||||
# pager-snapshot may carry a stale hop=0 captured while the old boot
|
||||
# guard forced the value, which would silently keep 5 GHz pinned.
|
||||
# Baseline policy: idle system => wlan1mon hops.
|
||||
if not _HOP_PAUSE_HELD:
|
||||
try:
|
||||
before = _read_hop()
|
||||
if before != '1':
|
||||
import mk8_events
|
||||
device_run(['uci', 'set', 'pineapd.wlan1mon.hop=1'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
device_run(['/etc/init.d/pineapd', 'reload'], timeout=30)
|
||||
mk8_events.log_event(
|
||||
'guard_fix',
|
||||
msg='restored wlan1mon.hop baseline (was %s)' % before)
|
||||
except Exception:
|
||||
pass
|
||||
h['mem_percent'] = _mem_percent()
|
||||
if h['mem_percent'] >= MEM_WARN_PERCENT:
|
||||
h['mem_streak'] = h.get('mem_streak', 0) + 1
|
||||
else:
|
||||
h['mem_streak'] = 0
|
||||
if h['mem_streak'] == MEM_WARN_STREAK:
|
||||
mk8_events.log_event('mem_warn', sev='warn',
|
||||
msg='memory above %d%% sustained' % MEM_WARN_PERCENT)
|
||||
return dict(h)
|
||||
|
||||
|
||||
@@ -4741,6 +4851,9 @@ def _bring_monitors_up(h):
|
||||
|
||||
|
||||
def h_health(ctx):
|
||||
import mk8_events
|
||||
import mk8_guard
|
||||
|
||||
h = dict(_health)
|
||||
h['sigsegv_count'] = h.pop('sigsegv_last')
|
||||
h['pool_disabled'] = _uci_section('pineapd.@ssidpool[0]').get('disable') == '1'
|
||||
@@ -4755,6 +4868,10 @@ def h_health(ctx):
|
||||
for k in ('pass', 'fixed', 'warn', 'fail')},
|
||||
'steps': ENV_CHECK_STATE['report'],
|
||||
}
|
||||
snap = mk8_events.snapshot()
|
||||
h['reliability'] = snap['reliability']
|
||||
h['events'] = snap['events']
|
||||
h['guard'] = mk8_guard.guard_report()
|
||||
return 200, h
|
||||
|
||||
|
||||
@@ -4771,6 +4888,116 @@ def start_health_monitor():
|
||||
threading.Thread(target=_health_loop, daemon=True).start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# RF role manager: radio1 shared between uplink STA and attack work.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def h_rfplan_get(ctx):
|
||||
import mk8_rfplan
|
||||
return 200, {'role': mk8_rfplan.current_role(),
|
||||
'assoc': mk8_rfplan.associated(),
|
||||
'hop_paused': mk8_rfplan.hop_paused()}
|
||||
|
||||
|
||||
def h_rfplan_post(ctx):
|
||||
import mk8_gate
|
||||
import mk8_events
|
||||
import mk8_rfplan
|
||||
body = ctx.body or {}
|
||||
role = (body.get('role') or '').strip().lower()
|
||||
if role not in ('uplink', 'attack', 'idle'):
|
||||
return 400, {'error': 'role must be uplink, attack or idle'}
|
||||
mk8_gate.enter('rfplan_' + role)
|
||||
try:
|
||||
result = mk8_rfplan.set_role(
|
||||
role,
|
||||
ssid=(body.get('ssid') or '').strip() or None,
|
||||
psk=(body.get('psk') or '').strip() or None)
|
||||
except Exception as exc:
|
||||
result = {'ok': False, 'error': str(exc)}
|
||||
ok = bool(result.get('ok'))
|
||||
try:
|
||||
mk8_events.log_event('rfplan', sev='info' if ok else 'warn',
|
||||
msg='rfplan role %s %s'
|
||||
% (role, 'applied' if ok else 'failed'),
|
||||
meta=result)
|
||||
except Exception:
|
||||
pass
|
||||
return (200, result) if ok else (502, result)
|
||||
|
||||
|
||||
def h_profiles_get(ctx):
|
||||
import mk8_profiles
|
||||
return 200, {'profiles': mk8_profiles.list_profiles()}
|
||||
|
||||
|
||||
def h_profile_save(ctx):
|
||||
import mk8_events
|
||||
import mk8_profiles
|
||||
body = ctx.body or {}
|
||||
name = (body.get('name') or '').strip()
|
||||
if not name:
|
||||
return 400, {'error': 'profile name is required'}
|
||||
try:
|
||||
ok = bool(mk8_profiles.snapshot(name))
|
||||
except ValueError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError:
|
||||
ok = False
|
||||
try:
|
||||
mk8_events.log_event('profile_save', sev='info' if ok else 'warn',
|
||||
msg='profile %s %s'
|
||||
% (name, 'saved' if ok else 'save failed'),
|
||||
meta={'name': name, 'ok': ok})
|
||||
except Exception:
|
||||
pass
|
||||
return (200, {'ok': True}) if ok else (502, {'ok': False})
|
||||
|
||||
|
||||
def _post_restore_converge():
|
||||
"""After any profile restore: re-apply invariants the restored config may
|
||||
not carry. Factory-default configs enable dummy_radio0 (the phy0
|
||||
channel-pinner) and may drop monitors; park and re-raise best-effort.
|
||||
Uses ungated primitives — the restore is already inside its own gate."""
|
||||
try:
|
||||
_park_dummy_sta()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_raise_monitors()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def h_profile_restore(ctx):
|
||||
import mk8_events
|
||||
import mk8_gate
|
||||
import mk8_profiles
|
||||
body = ctx.body or {}
|
||||
name = (body.get('name') or '').strip()
|
||||
if not name:
|
||||
return 400, {'error': 'profile name is required'}
|
||||
try:
|
||||
mk8_profiles._path(name)
|
||||
except ValueError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
mk8_gate.enter('restore_profile')
|
||||
result = mk8_profiles.restore(name)
|
||||
ok = bool(result.get('ok'))
|
||||
if ok:
|
||||
device_run(['wifi', 'reload'], timeout=45)
|
||||
_post_restore_converge()
|
||||
try:
|
||||
mk8_events.log_event('profile_restore',
|
||||
sev='info' if ok else 'warn',
|
||||
msg='profile %s %s' % (name, 'restored' if ok
|
||||
else 'restore failed'),
|
||||
meta=result)
|
||||
except Exception:
|
||||
pass
|
||||
return (200, result) if ok else (502, result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Startup environment check: run once at service startup (and via
|
||||
# ``server.py --env-check`` on the payload screen) to make the device match
|
||||
@@ -4850,15 +5077,24 @@ def _sta_uplink_enabled():
|
||||
return cfg.get('mode') == 'sta' and cfg.get('disabled') != '1'
|
||||
|
||||
|
||||
def _park_dummy_sta():
|
||||
"""Ungated primitive: disable dummy_radio0 + take wlan0 down. Callers
|
||||
that already run inside a gate (post-restore convergence) use this to
|
||||
avoid spawning a nested watchdog."""
|
||||
device_run(['uci', 'set', 'wireless.dummy_radio0.disabled=1'])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
for iface in ('wlan0',):
|
||||
rc, out, err = device_run(['ip', 'link', 'set', iface, 'down'], timeout=10)
|
||||
|
||||
|
||||
def _disable_sta_uplink():
|
||||
"""Disable the dummy_radio0 STA without bouncing the radios. The UCI flag
|
||||
keeps it off across reboots/wifi reloads; taking wlan0 down immediately
|
||||
frees phy0's channel for wlan0mon. Never runs `wifi reload` here — that
|
||||
tears down live APs and drops the monitors mid-assessment."""
|
||||
device_run(['uci', 'set', 'wireless.dummy_radio0.disabled=1'])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
for iface in ('wlan0',):
|
||||
rc, out, err = device_run(['ip', 'link', 'set', iface, 'down'], timeout=10)
|
||||
import mk8_gate
|
||||
mk8_gate.enter('uplink_disable')
|
||||
_park_dummy_sta()
|
||||
|
||||
|
||||
def env_check():
|
||||
@@ -4939,6 +5175,32 @@ def env_check():
|
||||
return report
|
||||
|
||||
|
||||
BOOT_MARKER = '/mmc/mk8/boot.marker'
|
||||
|
||||
|
||||
def check_boot_marker():
|
||||
import os, mk8_events
|
||||
try:
|
||||
unexpected = os.path.exists(BOOT_MARKER)
|
||||
mk8_events.mark_boot(unexpected=unexpected)
|
||||
with open(BOOT_MARKER, 'w') as f:
|
||||
f.write(str(int(time.time())))
|
||||
return unexpected
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _clear_boot_marker():
|
||||
"""Best-effort: a clean shutdown must not read as an unexpected reboot."""
|
||||
marker = globals().get('BOOT_MARKER')
|
||||
if not marker:
|
||||
return
|
||||
try:
|
||||
os.unlink(marker)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def startup_env_check(attempts=STARTUP_CHECK_ATTEMPTS,
|
||||
delay=STARTUP_CHECK_DELAY):
|
||||
"""Run and print the startup contract, allowing boot dependencies time.
|
||||
@@ -4959,6 +5221,10 @@ def startup_env_check(attempts=STARTUP_CHECK_ATTEMPTS,
|
||||
if ENV_CHECK_STATE.get('overall') != 'fail':
|
||||
print('ENVIRONMENT CHECK: %s' %
|
||||
ENV_CHECK_STATE['overall'].upper(), flush=True)
|
||||
try:
|
||||
check_boot_marker()
|
||||
except Exception:
|
||||
pass
|
||||
return last_report
|
||||
if attempt < attempts:
|
||||
print('ENVIRONMENT CHECK: FAIL; retry %d/%d in %ds' %
|
||||
@@ -6972,6 +7238,10 @@ def _request_shutdown(signum=None, frame=None):
|
||||
LIVE_STOP.set()
|
||||
HEALTH_STOP.set()
|
||||
_recon_hopper_stop.set()
|
||||
try:
|
||||
_clear_boot_marker()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _uci_iface_present(name):
|
||||
@@ -7132,9 +7402,16 @@ def h_mode_release(ctx):
|
||||
|
||||
ROUTER.add('GET', r'/api/mode', h_mode_get)
|
||||
ROUTER.add('POST', r'/api/mode/release', h_mode_release)
|
||||
ROUTER.add('GET', r'/api/rfplan', h_rfplan_get)
|
||||
ROUTER.add('POST', r'/api/rfplan/role', h_rfplan_post)
|
||||
ROUTER.add('GET', r'/api/reliability/profiles', h_profiles_get)
|
||||
ROUTER.add('POST', r'/api/reliability/profile', h_profile_save)
|
||||
ROUTER.add('POST', r'/api/reliability/restore', h_profile_restore)
|
||||
|
||||
|
||||
def serve():
|
||||
import mk8_gate
|
||||
mk8_gate.ENABLED = True
|
||||
LIVE_STOP.clear()
|
||||
HEALTH_STOP.clear()
|
||||
_recon_hopper_stop.set()
|
||||
@@ -7194,6 +7471,34 @@ if __name__ == '__main__':
|
||||
result = restore_pager_truth(resnapshot=False)
|
||||
print(json.dumps(result))
|
||||
sys.exit(0 if result.get('ok') else 1)
|
||||
if '--reconcile' in sys.argv:
|
||||
try:
|
||||
import mk8_guard
|
||||
print(json.dumps(mk8_guard.reconcile()))
|
||||
except Exception as exc: # boot must never fail here
|
||||
print(json.dumps({'error': str(exc)}))
|
||||
sys.exit(0)
|
||||
if '--rollback-snapshot' in sys.argv:
|
||||
import mk8_gate
|
||||
mk8_gate.ENABLED = True
|
||||
name = sys.argv[sys.argv.index('--rollback-snapshot') + 1]
|
||||
import mk8_profiles
|
||||
result = mk8_profiles.restore(name)
|
||||
device_run(['wifi', 'reload'], timeout=90)
|
||||
_post_restore_converge()
|
||||
import mk8_events
|
||||
mk8_events.log_event('rollback', sev='warn',
|
||||
msg='watchdog restored %s' % name,
|
||||
meta=result)
|
||||
print(json.dumps(result))
|
||||
sys.exit(0)
|
||||
if '--promote-snapshot' in sys.argv:
|
||||
import mk8_gate
|
||||
mk8_gate.ENABLED = True
|
||||
name = sys.argv[sys.argv.index('--promote-snapshot') + 1]
|
||||
import mk8_profiles
|
||||
print(json.dumps({'promoted': mk8_profiles.promote_lastknown_good()}))
|
||||
sys.exit(0)
|
||||
signal.signal(signal.SIGTERM, _request_shutdown)
|
||||
signal.signal(signal.SIGINT, _request_shutdown)
|
||||
serve()
|
||||
|
||||
@@ -494,7 +494,7 @@ html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
|
||||
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
|
||||
html.dark .modal { background: #303030; }
|
||||
|
||||
/* ---- PineAP overview segmented control ---- */
|
||||
/* ---- PineAP segmented control ---- */
|
||||
.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; overflow: hidden; }
|
||||
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
|
||||
.seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
|
||||
@@ -510,11 +510,6 @@ html.dark .modal { background: #303030; }
|
||||
.pineap-card-title-link:visited { color: inherit; }
|
||||
.pineap-card-title-link:hover { text-decoration: underline; }
|
||||
.pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; }
|
||||
.pineap-card-button-group { width: 100%; height: 30px; display: flex; }
|
||||
.pineap-card-button-group .seg { flex: 1; height: 100%; margin-top: 0; }
|
||||
.pineap-card-button-group .seg-btn { flex: 1; }
|
||||
.pineap-mode-save { display: flex; justify-content: flex-end; margin-top: 10px; }
|
||||
.pineap-mode-features { margin: 6px 0 0; padding-left: 20px; }
|
||||
.pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; }
|
||||
.pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); }
|
||||
|
||||
@@ -622,3 +617,37 @@ html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-col
|
||||
.payload-filters, .payload-dev-grid { grid-template-columns: 1fr; }
|
||||
.payload-actions { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
/* ---- Mark VIII reliability panel ---- */
|
||||
.mk8-rel-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.mk8-rel-head h2 { margin: 0; }
|
||||
.mk8-counter-row {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px; margin-bottom: 14px;
|
||||
}
|
||||
.mk8-counter { background: var(--surface-alt); border-radius: 2px; padding: 8px 12px; }
|
||||
.mk8-counter-value { font-size: 22px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.mk8-counter-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
|
||||
.mk8-events-feed { max-height: 280px; overflow-y: auto; border-top: 1px solid var(--border); }
|
||||
.mk8-event-row {
|
||||
display: flex; align-items: baseline; gap: 10px; padding: 6px 2px;
|
||||
border-bottom: 1px solid var(--border); font-size: 12px;
|
||||
}
|
||||
.mk8-event-time { flex: none; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.mk8-event-kind { flex: none; min-width: 90px; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--primary); }
|
||||
.mk8-event-msg { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.mk8-event-row.sev-warn .mk8-event-msg { color: #b26a00; }
|
||||
.mk8-event-row.sev-error .mk8-event-msg { color: var(--danger); }
|
||||
html.dark .mk8-event-row.sev-warn .mk8-event-msg { color: #ffb74d; }
|
||||
|
||||
/* ---- Mark VIII RF plan chip + role card ---- */
|
||||
#rf-chip { white-space: nowrap; }
|
||||
.rf-role-status { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 0 10px; font-size: 12px; }
|
||||
.rf-role-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 4px 14px; align-items: end;
|
||||
}
|
||||
.mk8-rf-result { font-size: 12px; margin-top: 10px; min-height: 16px; overflow-wrap: anywhere; }
|
||||
.mk8-rf-result.ok { color: #2e7d32; }
|
||||
.mk8-rf-result.error { color: var(--danger); }
|
||||
html.dark .mk8-rf-result.ok { color: #81c784; }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>WiFi Pineapple</title>
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<link rel="stylesheet" href="css/app.css?v=20260820-4">
|
||||
<link rel="stylesheet" href="css/app.css?v=20260822-1">
|
||||
<link rel="stylesheet" href="js/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -27,6 +27,7 @@
|
||||
<span class="toolbar-spacer"></span>
|
||||
<span id="live-status"></span>
|
||||
<span id="health-status" class="health-chip"></span>
|
||||
<span id="rf-chip" class="health-chip"></span>
|
||||
<div class="toolbar-action">
|
||||
<button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications"
|
||||
aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button>
|
||||
@@ -268,7 +269,7 @@
|
||||
<script src="js/xterm-addon-fit.min.js"></script>
|
||||
<script src="js/terminal.js?v=20260820-4"></script>
|
||||
<script src="js/pager.js?v=20260820-4"></script>
|
||||
<script src="js/views.js?v=20260820-4"></script>
|
||||
<script src="js/app.js?v=20260820-4"></script>
|
||||
<script src="js/views.js?v=20260822-3"></script>
|
||||
<script src="js/app.js?v=20260822-3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -448,6 +448,9 @@ const Live = (() => {
|
||||
let poll = null;
|
||||
let pollHealthTimer = null;
|
||||
let pollEventsTimer = null;
|
||||
let pollRfTimer = null;
|
||||
let rfState = null;
|
||||
let rfChannel = null;
|
||||
const lastEvents = { hsSeen: {}, hsPrimed: false, creds: null, credsPrimed: false,
|
||||
pineapUp: null, mon0: null, mon1: null };
|
||||
const subs = [];
|
||||
@@ -466,6 +469,10 @@ const Live = (() => {
|
||||
pollEventsTimer = setInterval(pollEvents, 15000);
|
||||
pollEvents();
|
||||
}
|
||||
if (!pollRfTimer) {
|
||||
pollRfTimer = setInterval(pollRfplan, 15000);
|
||||
pollRfplan();
|
||||
}
|
||||
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
|
||||
catch (e) { fallback(); return; }
|
||||
ws.onopen = () => { ever = true; };
|
||||
@@ -512,6 +519,57 @@ const Live = (() => {
|
||||
const n = (msg.clients || []).length;
|
||||
const el = document.getElementById('live-status');
|
||||
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
|
||||
const wifi = (msg.status || {}).wifi;
|
||||
if (Array.isArray(wifi)) {
|
||||
const up = wifi.find((w) => w && w.iface === 'wlan1up');
|
||||
rfChannel = up && up.channel != null ? Number(up.channel) : null;
|
||||
renderRfChip();
|
||||
}
|
||||
}
|
||||
function pollRfplan() {
|
||||
fetch(App.apiBase + '/api/rfplan', { credentials: 'include' }).then((r) => {
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
return r.json();
|
||||
}).then((d) => {
|
||||
rfState = d && typeof d.role === 'string' ? d : null;
|
||||
renderRfChip();
|
||||
}).catch(() => {
|
||||
rfState = null;
|
||||
const el = document.getElementById('rf-chip');
|
||||
if (el) {
|
||||
el.textContent = 'PHY1: ?';
|
||||
el.title = 'RF plan unavailable';
|
||||
el.className = 'health-chip warn';
|
||||
}
|
||||
});
|
||||
}
|
||||
function renderRfChip() {
|
||||
const el = document.getElementById('rf-chip');
|
||||
if (!el) return;
|
||||
if (!rfState) {
|
||||
el.textContent = '';
|
||||
el.title = '';
|
||||
el.className = 'health-chip';
|
||||
return;
|
||||
}
|
||||
let text;
|
||||
let cls = '';
|
||||
if (rfState.role === 'uplink') {
|
||||
text = 'PHY1: UPLINK' + (rfState.assoc && rfChannel ? ' ch' + rfChannel : '');
|
||||
cls = rfState.assoc ? 'good' : 'warn';
|
||||
} else if (rfState.role === 'attack') {
|
||||
text = 'PHY1: ATTACK';
|
||||
cls = 'warn';
|
||||
} else if (rfState.role === 'idle') {
|
||||
text = 'PHY1: IDLE';
|
||||
} else {
|
||||
text = 'PHY1: ' + String(rfState.role).toUpperCase();
|
||||
}
|
||||
el.textContent = text;
|
||||
el.className = 'health-chip' + (cls ? ' ' + cls : '');
|
||||
el.title = 'radio1 role: ' + rfState.role +
|
||||
' \u00b7 assoc ' + (rfState.assoc || 'none') +
|
||||
' \u00b7 hop ' + (rfState.hop_paused == null ? 'unknown' : rfState.hop_paused ? 'paused' : 'running');
|
||||
}
|
||||
function pollHealth() {
|
||||
fetch(App.apiBase + '/api/health', { credentials: 'include' }).then((r) => r.json())
|
||||
|
||||
@@ -214,6 +214,40 @@ views.dashboard = (root) => {
|
||||
live.appendChild(card);
|
||||
liveCards[k] = card.querySelector('.card-value');
|
||||
});
|
||||
|
||||
const counterDefs = [
|
||||
['boots', 'Boots'], ['unexpected_boots', 'Unexpected Boots'],
|
||||
['rollbacks', 'Rollbacks'], ['restarts', 'Restarts'], ['guard_fixes', 'Guard Fixes']
|
||||
];
|
||||
const counterVals = {};
|
||||
const counterRow = h('div', { class: 'mk8-counter-row' });
|
||||
counterDefs.forEach(([k, label]) => {
|
||||
const val = h('div', { class: 'mk8-counter-value', text: '—' });
|
||||
counterRow.appendChild(h('div', { class: 'mk8-counter' },
|
||||
val, h('div', { class: 'mk8-counter-label', text: label })));
|
||||
counterVals[k] = val;
|
||||
});
|
||||
const guardChip = h('span', { class: 'health-chip', text: 'GUARD —' });
|
||||
const feedBody = h('div', { class: 'mk8-events-feed' },
|
||||
h('div', { class: 'empty', text: 'No events recorded.' }));
|
||||
root.appendChild(h('div', { class: 'section' },
|
||||
h('div', { class: 'mk8-rel-head' }, h('h2', {}, 'Reliability'), guardChip),
|
||||
counterRow,
|
||||
feedBody));
|
||||
function renderEvents(events) {
|
||||
feedBody.innerHTML = '';
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
feedBody.appendChild(h('div', { class: 'empty', text: 'No events recorded.' }));
|
||||
return;
|
||||
}
|
||||
events.forEach((ev) => {
|
||||
const sev = ev.sev === 'error' ? 'error' : ev.sev === 'warn' ? 'warn' : 'info';
|
||||
feedBody.appendChild(h('div', { class: 'mk8-event-row sev-' + sev },
|
||||
h('span', { class: 'mk8-event-time', text: fmtShortTime(ev.ts) }),
|
||||
h('span', { class: 'mk8-event-kind', text: String(ev.kind || '?') }),
|
||||
h('span', { class: 'mk8-event-msg', text: String(ev.msg || '') })));
|
||||
});
|
||||
}
|
||||
function loadLive() {
|
||||
PagerAPI.get('/api/attacks/status').then((r) => {
|
||||
const s = r.data || {};
|
||||
@@ -242,6 +276,22 @@ views.dashboard = (root) => {
|
||||
(h2.pool_disabled ? ' · pool off' : '') +
|
||||
((h2.env || {}).overall ? ' · env ' + h2.env.overall : '');
|
||||
liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c';
|
||||
const rel = h2.reliability || {};
|
||||
Object.keys(counterVals).forEach((k) => {
|
||||
counterVals[k].textContent = rel[k] == null ? '—' : String(rel[k]);
|
||||
});
|
||||
const g = h2.guard || {};
|
||||
if (typeof g.in_sync !== 'boolean') {
|
||||
guardChip.className = 'health-chip';
|
||||
guardChip.textContent = 'GUARD —';
|
||||
} else if (g.in_sync) {
|
||||
guardChip.className = 'health-chip good';
|
||||
guardChip.textContent = 'GUARD IN SYNC';
|
||||
} else {
|
||||
guardChip.className = 'health-chip warn';
|
||||
guardChip.textContent = 'GUARD PENDING' + (g.pending && g.pending.length ? ' · ' + g.pending.length : '');
|
||||
}
|
||||
renderEvents(h2.events);
|
||||
}).catch(() => {});
|
||||
PagerAPI.get('/api/recon/status').then((r) => {
|
||||
const s = r.data || {};
|
||||
@@ -339,7 +389,7 @@ const PINEAP_TABS = [
|
||||
|
||||
// The Pager daemon can change these states but cannot read them back. Keep
|
||||
// them explicitly unknown until this WebUI successfully changes them.
|
||||
const PINEAP_SESSION = { mode: null, karma: null, advertise: null, collect: null };
|
||||
const PINEAP_SESSION = { karma: null, advertise: null, collect: null };
|
||||
|
||||
function setKnownCheckbox(cb, value) {
|
||||
cb.indeterminate = value == null;
|
||||
@@ -380,27 +430,6 @@ views.pineap = (root) => {
|
||||
});
|
||||
box.appendChild(statWrap);
|
||||
|
||||
const mode = h('span', { class: 'badge', text: '—' });
|
||||
let selectedMode = 'unknown';
|
||||
let modeDirty = false;
|
||||
let modePending = false;
|
||||
const segBtns = {};
|
||||
const modeBar = h('div', { class: 'seg' });
|
||||
['passive', 'active', 'advanced'].forEach((m) => {
|
||||
const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) });
|
||||
b.addEventListener('click', () => selectMode(m, true));
|
||||
modeBar.appendChild(b);
|
||||
segBtns[m] = b;
|
||||
});
|
||||
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
|
||||
const saveModeBtn = btn('Save Mode', saveMode, 'ghost');
|
||||
saveModeBtn.disabled = true;
|
||||
const modeCard = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title-flex' }, mode),
|
||||
h('div', { class: 'pineap-card-button-group' }, modeBar),
|
||||
modeInfo,
|
||||
h('div', { class: 'pineap-mode-save' }, saveModeBtn));
|
||||
|
||||
const quick = {
|
||||
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
||||
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
||||
@@ -415,8 +444,62 @@ views.pineap = (root) => {
|
||||
quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' },
|
||||
'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
|
||||
|
||||
const rfSel = h('select', {},
|
||||
h('option', { value: 'uplink', text: 'Uplink (station)' }),
|
||||
h('option', { value: 'attack', text: 'Attack' }),
|
||||
h('option', { value: 'idle', text: 'Idle' }));
|
||||
const rfSsid = h('input', { placeholder: 'Uplink network SSID', autocomplete: 'off' });
|
||||
const rfPsk = h('input', { type: 'password', placeholder: 'Leave blank for an open network',
|
||||
autocomplete: 'new-password' });
|
||||
const rfStatusBadge = h('span', { class: 'badge unknown', text: '—' });
|
||||
const rfStatusInfo = h('span', { class: 'muted', text: '' });
|
||||
const rfResult = h('div', { class: 'mk8-rf-result', text: '' });
|
||||
function renderRfStatus(d) {
|
||||
const role = d.role || 'idle';
|
||||
if (document.activeElement !== rfSel) rfSel.value = role;
|
||||
rfStatusBadge.textContent = role.toUpperCase();
|
||||
rfStatusBadge.className = 'badge ' +
|
||||
(role === 'uplink' ? (d.assoc ? 'on' : 'warn') : role === 'attack' ? 'warn' : 'off');
|
||||
const parts = [];
|
||||
if (role === 'uplink') parts.push(d.assoc ? 'associated to ' + d.assoc : 'not associated');
|
||||
if (d.hop_paused != null) parts.push('hop ' + (d.hop_paused ? 'paused' : 'running'));
|
||||
rfStatusInfo.textContent = parts.join(' · ');
|
||||
}
|
||||
const rfApply = btn('Apply Role', () => {
|
||||
const role = rfSel.value;
|
||||
if (role === 'uplink' && !rfSsid.value.trim()) {
|
||||
rfResult.textContent = 'SSID is required for the uplink role.';
|
||||
rfResult.className = 'mk8-rf-result error';
|
||||
return;
|
||||
}
|
||||
return PagerAPI.post('/api/rfplan/role', { role, ssid: rfSsid.value.trim(), psk: rfPsk.value })
|
||||
.then((r) => {
|
||||
const d = r.data || {};
|
||||
rfResult.textContent = 'Role applied: ' + (d.role || role) +
|
||||
(d.assoc ? ' — associated to ' + d.assoc : '');
|
||||
rfResult.className = 'mk8-rf-result ok';
|
||||
rfPsk.value = '';
|
||||
return PagerAPI.get('/api/rfplan').then((s) => renderRfStatus(s.data || {}));
|
||||
})
|
||||
.catch((e) => {
|
||||
rfResult.textContent = (e && e.message) || 'Role change failed.';
|
||||
rfResult.className = 'mk8-rf-result error';
|
||||
});
|
||||
});
|
||||
const rfCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
|
||||
h('div', { class: 'pineap-card-title' }, 'RF Role (radio1)'),
|
||||
h('p', { class: 'pineap-card-subtitle',
|
||||
text: 'Radio1 is shared between an uplink client and attack work; applying a role makes them mutually exclusive.' }));
|
||||
rfCard.appendChild(h('div', { class: 'rf-role-status' }, rfStatusBadge, rfStatusInfo));
|
||||
rfCard.appendChild(h('label', {}, 'Role', rfSel));
|
||||
rfCard.appendChild(h('div', { class: 'rf-role-grid' },
|
||||
h('label', {}, 'Uplink SSID', rfSsid),
|
||||
h('label', {}, 'Uplink Password', rfPsk)));
|
||||
rfCard.appendChild(rfApply);
|
||||
rfCard.appendChild(rfResult);
|
||||
|
||||
const modeRow = h('div', { class: 'pineap-title-card-container' });
|
||||
modeRow.appendChild(modeCard);
|
||||
modeRow.appendChild(rfCard);
|
||||
modeRow.appendChild(quickCard);
|
||||
box.appendChild(modeRow);
|
||||
|
||||
@@ -452,9 +535,6 @@ views.pineap = (root) => {
|
||||
}
|
||||
function rememberAdvanced(key, value) {
|
||||
PINEAP_SESSION[key] = value;
|
||||
PINEAP_SESSION.mode = 'advanced';
|
||||
modeDirty = false;
|
||||
selectMode('advanced', false);
|
||||
}
|
||||
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }),
|
||||
(v) => rememberAdvanced('collect', v));
|
||||
@@ -462,80 +542,16 @@ views.pineap = (root) => {
|
||||
(v) => rememberAdvanced('advertise', v));
|
||||
setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise);
|
||||
|
||||
function renderModeInfo(m) {
|
||||
modeInfo.innerHTML = '';
|
||||
if (m === 'unknown') {
|
||||
modeInfo.textContent = 'Select a mode to establish the Pager\'s PineAP preset.';
|
||||
return;
|
||||
}
|
||||
const descriptions = {
|
||||
passive: ['Capture SSIDs to the impersonation pool', 'Do not broadcast the pool', 'Keep the PineAP response engine disabled'],
|
||||
active: ['Capture SSIDs to the impersonation pool', 'Enable the PineAP response engine', 'Pool broadcast stays disabled (firmware crash fix)']
|
||||
};
|
||||
if (m === 'advanced') {
|
||||
modeInfo.textContent = 'All supported PineAP features are individually customizable from Quick Settings and the PineAP tabs.';
|
||||
return;
|
||||
}
|
||||
modeInfo.appendChild(h('div', { text: 'In ' + m[0].toUpperCase() + m.slice(1) + ' Mode:' }));
|
||||
const list = h('ul', { class: 'pineap-mode-features' });
|
||||
descriptions[m].forEach((text) => list.appendChild(h('li', { text })));
|
||||
modeInfo.appendChild(list);
|
||||
}
|
||||
|
||||
function selectMode(m, dirty) {
|
||||
selectedMode = m;
|
||||
if (dirty) modeDirty = true;
|
||||
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
|
||||
mode.textContent = m === 'unknown' ? 'Unknown' : m[0].toUpperCase() + m.slice(1);
|
||||
mode.className = 'badge ' + (m === 'unknown' ? 'unknown' : 'on');
|
||||
renderModeInfo(m);
|
||||
saveModeBtn.disabled = !modeDirty || modePending || m === 'unknown';
|
||||
}
|
||||
|
||||
function saveMode() {
|
||||
if (!modeDirty || modePending || selectedMode === 'unknown') return;
|
||||
modePending = true;
|
||||
saveModeBtn.disabled = true;
|
||||
saveModeBtn.classList.add('busy');
|
||||
saveModeBtn.setAttribute('aria-busy', 'true');
|
||||
PagerAPI.post('/api/pineap/mode', { mode: selectedMode }).then((r) => {
|
||||
const state = r.data || {};
|
||||
PINEAP_SESSION.mode = state.mode || selectedMode;
|
||||
['karma', 'advertise', 'collect'].forEach((key) => {
|
||||
if (typeof state[key] === 'boolean') PINEAP_SESSION[key] = state[key];
|
||||
});
|
||||
modeDirty = false;
|
||||
selectMode(PINEAP_SESSION.mode, false);
|
||||
App.toast('Mode: ' + PINEAP_SESSION.mode[0].toUpperCase() + PINEAP_SESSION.mode.slice(1));
|
||||
load();
|
||||
}).catch(() => { App.toast('Failed to save PineAP mode', 'error'); })
|
||||
.finally(() => {
|
||||
modePending = false;
|
||||
saveModeBtn.classList.remove('busy');
|
||||
saveModeBtn.removeAttribute('aria-busy');
|
||||
saveModeBtn.disabled = !modeDirty;
|
||||
});
|
||||
}
|
||||
|
||||
let loadPending = false;
|
||||
function load() {
|
||||
if (loadPending) return;
|
||||
loadPending = true;
|
||||
const stateRequest = Promise.all([
|
||||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/mode').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, host, ap, preset]) => {
|
||||
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}, p = preset.data || {};
|
||||
const disabled = Object.prototype.hasOwnProperty.call(hh, 'pineap_disabled') ? !!hh.pineap_disabled : null;
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, ap]) => {
|
||||
const c = cfg.data || {}, a = ap.data || {};
|
||||
const wpa = a.wpa || {};
|
||||
PINEAP_SESSION.mode = ['passive', 'active', 'advanced'].indexOf(p.mode) !== -1 ? p.mode : 'unknown';
|
||||
['karma', 'advertise', 'collect'].forEach((key) => {
|
||||
if (typeof p[key] === 'boolean') PINEAP_SESSION[key] = p[key];
|
||||
});
|
||||
if (!modeDirty) selectMode(PINEAP_SESSION.mode, false);
|
||||
mode.className = 'badge ' + (disabled === true ? 'off' : disabled === false ? 'on' : 'unknown');
|
||||
const collect = typeof PINEAP_SESSION.collect === 'boolean'
|
||||
? PINEAP_SESSION.collect
|
||||
: Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null;
|
||||
@@ -562,7 +578,14 @@ views.pineap = (root) => {
|
||||
}).catch(() => { stats.clients.textContent = '0'; }),
|
||||
PagerAPI.get('/api/pineap/handshakes').then((hs) => {
|
||||
stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable';
|
||||
}).catch(() => { stats.handshakes.textContent = 'Unavailable'; })
|
||||
}).catch(() => { stats.handshakes.textContent = 'Unavailable'; }),
|
||||
PagerAPI.get('/api/rfplan').then((rf) => {
|
||||
renderRfStatus(rf.data || {});
|
||||
}).catch(() => {
|
||||
rfStatusBadge.textContent = '—';
|
||||
rfStatusBadge.className = 'badge unknown';
|
||||
rfStatusInfo.textContent = 'RF plan unavailable';
|
||||
})
|
||||
];
|
||||
Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; });
|
||||
}
|
||||
@@ -3807,6 +3830,43 @@ views.settings = (root) => {
|
||||
overlay.appendChild(restoreBtn);
|
||||
loadOverlay();
|
||||
|
||||
const profiles = settingsCard(box, 'Reliability Profiles',
|
||||
'Snapshots of the PineAP, wireless, and network UCI config. Save one before risky changes; restoring rolls the config back and reloads the radios.');
|
||||
const profBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
|
||||
profiles.appendChild(profBody);
|
||||
function loadProfiles() {
|
||||
PagerAPI.get('/api/reliability/profiles').then((r) => {
|
||||
const names = (r.data || {}).profiles || [];
|
||||
profBody.innerHTML = '';
|
||||
if (!names.length) {
|
||||
profBody.appendChild(h('div', { class: 'empty', text: 'No saved profiles yet.' }));
|
||||
return;
|
||||
}
|
||||
profBody.appendChild(table([
|
||||
{ label: 'Profile', key: 'name' },
|
||||
{ label: '', render: (row) => {
|
||||
const doRestore = btn('Restore', () => runAction(doRestore,
|
||||
() => PagerAPI.post('/api/reliability/restore', { name: row.name })
|
||||
.then(() => App.toast('Profile restored; radios reloaded')), 'Restoring…'), 'ghost');
|
||||
return doRestore;
|
||||
} }
|
||||
], names.map((name) => ({ name }))));
|
||||
}).catch(() => { profBody.textContent = 'Unable to load profiles.'; });
|
||||
}
|
||||
const profName = h('input', { placeholder: 'e.g. pre-evilwpa', maxlength: '64',
|
||||
autocomplete: 'off' });
|
||||
const profSave = btn('Save Profile', () => {
|
||||
const name = profName.value.trim();
|
||||
if (!name) { App.toast('Profile name is required', 'error'); return; }
|
||||
return runAction(profSave, () => PagerAPI.post('/api/reliability/profile', { name })
|
||||
.then(() => { profName.value = ''; App.toast('Config profile saved'); loadProfiles(); }), 'Saving…');
|
||||
});
|
||||
profiles.appendChild(h('div', { class: 'settings-form-grid' },
|
||||
h('label', {}, 'Profile Name', profName)));
|
||||
profiles.appendChild(h('div', { class: 'settings-actions' }, profSave,
|
||||
btn('Refresh', () => { loadProfiles(); }, 'ghost')));
|
||||
loadProfiles();
|
||||
|
||||
settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.');
|
||||
|
||||
const resources = settingsCard(box, 'Resources');
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build helpers shared by Mark VIII deploy scripts.
|
||||
|
||||
stamp_version() stamps release metadata into BUILD COPIES ONLY: callers
|
||||
always pass a staging/build directory, never the source tree, so the repo
|
||||
stays clean while every deployed artifact reports the same VERSION.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
VERSION_RE = re.compile(r'^[0-9][A-Za-z0-9._-]{0,31}$')
|
||||
SERVER_VERSION_LINE = "SERVER_VERSION = '%s'\n"
|
||||
|
||||
|
||||
def _stamp_manifest(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
data['version'] = version
|
||||
with open(path, 'w', encoding='ascii') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write('\n')
|
||||
return True
|
||||
|
||||
|
||||
def _stamp_payload_sh(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
return False
|
||||
out = []
|
||||
replaced = False
|
||||
for line in lines:
|
||||
m = None if replaced \
|
||||
else re.match(r'^(\s*#\s*[Vv]ersion:).*$', line)
|
||||
if m:
|
||||
out.append(m.group(1) + ' ' + version + '\n')
|
||||
replaced = True
|
||||
else:
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
insert = 1 if lines and lines[0].startswith('#!') else 0
|
||||
out.insert(insert, '# Version: %s\n' % version)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(out)
|
||||
return True
|
||||
|
||||
|
||||
def _server_insert_index(lines):
|
||||
"""Index just past any shebang/comments/blanks and module docstring."""
|
||||
i = 0
|
||||
n = len(lines)
|
||||
if i < n and lines[i].startswith('#!'):
|
||||
i += 1
|
||||
while i < n and (lines[i].strip().startswith('#')
|
||||
or not lines[i].strip()):
|
||||
i += 1
|
||||
if i < n:
|
||||
stripped = lines[i].lstrip()
|
||||
quote = stripped[:3]
|
||||
if quote in ('"""', "'''"):
|
||||
closed_here = quote in stripped[3:]
|
||||
i += 1
|
||||
if not closed_here:
|
||||
while i < n and quote not in lines[i]:
|
||||
i += 1
|
||||
i += 1
|
||||
return min(i, len(lines))
|
||||
|
||||
|
||||
def _stamp_server_py(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
return False
|
||||
lines = [l for l in lines if not l.startswith('SERVER_VERSION')]
|
||||
idx = _server_insert_index(lines)
|
||||
lines.insert(idx, SERVER_VERSION_LINE % version)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
return True
|
||||
|
||||
|
||||
def stamp_version(build_dir, version):
|
||||
"""Stamp <version> into build copies found under build_dir.
|
||||
|
||||
Updates every ``_hak5_manifest.json`` (version field), ``payload.sh``
|
||||
(header Version line) and ``server.py`` (injected SERVER_VERSION
|
||||
constant near the top). Idempotent: re-running never duplicates the
|
||||
injected constant or header. Returns the list of stamped paths."""
|
||||
if not VERSION_RE.match(str(version)):
|
||||
raise ValueError('invalid version string: %r' % (version,))
|
||||
stamped = []
|
||||
for root, dirs, files in os.walk(build_dir):
|
||||
for fname in ('_hak5_manifest.json', 'payload.sh', 'server.py'):
|
||||
if fname in files:
|
||||
path = os.path.join(root, fname)
|
||||
if fname == '_hak5_manifest.json':
|
||||
ok = _stamp_manifest(path, version)
|
||||
elif fname == 'payload.sh':
|
||||
ok = _stamp_payload_sh(path, version)
|
||||
else:
|
||||
ok = _stamp_server_py(path, version)
|
||||
if ok:
|
||||
stamped.append(path)
|
||||
return sorted(stamped)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
print('usage: build_common.py <build_dir> <version>',
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
for path in stamp_version(sys.argv[1], sys.argv[2]):
|
||||
print('stamped: %s' % path)
|
||||
+112
-33
@@ -21,6 +21,11 @@ Options:
|
||||
--no-portal-refresh Skip the best-effort portal refresh
|
||||
-h, --help Show this help
|
||||
|
||||
Deploys to /mmc/mk8/releases/<ts>/ with an atomically repointed 'current'
|
||||
symlink, mirrors the payload into the legacy /root/payloads location the
|
||||
init scripts run from, verifies sha256 of the upload, and polls the local
|
||||
API after start; on failure it rolls the symlink + legacy dir back.
|
||||
|
||||
If neither --password nor --ssh-key is supplied, ssh/scp prompt normally.
|
||||
EOF
|
||||
}
|
||||
@@ -61,6 +66,17 @@ STAGE="$OUT_DIR/stage"
|
||||
printf 'Payload directory not found: %s\n' "$PAYLOAD_DIR" >&2
|
||||
exit 1
|
||||
}
|
||||
VERSION_FILE="$ROOT/VERSION"
|
||||
[[ -f "$VERSION_FILE" ]] || {
|
||||
printf 'VERSION file not found: %s\n' "$VERSION_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")"
|
||||
[[ -n "$VERSION" ]] || {
|
||||
printf 'VERSION file is empty.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'Deploying Mark VIII version %s\n' "$VERSION"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
rm -rf "$STAGE"
|
||||
@@ -68,6 +84,10 @@ mkdir -p "$STAGE/user/$PAYLOAD_CATEGORY"
|
||||
cp -R "$PAYLOAD_DIR" "$STAGE/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
|
||||
find "$STAGE" \( -type d -name __pycache__ -o -type f -name '*.pyc' \) -prune -exec rm -rf {} +
|
||||
|
||||
# Stamp build copies only (never the source tree): payload.sh header,
|
||||
# staged server.py SERVER_VERSION constant; manifest is stamped below.
|
||||
python3 "$ROOT/scripts/build_common.py" "$STAGE" "$VERSION"
|
||||
|
||||
B64_KEY="$(python3 -c 'import base64; print(base64.urlsafe_b64encode(b"pager-webui").decode().rstrip("="))')"
|
||||
ZIP_NAME="payload-$B64_KEY.zip"
|
||||
ZIP_PATH="$OUT_DIR/$ZIP_NAME"
|
||||
@@ -94,7 +114,9 @@ with open(destination, 'w', encoding='ascii') as handle:
|
||||
json.dump(manifest, handle, indent=2)
|
||||
handle.write('\n')
|
||||
PY
|
||||
printf 'Built: %s\n' "$ZIP_PATH"
|
||||
# Single-source version: stamp the generated manifest copy too.
|
||||
python3 "$ROOT/scripts/build_common.py" "$MANIFEST_PATH" "$VERSION" >/dev/null
|
||||
printf 'Built: %s (sha256 %s)\n' "$ZIP_PATH" "$HASH"
|
||||
|
||||
TARGET="$PAGER_USER@$PAGER_HOST"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new)
|
||||
@@ -166,46 +188,103 @@ echo PYTHON_OK'
|
||||
|
||||
install_python3
|
||||
|
||||
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/"
|
||||
run_ssh "$TARGET" 'mkdir -p /tmp/mk8-stage && rm -rf /tmp/mk8-stage/*'
|
||||
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/mk8-stage/"
|
||||
|
||||
REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
|
||||
LEGACY_PAYLOAD_DIR="user/general/$PAYLOAD_KEY"
|
||||
RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
|
||||
REMOTE_COMMAND="set -e
|
||||
cd /root/payloads
|
||||
stage='.pager-webui.deploy.\$\$'
|
||||
backup='.pager-webui.backup.\$\$'
|
||||
trap 'rm -rf \"\$stage\" \"\$backup\"' EXIT
|
||||
mkdir -p \"\$stage\"
|
||||
cd \"\$stage\"
|
||||
unzip -q '/tmp/$ZIP_NAME'
|
||||
new=\"\$PWD/$REMOTE_PAYLOAD_DIR\"
|
||||
[ -f \"\$new/server.py\" ] && [ -f \"\$new/payload.sh\" ] && [ -d \"\$new/www\" ]
|
||||
cp /tmp/_hak5_manifest.json \"\$new/_hak5_manifest.json\"
|
||||
chmod +x \"\$new/payload.sh\" \"\$new/pagerwebui.init\"
|
||||
chmod -R 755 \"\$new/www\"
|
||||
cd /root/payloads
|
||||
if [ -d '$REMOTE_PAYLOAD_DIR' ]; then
|
||||
mkdir -p \"\$(dirname \"\$backup\")\"
|
||||
mv '$REMOTE_PAYLOAD_DIR' \"\$backup\"
|
||||
fi
|
||||
if mv \"\$new\" '$REMOTE_PAYLOAD_DIR'; then
|
||||
rm -rf \"\$backup\" '$LEGACY_PAYLOAD_DIR'
|
||||
else
|
||||
[ ! -d \"\$backup\" ] || mv \"\$backup\" '$REMOTE_PAYLOAD_DIR'
|
||||
STAGE_DIR='/tmp/mk8-stage'
|
||||
ZIP="\$STAGE_DIR/$ZIP_NAME"
|
||||
RELDIR='/mmc/mk8/releases/$RELEASE_TS'
|
||||
PAYDIR='$REMOTE_PAYLOAD_DIR'
|
||||
LIVE=\"/root/payloads/\$PAYDIR\"
|
||||
BACKUP=\"/root/payloads/.pager-webui.backup.\$\$\"
|
||||
CURRENT='/mmc/mk8/releases/current'
|
||||
PREV=\$(readlink \$CURRENT 2>/dev/null || true)
|
||||
cleanup() { rm -rf \"\$STAGE_DIR\"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Upload integrity gate: remote sha256 must match the local build hash.
|
||||
GOT=\$(sha256sum \"\$ZIP\" | awk '{print \$1}')
|
||||
[ \"\$GOT\" = '$HASH' ] || { echo 'sha256 mismatch on uploaded zip' >&2; exit 1; }
|
||||
[ -f \"\$STAGE_DIR/_hak5_manifest.json\" ] || { echo 'manifest missing' >&2; exit 1; }
|
||||
|
||||
/etc/init.d/pagerwebui stop >/dev/null 2>&1 || true
|
||||
mkdir -p \"\$RELDIR\"
|
||||
unzip -q \"\$ZIP\" -d \"\$RELDIR\"
|
||||
NEW=\"\$RELDIR/\$PAYDIR\"
|
||||
[ -f \"\$NEW/server.py\" ] && [ -f \"\$NEW/payload.sh\" ] && [ -d \"\$NEW/www\" ] || {
|
||||
echo 'release payload incomplete' >&2
|
||||
rm -rf \"\$RELDIR\"
|
||||
exit 1
|
||||
}
|
||||
cp \"\$STAGE_DIR/_hak5_manifest.json\" \"\$NEW/_hak5_manifest.json\"
|
||||
chmod +x \"\$NEW/payload.sh\" \"\$NEW/pagerwebui.init\"
|
||||
chmod -R 755 \"\$NEW/www\"
|
||||
ln -sfn \"\$RELDIR\" \"\$CURRENT\"
|
||||
|
||||
# Mirror into the legacy /root/payloads path the init scripts execute.
|
||||
rollback_install() {
|
||||
rm -rf \"\$LIVE\"
|
||||
[ ! -d \"\$BACKUP\" ] || mv \"\$BACKUP\" \"\$LIVE\"
|
||||
if [ -n \"\$PREV\" ]; then
|
||||
ln -sfn \"\$PREV\" \"\$CURRENT\"
|
||||
else
|
||||
rm -f \"\$CURRENT\"
|
||||
fi
|
||||
}
|
||||
if [ -d \"\$LIVE\" ]; then mv \"\$LIVE\" \"\$BACKUP\"; fi
|
||||
if ! mkdir -p \"\$LIVE\" || ! cp -a \"\$NEW/.\" \"\$LIVE/\"; then
|
||||
rollback_install
|
||||
exit 1
|
||||
fi
|
||||
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
|
||||
cp '$REMOTE_PAYLOAD_DIR/pagerwebui.init' /etc/init.d/pagerwebui
|
||||
cp -f \"\$LIVE/pagerwebui.init\" /etc/init.d/pagerwebui
|
||||
chmod +x /etc/init.d/pagerwebui
|
||||
/etc/init.d/pagerwebui enable
|
||||
if /etc/init.d/pagerwebui running >/dev/null 2>&1; then
|
||||
/etc/init.d/pagerwebui restart
|
||||
else
|
||||
cp -f \"\$LIVE/mk8-guard.init\" /etc/init.d/mk8-guard
|
||||
chmod 755 /etc/init.d/mk8-guard
|
||||
/etc/init.d/mk8-guard enable
|
||||
/etc/init.d/pagerwebui start
|
||||
|
||||
# Post-deploy verification: API must answer within ~60s (deadline-capped so
|
||||
# hung connections cannot stretch the window) or we roll back.
|
||||
T0=\$(date +%s)
|
||||
DEADLINE=\$((\$T0 + 60))
|
||||
HEALTH_OK=''
|
||||
while [ \$(date +%s) -lt \$DEADLINE ]; do
|
||||
if curl -fsS -m 3 http://127.0.0.1:8080/ >/dev/null 2>&1; then
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z \"\$HEALTH_OK\" ]; then
|
||||
echo 'post-deploy health check failed; rolling back' >&2
|
||||
/etc/init.d/pagerwebui stop >/dev/null 2>&1 || true
|
||||
rollback_install
|
||||
rm -rf \"\$RELDIR\"
|
||||
/etc/init.d/pagerwebui start
|
||||
exit 1
|
||||
fi
|
||||
rm -rf \"\$BACKUP\"
|
||||
# Prune old releases; keep the newest 3 including current.
|
||||
ALL=\$(ls -1d /mmc/mk8/releases/2* 2>/dev/null | sort)
|
||||
TOTAL=\$(printf '%s\\n' \"\$ALL\" | grep -c .)
|
||||
KEEP_FROM=\$((TOTAL - 2))
|
||||
if [ \"\$KEEP_FROM\" -gt 1 ]; then
|
||||
printf '%s\\n' \"\$ALL\" | awk -v kf=\"\$KEEP_FROM\" 'NR < kf' | while read r; do
|
||||
CUR=\$(readlink \$CURRENT 2>/dev/null || true)
|
||||
[ \"\$r\" = \"\$CUR\" ] || rm -rf \"\$r\"
|
||||
done
|
||||
fi
|
||||
echo RELEASE_OK@\"\$RELDIR\""
|
||||
if run_ssh "$TARGET" "$REMOTE_COMMAND"; then
|
||||
printf 'Release active: /mmc/mk8/releases/%s\n' "$RELEASE_TS"
|
||||
else
|
||||
printf 'Deployment failed; previous release restored on the pager.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo EXTRACT_OK"
|
||||
run_ssh "$TARGET" "$REMOTE_COMMAND"
|
||||
printf 'Installed to /root/payloads/%s/\n' "$REMOTE_PAYLOAD_DIR"
|
||||
|
||||
if $PORTAL_REFRESH && [[ -n "$PASSWORD" ]]; then
|
||||
PASSWORD_B64="$(printf '%s' "$PASSWORD" | base64)"
|
||||
@@ -232,4 +311,4 @@ elif $PORTAL_REFRESH; then
|
||||
printf 'Skipping portal refresh without --password; payload installation is complete.\n'
|
||||
fi
|
||||
|
||||
printf 'Deploy complete. Browse http://%s:8080/\n' "$PAGER_HOST"
|
||||
printf 'Deploy complete (v%s). Browse http://%s:8080/\n' "$VERSION" "$PAGER_HOST"
|
||||
|
||||
Executable
+514
@@ -0,0 +1,514 @@
|
||||
#!/bin/sh
|
||||
# Mark VIII on-device reliability smoke suite (POSIX sh, BusyBox-safe).
|
||||
#
|
||||
# Usage: smoke.sh [--write]
|
||||
#
|
||||
# Read-only checks (default):
|
||||
# 1. Web UI answers GET /
|
||||
# 2. mk8-guard installed and enabled (S49 boot symlink)
|
||||
# 3. Safe-UCI invariants + SSID pool size <= 20
|
||||
# 4. Event journal: last line of /mmc/mk8/events.log parses as JSON
|
||||
# with a 'kind' field (via python3)
|
||||
# 5. Monitor interfaces wlan0mon + wlan1mon exist
|
||||
# 6. Deployed server.py SERVER_VERSION == payload.sh Version header
|
||||
# 7. Authenticated API path: POST /api/login -> GET /api/health
|
||||
# (requires webui password in $PASS; skipped when unset)
|
||||
#
|
||||
# Destructive drills (--write only; values auto-restored):
|
||||
# 8. Bad-value drill: feeds --reconcile a wrong bands value and an
|
||||
# oversized SSID pool (25 dummy entries), verifies both are
|
||||
# repaired, restores originals.
|
||||
# 9. RF role drill (only when SMOKE_UPLINK_SSID is set): switches
|
||||
# radio1 to the uplink role against the named lab AP, expects an
|
||||
# association, then back to attack with hopping resumed.
|
||||
# 10. Rollback watchdog drill: runs mk8-watchdog.sh against a config
|
||||
# profile while the web UI is up (expects clean promote exit),
|
||||
# then STOPS the pagerwebui service and expects the watchdog to
|
||||
# roll back and journal a 'rollback' event, then restarts webui.
|
||||
# Preceded by a 5-second warning countdown; brief web outage.
|
||||
# Drills run only when every read-only check has passed.
|
||||
#
|
||||
# Environment:
|
||||
# PASS webui password used for POST /api/login (check 7).
|
||||
# SMOKE_UPLINK_SSID lab AP SSID; enables the --write RF role drill.
|
||||
# SMOKE_UPLINK_PSK optional PSK for the lab AP.
|
||||
#
|
||||
# Exit status: 0 when every executed check passes, 1 otherwise.
|
||||
|
||||
set -u
|
||||
|
||||
BASE=/mmc/mk8
|
||||
REL="$BASE/releases/current"
|
||||
# Release layout: <release>/user/<category>/<key>/... — resolve the payload dir.
|
||||
REL_PAY="$(find "$REL/user" -maxdepth 3 -name server.py 2>/dev/null | head -n 1)"
|
||||
REL_PAY="${REL_PAY%/server.py}"
|
||||
LEGACY=/root/payloads/user/remote_access/pager-webui
|
||||
URL=http://127.0.0.1:8080
|
||||
GUARD_INIT=/etc/init.d/mk8-guard
|
||||
GUARD_LINK=/etc/rc.d/S49mk8-guard
|
||||
WEBUI_INIT=/etc/init.d/pagerwebui
|
||||
JAR=/tmp/mk8-smoke-cookies.$$
|
||||
|
||||
WRITE=0
|
||||
PASS="${PASS:-}"
|
||||
UPLINK_SSID="${SMOKE_UPLINK_SSID:-}"
|
||||
UPLINK_PSK="${SMOKE_UPLINK_PSK:-}"
|
||||
WEB_STOPPED=0
|
||||
FAILED=0
|
||||
WAIT_RC=0
|
||||
wp=""
|
||||
|
||||
PY="$(command -v python3 2>/dev/null || true)"
|
||||
[ -n "$PY" ] || PY=/usr/bin/python3
|
||||
[ -x "$PY" ] || PY=""
|
||||
|
||||
usage() {
|
||||
printf 'Usage: smoke.sh [--write]\n'
|
||||
printf '\n'
|
||||
printf 'Read-only checks run by default. --write adds destructive drills\n'
|
||||
printf '(run only if every read-only check passed) that briefly toggle\n'
|
||||
printf 'UCI config and stop/start the pagerwebui service; original values\n'
|
||||
printf 'are restored automatically.\n'
|
||||
printf 'Set PASS=<webui password> to enable the authenticated API check.\n'
|
||||
printf 'Set SMOKE_UPLINK_SSID=[<PSK via SMOKE_UPLINK_PSK>] to enable the\n'
|
||||
printf '--write RF role drill against a lab AP.\n'
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
rm -f "$JAR" 2>/dev/null
|
||||
if [ -n "$wp" ]; then
|
||||
kill "$wp" 2>/dev/null
|
||||
fi
|
||||
if [ "$WEB_STOPPED" = "1" ]; then
|
||||
info 'restoring pagerwebui service'
|
||||
"$WEBUI_INIT" start >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
pass() { printf 'PASS %s\n' "$1"; }
|
||||
fail() { printf 'FAIL %s\n' "$1"; FAILED=$((FAILED + 1)); }
|
||||
skip() { printf 'SKIP %s\n' "$1"; }
|
||||
info() { printf ' %s\n' "$1"; }
|
||||
|
||||
uci_get() { uci -q get "$1" 2>/dev/null | tr -d '\r'; }
|
||||
|
||||
rollback_count() {
|
||||
if [ -z "$PY" ] || [ ! -f "$BASE/events.log" ]; then
|
||||
printf 0
|
||||
return
|
||||
fi
|
||||
tail -n 400 "$BASE/events.log" 2>/dev/null | "$PY" -c '
|
||||
import json, sys
|
||||
n = 0
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(d, dict) and d.get("kind") == "rollback":
|
||||
n += 1
|
||||
print(n)' 2>/dev/null || printf 0
|
||||
}
|
||||
|
||||
# wait_exit <pid> <seconds>: poll for background job exit; sets WAIT_RC
|
||||
# and returns 0 once reaped, 1 on timeout (job left running).
|
||||
wait_exit() {
|
||||
_pid="$1"; _t="$2"; _n=0
|
||||
while [ "$_n" -lt "$_t" ]; do
|
||||
if ! kill -0 "$_pid" 2>/dev/null; then
|
||||
wait "$_pid"
|
||||
WAIT_RC=$?
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
_n=$((_n + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
kill_bg() {
|
||||
kill "$1" 2>/dev/null
|
||||
sleep 1
|
||||
kill -9 "$1" 2>/dev/null
|
||||
wait "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
check_web_up() {
|
||||
if curl -fsS -m 5 "$URL/" >/dev/null 2>&1; then
|
||||
pass 'web: GET / answered'
|
||||
else
|
||||
fail 'web: GET / failed'
|
||||
fi
|
||||
}
|
||||
|
||||
check_guard() {
|
||||
if [ -x "$GUARD_INIT" ] && [ -e "$GUARD_LINK" ]; then
|
||||
pass 'guard: init script executable and enabled (S49)'
|
||||
else
|
||||
fail "guard: missing executable/init or S49 link ($GUARD_INIT $GUARD_LINK)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_invariants() {
|
||||
inv_fail=0
|
||||
while IFS= read -r kv; do
|
||||
[ -n "$kv" ] || continue
|
||||
key="${kv%%=*}"
|
||||
want="${kv#*=}"
|
||||
got="$(uci_get "$key")"
|
||||
if [ "$got" != "$want" ]; then
|
||||
inv_fail=$((inv_fail + 1))
|
||||
info "uci $key=$got (want $want)"
|
||||
fi
|
||||
done <<EOF
|
||||
pineapd.@ssidpool[0].disable=1
|
||||
pineapd.wlan2mon.disable=1
|
||||
pineapd.wlan2mon.hop=0
|
||||
pineapd.wlan1mon.bands=5
|
||||
pineapd.wlan0mon.bands=2
|
||||
pineapd.@pineapd[0].autossidpool=0
|
||||
EOF
|
||||
pool="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||
pool_n=$(printf '%s' "$pool" | awk '{n += NF} END {print n + 0}')
|
||||
if [ "$pool_n" -le 20 ]; then
|
||||
info "ssid pool size: $pool_n (max 20)"
|
||||
else
|
||||
inv_fail=$((inv_fail + 1))
|
||||
info "ssid pool size: $pool_n (max 20)"
|
||||
fi
|
||||
if [ "$inv_fail" -eq 0 ]; then
|
||||
pass 'invariants: safe UCI values + pool <= 20'
|
||||
else
|
||||
fail "invariants: $inv_fail violation(s)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_journal() {
|
||||
if [ -z "$PY" ]; then
|
||||
fail 'journal: python3 not found for JSON check'
|
||||
return
|
||||
fi
|
||||
last="$(tail -n 1 "$BASE/events.log" 2>/dev/null)"
|
||||
out="$(printf '%s' "$last" | "$PY" -c '
|
||||
import json, sys
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
raise SystemExit("events.log empty or missing")
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception as exc:
|
||||
raise SystemExit("not JSON: %s" % exc)
|
||||
kind = d.get("kind") if isinstance(d, dict) else None
|
||||
if not kind:
|
||||
raise SystemExit("last entry has no kind field")
|
||||
print(kind)' 2>&1)"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
pass "journal: last entry ok (kind=$out)"
|
||||
else
|
||||
fail "journal: $out"
|
||||
fi
|
||||
}
|
||||
|
||||
check_monitors() {
|
||||
miss=""
|
||||
for m in wlan0mon wlan1mon; do
|
||||
ip link show "$m" >/dev/null 2>&1 || miss="$miss $m"
|
||||
done
|
||||
if [ -z "$miss" ]; then
|
||||
pass 'monitors: wlan0mon + wlan1mon present'
|
||||
else
|
||||
fail "monitors: down:$miss"
|
||||
fi
|
||||
}
|
||||
|
||||
check_versions() {
|
||||
sv="$(grep '^SERVER_VERSION' "$REL_PAY/server.py" 2>/dev/null | head -n 1 \
|
||||
| sed -e 's/^SERVER_VERSION = //' -e "s/'//g" | tr -d '\r')"
|
||||
pv="$(grep '^#[ ]*[Vv]ersion:' "$REL_PAY/payload.sh" 2>/dev/null | head -n 1 \
|
||||
| sed 's/^#[ ]*[Vv]ersion:[ ]*//' | tr -d '\r')"
|
||||
if [ -n "$sv" ] && [ "$sv" = "$pv" ]; then
|
||||
pass "versions: release server.py and payload.sh agree ($sv)"
|
||||
else
|
||||
fail "versions: server.py='$sv' payload.sh='$pv'"
|
||||
fi
|
||||
}
|
||||
|
||||
check_authed_api() {
|
||||
if [ -z "$PASS" ]; then
|
||||
skip 'authed API: PASS not set'
|
||||
return
|
||||
fi
|
||||
if [ -z "$PY" ]; then
|
||||
fail 'authed API: python3 not found for response check'
|
||||
return
|
||||
fi
|
||||
body='{"username":"root","password":"'"$PASS"'"}'
|
||||
code="$(curl -fsS -m 10 -o /dev/null -w '%{http_code}' \
|
||||
-c "$JAR" -H 'Content-Type: application/json' \
|
||||
-d "$body" "$URL/api/login" 2>/dev/null)"
|
||||
if [ "$code" != "200" ]; then
|
||||
fail "authed API: login failed (http=$code)"
|
||||
return
|
||||
fi
|
||||
shape="$(curl -fsS -m 10 -b "$JAR" "$URL/api/health" 2>/dev/null \
|
||||
| "$PY" -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
except Exception:
|
||||
raise SystemExit("unparseable")
|
||||
print("dict" if isinstance(d, dict) else "other" )' 2>/dev/null)"
|
||||
if [ "$shape" = "dict" ]; then
|
||||
pass 'authed API: login + cookie-authenticated /api/health ok'
|
||||
else
|
||||
fail 'authed API: /api/health did not return a JSON object'
|
||||
fi
|
||||
}
|
||||
|
||||
drill_bad_values() {
|
||||
info 'bad-value drill: bands=2,5 then 25-entry SSID pool'
|
||||
orig_bands="$(uci_get pineapd.wlan1mon.bands)"
|
||||
[ -n "$orig_bands" ] || orig_bands=5
|
||||
orig_pool="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||
|
||||
uci set pineapd.wlan1mon.bands='2,5'
|
||||
uci commit pineapd
|
||||
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||
got="$(uci_get pineapd.wlan1mon.bands)"
|
||||
if [ "$got" = "5" ]; then
|
||||
pass 'drill: reconcile repaired bands 2,5 -> 5'
|
||||
else
|
||||
fail "drill: bands not repaired (got '$got')"
|
||||
fi
|
||||
uci set pineapd.wlan1mon.bands="$orig_bands"
|
||||
|
||||
list=""
|
||||
i=0
|
||||
while [ "$i" -lt 25 ]; do
|
||||
list="$list smoke$i"
|
||||
i=$((i + 1))
|
||||
done
|
||||
uci set "pineapd.@ssidpool[0].ssid=${list# }"
|
||||
uci commit pineapd
|
||||
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||
got="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||
if [ -z "$got" ]; then
|
||||
pass 'drill: reconcile cleared oversized SSID pool'
|
||||
else
|
||||
fail "drill: oversized pool survived ($(printf '%s' "$got" \
|
||||
| awk '{n += NF} END {print n + 0}') entries)"
|
||||
fi
|
||||
if [ -n "$orig_pool" ]; then
|
||||
uci set "pineapd.@ssidpool[0].ssid=$orig_pool"
|
||||
else
|
||||
uci -q delete 'pineapd.@ssidpool[0].ssid'
|
||||
fi
|
||||
uci commit pineapd
|
||||
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||
}
|
||||
|
||||
drill_role() {
|
||||
if [ -z "$UPLINK_SSID" ]; then
|
||||
skip 'role drill: set SMOKE_UPLINK_SSID to enable'
|
||||
return
|
||||
fi
|
||||
info "role drill: uplink '$UPLINK_SSID' then attack"
|
||||
out="$("$PY" - "$REL" "$LEGACY" "$UPLINK_SSID" "$UPLINK_PSK" <<'PYEOF' 2>&1
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
rel, legacy, ssid, psk = sys.argv[1:5]
|
||||
mk8_rfplan = None
|
||||
for d in (rel, legacy):
|
||||
if os.path.isfile(os.path.join(d, 'mk8_rfplan.py')):
|
||||
sys.path.insert(0, d)
|
||||
try:
|
||||
import mk8_rfplan
|
||||
break
|
||||
except Exception:
|
||||
sys.path.remove(d)
|
||||
if mk8_rfplan is None:
|
||||
print('FAIL mk8_rfplan not importable from release or legacy dir')
|
||||
raise SystemExit(1)
|
||||
|
||||
r1 = mk8_rfplan.set_role(
|
||||
'uplink', ssid=ssid or None, psk=psk or None)
|
||||
if not isinstance(r1, dict) or not r1.get('ok'):
|
||||
print('FAIL uplink set_role failed: %s' % json.dumps(r1))
|
||||
raise SystemExit(1)
|
||||
|
||||
assoc = mk8_rfplan.associated()
|
||||
if not assoc:
|
||||
print('FAIL uplink associated() returned nothing after set_role')
|
||||
else:
|
||||
print('associated as %s' % assoc)
|
||||
|
||||
try:
|
||||
r2 = mk8_rfplan.set_role('attack')
|
||||
except Exception as exc:
|
||||
r2 = {'ok': False, 'error': str(exc)}
|
||||
if not isinstance(r2, dict) or not r2.get('ok'):
|
||||
print('FAIL attack set_role failed: %s' % json.dumps(r2))
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0 if assoc else 1)
|
||||
PYEOF
|
||||
)"
|
||||
rc=$?
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
fail 'role drill: uplink association failed (see detail above)'
|
||||
return
|
||||
fi
|
||||
hop="$(uci_get pineapd.wlan1mon.hop)"
|
||||
if [ "$hop" = "1" ]; then
|
||||
pass 'role drill: uplink assoc + attack role + hopping resumed'
|
||||
else
|
||||
fail "role drill: pineapd.wlan1mon.hop=$hop after attack role (want 1)"
|
||||
fi
|
||||
}
|
||||
|
||||
drill_watchdog() {
|
||||
WDOG="$REL_PAY/mk8-watchdog.sh"
|
||||
[ -f "$WDOG" ] || WDOG="$LEGACY/mk8-watchdog.sh"
|
||||
if [ ! -f "$WDOG" ]; then
|
||||
fail 'drill: mk8-watchdog.sh not found in release or legacy dir'
|
||||
return
|
||||
fi
|
||||
# Ensure a profile exists: snapshot the live config as the drill target.
|
||||
if [ ! -d "$BASE/profiles" ] || [ -z "$(ls "$BASE/profiles" 2>/dev/null)" ]; then
|
||||
"$PY" - "$REL_PAY" <<'PYEOF' >/dev/null 2>&1 || {
|
||||
import sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
import mk8_profiles
|
||||
mk8_profiles.snapshot('smoke-drill')
|
||||
PYEOF
|
||||
fail 'drill: could not snapshot a profile for watchdog drill'
|
||||
return
|
||||
}
|
||||
fi
|
||||
prof=lastknown-good
|
||||
[ -d "$BASE/profiles/$prof" ] \
|
||||
|| prof="$(ls "$BASE/profiles" 2>/dev/null | head -n 1)"
|
||||
if [ -z "$prof" ]; then
|
||||
fail 'drill: no profiles under /mmc/mk8/profiles to exercise watchdog'
|
||||
return
|
||||
fi
|
||||
|
||||
printf ' WARNING: watchdog drill stops/starts the webui service.\n'
|
||||
n=5
|
||||
while [ "$n" -gt 0 ]; do
|
||||
printf ' starting in %ds (ctrl-c to abort)\n' "$n"
|
||||
sleep 1
|
||||
n=$((n - 1))
|
||||
done
|
||||
|
||||
if ! curl -fsS -m 5 "$URL/" >/dev/null 2>&1; then
|
||||
fail 'drill: web must be up before watchdog promote phase'
|
||||
return
|
||||
fi
|
||||
|
||||
# Phase 1: healthy system -> watchdog promotes snapshot, exits 0.
|
||||
"$WDOG" "$prof" 1 2 2 30 &
|
||||
wp=$!
|
||||
if wait_exit "$wp" 40; then
|
||||
if [ "$WAIT_RC" -eq 0 ]; then
|
||||
pass "drill: watchdog promote path exited 0 (profile=$prof)"
|
||||
else
|
||||
fail "drill: watchdog promote exit=$WAIT_RC"
|
||||
fi
|
||||
else
|
||||
kill_bg "$wp"
|
||||
fail 'drill: watchdog promote did not exit within 40s'
|
||||
fi
|
||||
|
||||
# Phase 2: web stopped -> watchdog rolls back and journals it.
|
||||
before="$(rollback_count)"
|
||||
WEB_STOPPED=1
|
||||
"$WEBUI_INIT" stop >/dev/null 2>&1
|
||||
"$WDOG" "$prof" 1 2 2 60 &
|
||||
wp=$!
|
||||
rolled=0
|
||||
t=0
|
||||
while [ "$t" -lt 150 ]; do
|
||||
now="$(rollback_count)"
|
||||
[ "$now" -gt "$before" ] && { rolled=1; break; }
|
||||
kill -0 "$wp" 2>/dev/null || break
|
||||
sleep 2
|
||||
t=$((t + 2))
|
||||
done
|
||||
kill_bg "$wp"
|
||||
WEB_STOPPED=0
|
||||
"$WEBUI_INIT" start >/dev/null 2>&1
|
||||
up=0
|
||||
t=0
|
||||
while [ "$t" -lt 45 ]; do
|
||||
curl -fsS -m 3 "$URL/" >/dev/null 2>&1 && { up=1; break; }
|
||||
sleep 2
|
||||
t=$((t + 2))
|
||||
done
|
||||
if [ "$rolled" -eq 1 ]; then
|
||||
pass 'drill: watchdog rollback journaled'
|
||||
else
|
||||
fail 'drill: no new rollback entry in events.log'
|
||||
fi
|
||||
if [ "$up" -eq 1 ]; then
|
||||
pass 'drill: webui restored after rollback drill'
|
||||
else
|
||||
fail 'drill: webui did not come back within 45s'
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--write) WRITE=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown argument: %s\n' "$arg" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$(id -u 2>/dev/null)" != "0" ]; then
|
||||
printf 'FAIL smoke: must run as root on the device\n'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Mark VIII smoke suite (%s%s)\n' \
|
||||
"$(date '+%Y-%m-%d %H:%M:%S')" \
|
||||
"$([ "$WRITE" = "1" ] && printf ' --write')"
|
||||
|
||||
check_web_up
|
||||
check_guard
|
||||
check_invariants
|
||||
check_journal
|
||||
check_monitors
|
||||
check_versions
|
||||
check_authed_api
|
||||
|
||||
if [ "$WRITE" = "1" ]; then
|
||||
if [ -z "$PY" ]; then
|
||||
fail 'drills: python3 required but not found'
|
||||
elif [ "$FAILED" -ne 0 ]; then
|
||||
skip 'drills: read-only checks failed; refusing drills'
|
||||
else
|
||||
printf -- '--- --write drills ---\n'
|
||||
drill_bad_values
|
||||
drill_role
|
||||
drill_watchdog
|
||||
fi
|
||||
fi
|
||||
|
||||
printf -- '---\n'
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
printf 'SMOKE OK\n'
|
||||
exit 0
|
||||
fi
|
||||
printf 'SMOKE FAILED (%d check(s))\n' "$FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
main "$@"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for scripts/build_common.py version stamping helpers."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
|
||||
import build_common
|
||||
|
||||
|
||||
class BuildCommonTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.payload = os.path.join(self.dir, 'user', 'remote_access',
|
||||
'pager-webui')
|
||||
os.makedirs(self.payload)
|
||||
with open(os.path.join(self.dir, '_hak5_manifest.json'), 'w') as f:
|
||||
f.write('{"payload": "pager-webui", "version": "1.3.2"}')
|
||||
with open(os.path.join(self.payload, 'payload.sh'), 'w') as f:
|
||||
f.write('#!/bin/bash\n'
|
||||
'# Title: Mark VIII\n'
|
||||
'# Description: test payload\n'
|
||||
'# Version: 1.3.2\n'
|
||||
'# Category: Remote-Access\n'
|
||||
'\n'
|
||||
'echo hi\n')
|
||||
with open(os.path.join(self.payload, 'server.py'), 'w') as f:
|
||||
f.write('"""Mark VIII server."""\n'
|
||||
'import os\n'
|
||||
'\n'
|
||||
'PORT = 8080\n')
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def _server_text(self):
|
||||
with open(os.path.join(self.payload, 'server.py')) as f:
|
||||
return f.read()
|
||||
|
||||
def test_stamp_version_updates_all_three_files(self):
|
||||
stamped = sorted(build_common.stamp_version(self.dir, '1.4.0'))
|
||||
expected = sorted([
|
||||
os.path.join(self.dir, '_hak5_manifest.json'),
|
||||
os.path.join(self.payload, 'payload.sh'),
|
||||
os.path.join(self.payload, 'server.py'),
|
||||
])
|
||||
self.assertEqual(stamped, expected)
|
||||
with open(os.path.join(self.dir, '_hak5_manifest.json')) as f:
|
||||
self.assertEqual(json.load(f)['version'], '1.4.0')
|
||||
with open(os.path.join(self.payload, 'payload.sh')) as f:
|
||||
sh_text = f.read()
|
||||
self.assertIn('# Version: 1.4.0', sh_text)
|
||||
self.assertNotIn('# Version: 1.3.2', sh_text)
|
||||
server_text = self._server_text()
|
||||
self.assertIn("SERVER_VERSION = '1.4.0'", server_text)
|
||||
self.assertEqual(server_text.count('SERVER_VERSION'), 1)
|
||||
self.assertLess(server_text.index("SERVER_VERSION = '1.4.0'"),
|
||||
server_text.index('\nimport os'))
|
||||
|
||||
def test_stamp_version_is_idempotent_and_upgrades(self):
|
||||
build_common.stamp_version(self.dir, '1.4.0')
|
||||
stamped = build_common.stamp_version(self.dir, '1.5.0')
|
||||
self.assertEqual(len(stamped), 3)
|
||||
server_text = self._server_text()
|
||||
self.assertEqual(server_text.count('SERVER_VERSION'), 1)
|
||||
self.assertIn("SERVER_VERSION = '1.5.0'", server_text)
|
||||
with open(os.path.join(self.payload, 'payload.sh')) as f:
|
||||
self.assertIn('# Version: 1.5.0', f.read())
|
||||
|
||||
def test_server_without_docstring_gets_top_injection(self):
|
||||
path = os.path.join(self.payload, 'server.py')
|
||||
with open(path, 'w') as f:
|
||||
f.write('# comment header\n'
|
||||
'\n'
|
||||
'import os\n'
|
||||
'PORT = 8080\n')
|
||||
build_common.stamp_version(self.dir, '9.9.9')
|
||||
text = self._server_text()
|
||||
lines = text.splitlines(True)
|
||||
idx = [i for i, l in enumerate(lines) if l.startswith('SERVER_VERSION')]
|
||||
self.assertEqual(len(idx), 1)
|
||||
self.assertLess(idx[0], [i for i, l in enumerate(lines)
|
||||
if l.startswith('import os')][0])
|
||||
|
||||
def test_missing_files_are_tolerated(self):
|
||||
empty = tempfile.mkdtemp()
|
||||
try:
|
||||
self.assertEqual(build_common.stamp_version(empty, '1.4.0'), [])
|
||||
finally:
|
||||
shutil.rmtree(empty, ignore_errors=True)
|
||||
|
||||
def test_invalid_version_is_rejected(self):
|
||||
for bad in ("1.4'; import os", '', 'a' * 64, 'ver x'):
|
||||
with self.assertRaises(ValueError):
|
||||
build_common.stamp_version(self.dir, bad)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -69,3 +69,15 @@ class DeviceRunTest(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class DeviceRunInputTest(unittest.TestCase):
|
||||
def test_str_input_data_is_encoded(self):
|
||||
rc, out, err = server.device_run(['cat'], input_data='uci import text')
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(out, 'uci import text')
|
||||
|
||||
def test_bytes_input_data_passes_through(self):
|
||||
rc, out, err = server.device_run(['cat'], input_data=b'raw')
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(out, 'raw')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
@@ -139,5 +140,171 @@ class HealthCheckTest(unittest.TestCase):
|
||||
self.assertIn('pool_disabled', payload)
|
||||
|
||||
|
||||
class SupervisorExtrasTest(unittest.TestCase):
|
||||
def runTestWith(self): # helper: reuse existing setUp fake_run
|
||||
pass
|
||||
|
||||
def test_mem_percent_math(self):
|
||||
import tempfile
|
||||
content = 'MemTotal: 250000 kB\nMemAvailable: 100000 kB\n'
|
||||
path = tempfile.mktemp()
|
||||
open(path, 'w').write(content)
|
||||
self.assertEqual(server._mem_percent(path), 60)
|
||||
|
||||
def test_health_reparks_resurrected_dummy_sta(self):
|
||||
server._health['ticks'] = server.HEALTH_STA_PARK_INTERVAL - 1
|
||||
with mock.patch.object(
|
||||
server, '_sta_uplink_enabled', return_value=True), \
|
||||
mock.patch.object(
|
||||
server, '_park_dummy_sta') as park, \
|
||||
mock.patch.object(
|
||||
server, '_raise_monitors', return_value=[]), \
|
||||
mock.patch('mk8_guard.reconcile',
|
||||
return_value={'changed': [], 'pool_cleared': False,
|
||||
'monitors_raised': []}) as rec:
|
||||
h = server.health_check()
|
||||
park.assert_called_once()
|
||||
rec.assert_called_once()
|
||||
self.assertEqual(server._health['ticks'],
|
||||
server.HEALTH_STA_PARK_INTERVAL)
|
||||
# not on interval ticks: no re-park, no reconcile
|
||||
server._health['ticks'] = 1
|
||||
with mock.patch.object(
|
||||
server, '_sta_uplink_enabled', return_value=True), \
|
||||
mock.patch.object(
|
||||
server, '_park_dummy_sta') as park, \
|
||||
mock.patch('mk8_guard.reconcile') as rec:
|
||||
server.health_check()
|
||||
park.assert_not_called()
|
||||
rec.assert_not_called()
|
||||
|
||||
def test_health_reconcile_journals_changed_keys(self):
|
||||
import mk8_events
|
||||
events = []
|
||||
old_log = mk8_events.log_event
|
||||
old_run = server.device_run
|
||||
old_iface = server._iface_up
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a[:2] == ['pidof', 'pineapd']:
|
||||
return (0, '12345\n', '')
|
||||
if a[:2] == ['ip', 'link', 'show']:
|
||||
return (0, '4: wlan0mon: <UP> state unknown', '')
|
||||
return (0, '', '')
|
||||
|
||||
mk8_events.log_event = lambda kind, **kw: events.append((kind, kw))
|
||||
server.device_run = fake_run
|
||||
server._iface_up = lambda name: True
|
||||
old_ticks = server._health.get('ticks')
|
||||
try:
|
||||
server._health['ticks'] = server.HEALTH_STA_PARK_INTERVAL - 1
|
||||
with mock.patch.object(server, '_sta_uplink_enabled',
|
||||
return_value=False), \
|
||||
mock.patch.object(server, '_raise_monitors',
|
||||
return_value=[]), \
|
||||
mock.patch('mk8_guard.reconcile',
|
||||
return_value={'changed':
|
||||
['pineapd.@pineapd[0].autossidpool'],
|
||||
'pool_cleared': False,
|
||||
'monitors_raised': []}):
|
||||
server.health_check()
|
||||
self.assertTrue(any(k == 'guard_fix' and 'autossidpool' in kw.get('msg', '')
|
||||
for k, kw in events))
|
||||
finally:
|
||||
mk8_events.log_event = old_log
|
||||
server.device_run = old_run
|
||||
server._iface_up = old_iface
|
||||
if old_ticks is None:
|
||||
server._health.pop('ticks', None)
|
||||
else:
|
||||
server._health['ticks'] = old_ticks
|
||||
|
||||
def test_health_reports_events_and_counters(self):
|
||||
import mk8_events
|
||||
mk8_events.log_event('restart', msg='x')
|
||||
status, h = server.h_health(None)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('events', h)
|
||||
self.assertIn('boots', h['reliability'])
|
||||
|
||||
def test_boot_marker_detects_unexpected(self):
|
||||
import mk8_events, tempfile, os
|
||||
marker = tempfile.mktemp()
|
||||
old = server.BOOT_MARKER
|
||||
server.BOOT_MARKER = marker
|
||||
try:
|
||||
open(marker, 'w').write('0')
|
||||
self.assertTrue(server.check_boot_marker())
|
||||
os.unlink(marker)
|
||||
self.assertFalse(server.check_boot_marker())
|
||||
finally:
|
||||
server.BOOT_MARKER = old
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class HopBaselineTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runs = []
|
||||
self.old_run = server.device_run
|
||||
self.old_iface = server._iface_up
|
||||
server._iface_up = lambda name: True
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
self.runs.append(a)
|
||||
if a[:2] == ['pidof', 'pineapd']:
|
||||
return (0, '123\n', '')
|
||||
if a[:3] == ['uci', 'get', 'pineapd.wlan1mon.hop']:
|
||||
return (0, self.hop + '\n', '')
|
||||
return (0, '', '')
|
||||
|
||||
server.device_run = fake_run
|
||||
self.hop = '0'
|
||||
|
||||
def tearDown(self):
|
||||
server.device_run = self.old_run
|
||||
server._iface_up = self.old_iface
|
||||
server._health['ticks'] = 0
|
||||
|
||||
def test_resume_called_when_not_held(self):
|
||||
import unittest.mock as um
|
||||
old_held = server._HOP_PAUSE_HELD
|
||||
server._HOP_PAUSE_HELD = False
|
||||
server._health['ticks'] = server.HEALTH_STA_PARK_INTERVAL - 1
|
||||
try:
|
||||
with mock.patch.object(server, '_sta_uplink_enabled',
|
||||
return_value=False), \
|
||||
mock.patch.object(server, '_raise_monitors',
|
||||
return_value=[]), \
|
||||
mock.patch('mk8_guard.reconcile',
|
||||
return_value={'changed': [], 'pool_cleared': False,
|
||||
'monitors_raised': []}):
|
||||
server.health_check()
|
||||
sets = [r for r in self.runs
|
||||
if r[:3] == ['uci', 'set', 'pineapd.wlan1mon.hop=1']]
|
||||
self.assertEqual(len(sets), 1)
|
||||
finally:
|
||||
server._HOP_PAUSE_HELD = old_held
|
||||
|
||||
def test_resume_skipped_while_pause_held(self):
|
||||
old_held = server._HOP_PAUSE_HELD
|
||||
server._HOP_PAUSE_HELD = True
|
||||
server._health['ticks'] = server.HEALTH_STA_PARK_INTERVAL - 1
|
||||
try:
|
||||
with mock.patch.object(server, '_sta_uplink_enabled',
|
||||
return_value=False), \
|
||||
mock.patch.object(server, '_raise_monitors',
|
||||
return_value=[]), \
|
||||
mock.patch('mk8_guard.reconcile',
|
||||
return_value={'changed': [], 'pool_cleared': False,
|
||||
'monitors_raised': []}):
|
||||
server.health_check()
|
||||
sets = [r for r in self.runs
|
||||
if r[:3] == ['uci', 'set', 'pineapd.wlan1mon.hop=1']]
|
||||
self.assertEqual(len(sets), 0)
|
||||
finally:
|
||||
server._HOP_PAUSE_HELD = old_held
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import json, os, sys, tempfile, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_events
|
||||
|
||||
class EventsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.old = mk8_events.MK8_DIR
|
||||
mk8_events.MK8_DIR = self.dir
|
||||
mk8_events.EVENTS_PATH = os.path.join(self.dir, 'events.log')
|
||||
|
||||
def tearDown(self):
|
||||
mk8_events.MK8_DIR = self.old
|
||||
mk8_events.EVENTS_PATH = os.path.join(self.old, 'events.log')
|
||||
mk8_events.MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
def test_log_and_read_newest_first(self):
|
||||
mk8_events.log_event('boot', msg='first')
|
||||
mk8_events.log_event('rollback', sev='warn', msg='second', meta={'op': 'wifi'})
|
||||
rows = mk8_events.read_events()
|
||||
self.assertEqual(rows[0]['kind'], 'rollback')
|
||||
self.assertEqual(rows[1]['kind'], 'boot')
|
||||
self.assertEqual(rows[0]['meta'], {'op': 'wifi'})
|
||||
|
||||
def test_counters(self):
|
||||
mk8_events.log_event('boot'); mk8_events.log_event('rollback')
|
||||
mk8_events.log_event('restart'); mk8_events.log_event('guard_fix')
|
||||
c = mk8_events.counters()
|
||||
self.assertEqual(c['boots'], 1)
|
||||
self.assertEqual(c['rollbacks'], 1)
|
||||
self.assertEqual(c['restarts'], 1)
|
||||
self.assertEqual(c['guard_fixes'], 1)
|
||||
|
||||
def test_snapshot_bounded_and_counts(self):
|
||||
# Deterministic window: 3 restarts (oldest, outside the scan window),
|
||||
# then 40 ticks, then 1 boot LAST so the newest-first window of 30
|
||||
# includes it.
|
||||
for _ in range(3):
|
||||
mk8_events.log_event('restart', msg='old')
|
||||
for i in range(40):
|
||||
mk8_events.log_event('tick', msg='t%d' % i)
|
||||
mk8_events.log_event('boot', msg='final boot')
|
||||
snap = mk8_events.snapshot(event_limit=5, scan=30)
|
||||
self.assertLessEqual(len(snap['events']), 5)
|
||||
ev = snap['events']
|
||||
tss = [r.get('ts', 0) for r in ev]
|
||||
self.assertEqual(tss, sorted(tss, reverse=True), 'must be newest-first')
|
||||
self.assertEqual(ev[0]['kind'], 'boot')
|
||||
rel = snap['reliability']
|
||||
self.assertEqual(rel['boots'], 1,
|
||||
'boot is inside the scanned window')
|
||||
self.assertEqual(rel['restarts'], 0,
|
||||
'counters must cover only the bounded window')
|
||||
self.assertEqual(rel['unexpected_boots'], 0)
|
||||
# Full-journal counters() stays available and unbounded for callers
|
||||
# that want it.
|
||||
self.assertEqual(mk8_events.counters()['restarts'], 3)
|
||||
|
||||
def test_snapshot_spans_rotation(self):
|
||||
mk8_events.MAX_BYTES = 200
|
||||
for i in range(8):
|
||||
mk8_events.log_event('tick', msg='x' * 20)
|
||||
if i == 3:
|
||||
mk8_events.log_event('rollback', sev='warn', msg='mid')
|
||||
rotated = [i for i in range(1, mk8_events.KEEP + 1)
|
||||
if os.path.exists(mk8_events.EVENTS_PATH + '.%d' % i)]
|
||||
self.assertGreaterEqual(len(rotated) + 1, 2,
|
||||
'journal must actually rotate here')
|
||||
snap = mk8_events.snapshot(event_limit=100, scan=1000)
|
||||
# Snapshot must span every rotated generation read_events sees.
|
||||
self.assertEqual(len(snap['events']),
|
||||
len(mk8_events.read_events(limit=1000)))
|
||||
tss = [r.get('ts', 0) for r in snap['events']]
|
||||
self.assertEqual(tss, sorted(tss, reverse=True))
|
||||
kinds = {r['kind'] for r in snap['events']}
|
||||
self.assertEqual(kinds, {'tick', 'rollback'})
|
||||
self.assertEqual(snap['reliability']['rollbacks'], 1)
|
||||
|
||||
def test_rotation_keeps_recent(self):
|
||||
mk8_events.MAX_BYTES = 200
|
||||
for i in range(20):
|
||||
mk8_events.log_event('tick', msg='x' * 30)
|
||||
rows = mk8_events.read_events()
|
||||
self.assertGreater(len(rows), 0)
|
||||
self.assertLessEqual(len(rows), 20)
|
||||
self.assertTrue(os.path.exists(mk8_events.EVENTS_PATH + '.1'))
|
||||
self.assertFalse(os.path.exists(mk8_events.EVENTS_PATH + '.5'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
import os, sys, types, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_gate
|
||||
|
||||
|
||||
class DecisionTest(unittest.TestCase):
|
||||
def tick(self, state):
|
||||
action, new = mk8_gate.watchdog_decision(state)
|
||||
return action, new
|
||||
|
||||
def test_no_action_below_fail_threshold(self):
|
||||
action, s = self.tick({'fails': 5, 'oks': 0, 'tripped': False})
|
||||
self.assertIsNone(action)
|
||||
self.assertFalse(s['tripped'])
|
||||
|
||||
def test_rollback_at_threshold(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 0, 'tripped': False})
|
||||
self.assertEqual(action, 'rollback')
|
||||
self.assertTrue(s['tripped'])
|
||||
self.assertEqual(s['oks'], 0)
|
||||
|
||||
def test_promote_after_recovery(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 6, 'tripped': True})
|
||||
self.assertEqual(action, 'promote')
|
||||
|
||||
def test_no_promote_before_recovery_threshold(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 5, 'tripped': True})
|
||||
self.assertIsNone(action)
|
||||
|
||||
|
||||
class EnterTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.addCleanup(setattr, mk8_gate, 'ENABLED', mk8_gate.ENABLED)
|
||||
self.spawned = []
|
||||
|
||||
def _install_fake_profiles(self):
|
||||
snaps = []
|
||||
fake = types.ModuleType('mk8_profiles')
|
||||
fake.auto_name = lambda op: 'pre-%s-42' % op
|
||||
fake.snapshot = lambda name: (snaps.append(name), True)[1]
|
||||
old = sys.modules.get('mk8_profiles')
|
||||
sys.modules['mk8_profiles'] = fake
|
||||
self.addCleanup(sys.modules.__setitem__, 'mk8_profiles', old)
|
||||
return snaps
|
||||
|
||||
def _capture_popen(self):
|
||||
cmds = []
|
||||
old = mk8_gate.subprocess.Popen
|
||||
mk8_gate.subprocess.Popen = lambda cmd, **kw: cmds.append(cmd)
|
||||
self.addCleanup(setattr, mk8_gate.subprocess, 'Popen', old)
|
||||
return cmds
|
||||
|
||||
def test_enter_disabled_is_noop(self):
|
||||
mk8_gate.ENABLED = False
|
||||
cmds = self._capture_popen()
|
||||
snaps = self._install_fake_profiles()
|
||||
self.assertIsNone(mk8_gate.enter('ap_change'))
|
||||
self.assertEqual(snaps, [])
|
||||
self.assertEqual(cmds, [])
|
||||
|
||||
def test_enter_enabled_snapshots_and_spawns(self):
|
||||
mk8_gate.ENABLED = True
|
||||
cmds = self._capture_popen()
|
||||
snaps = self._install_fake_profiles()
|
||||
name = mk8_gate.enter('attack_deploy')
|
||||
self.assertEqual(name, 'pre-attack_deploy-42')
|
||||
self.assertEqual(snaps, [name])
|
||||
self.assertEqual(len(cmds), 1)
|
||||
self.assertIn('setsid sh', cmds[0])
|
||||
self.assertIn(name, cmds[0])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,144 @@
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
import mk8_guard
|
||||
|
||||
HOP_KEY = 'pineapd.wlan1mon.hop'
|
||||
|
||||
class GuardTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.uci = {}
|
||||
self.pool = 0
|
||||
self.mon_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||
self.old_iface_up = server._iface_up
|
||||
self.old_server_device_run = server.device_run
|
||||
self.old_guard_device_run = mk8_guard.device_run
|
||||
server._iface_up = lambda name: self.mon_up.get(name, True)
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
self.calls.append(a)
|
||||
if a[:2] == ['uci', 'get']:
|
||||
key = a[2]
|
||||
if key == 'pineapd.@ssidpool[0].ssid':
|
||||
return (0, ''.join('s%d\n' % i for i in range(self.pool)), '')
|
||||
return (0, self.uci.get(key, '') + '\n', '')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci[k] = v
|
||||
if a[:2] == ['uci', 'delete']:
|
||||
self.pool = 0
|
||||
return (0, '', '')
|
||||
mk8_guard.device_run = fake_run
|
||||
server.device_run = fake_run
|
||||
mk8_guard._GR_CACHE['data'] = None
|
||||
|
||||
def tearDown(self):
|
||||
server._iface_up = self.old_iface_up
|
||||
server.device_run = self.old_server_device_run
|
||||
mk8_guard.device_run = self.old_guard_device_run
|
||||
mk8_guard._GR_CACHE['data'] = None
|
||||
|
||||
def wanted_count(self):
|
||||
# Safe set minus wlan1mon.hop (rfplan owns hop), plus WANTED_EXTRA.
|
||||
return (len(server.PINEAPD_SAFE_UCI) - 1 +
|
||||
len(mk8_guard.WANTED_EXTRA))
|
||||
|
||||
def test_applies_all_wanted_when_missing(self):
|
||||
self.pool = 0
|
||||
result = mk8_guard.reconcile(clear_pool=False)
|
||||
sets = [c[2] for c in self.calls if c[:2] == ['uci', 'set']]
|
||||
self.assertEqual(len(sets), self.wanted_count())
|
||||
self.assertTrue(result['changed'])
|
||||
self.assertNotIn(HOP_KEY, {c[2].partition('=')[0]
|
||||
for c in self.calls
|
||||
if c[:2] == ['uci', 'set']})
|
||||
|
||||
def test_wanted_excludes_hop(self):
|
||||
wanted = mk8_guard._wanted()
|
||||
self.assertNotIn(HOP_KEY, wanted)
|
||||
self.assertEqual(len(wanted), self.wanted_count())
|
||||
|
||||
def test_clears_large_pool_only(self):
|
||||
self.pool = 25
|
||||
result = mk8_guard.reconcile(clear_pool=True)
|
||||
self.assertTrue(result['pool_cleared'])
|
||||
self.assertIn(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'], self.calls)
|
||||
|
||||
def test_small_pool_untouched(self):
|
||||
self.pool = 5
|
||||
result = mk8_guard.reconcile(clear_pool=True)
|
||||
self.assertFalse(result['pool_cleared'])
|
||||
|
||||
def test_report_ignores_hop_and_caches(self):
|
||||
for key, value in mk8_guard._wanted().items():
|
||||
self.uci[key] = value
|
||||
self.uci[HOP_KEY] = '1' # attack-role baseline; must stay ignored
|
||||
report = mk8_guard.guard_report()
|
||||
self.assertTrue(report['in_sync'], report)
|
||||
gets = [c[2] for c in self.calls if c[:2] == ['uci', 'get']]
|
||||
self.assertNotIn(HOP_KEY, gets)
|
||||
n_after_first = len(self.calls)
|
||||
self.assertIs(mk8_guard.guard_report(), report)
|
||||
self.assertEqual(len(self.calls), n_after_first,
|
||||
'guard_report must serve from cache within TTL')
|
||||
mk8_guard._GR_CACHE['t'] -= mk8_guard.GR_TTL_SECONDS * 2
|
||||
mk8_guard.guard_report()
|
||||
self.assertGreater(len(self.calls), n_after_first)
|
||||
# reconcile mutates live state; it must invalidate the cached report.
|
||||
mk8_guard.guard_report()
|
||||
n_cached = len(self.calls)
|
||||
mk8_guard.reconcile(clear_pool=False)
|
||||
self.assertIsNone(mk8_guard._GR_CACHE['data'])
|
||||
mk8_guard.guard_report()
|
||||
self.assertGreater(len(self.calls), n_cached)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class SectionRecreateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.uci = {}
|
||||
self.old_server_run = server.device_run
|
||||
self.old_iface = server._iface_up
|
||||
server._iface_up = lambda name: True
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
self.calls.append(a)
|
||||
if a[:3] == ['uci', '-q', 'show']:
|
||||
return (1, '', '') # section missing
|
||||
if a[:2] == ['uci', 'get']:
|
||||
got = self.uci.get(a[2])
|
||||
return (0, (got if got is not None else '') + '\n', '')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci[k] = v
|
||||
return (0, '', '')
|
||||
|
||||
def dual_run(args, timeout=20, input_data=None):
|
||||
# install the same fake for server-side helpers
|
||||
self.__dict__.setdefault('_srv', server)
|
||||
return fake_run(args, timeout=timeout, input_data=input_data)
|
||||
|
||||
mk8_guard.device_run = fake_run
|
||||
server.device_run = fake_run
|
||||
|
||||
def tearDown(self):
|
||||
server.device_run = self.old_server_run
|
||||
server._iface_up = self.old_iface
|
||||
|
||||
def test_recreates_missing_pineapd_section(self):
|
||||
result = mk8_guard.reconcile(clear_pool=False)
|
||||
self.assertIn(['uci', 'add', 'pineapd', 'pineapd'], self.calls)
|
||||
self.assertTrue(any('section recreated' in c for c in result['changed']))
|
||||
sets = [c for c in self.calls if c[:2] == ['uci', 'set']]
|
||||
self.assertEqual(len(sets),
|
||||
len(server.PINEAPD_SAFE_UCI) - 1
|
||||
+ len(mk8_guard.WANTED_EXTRA))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,84 @@
|
||||
import os, sys, tempfile, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_profiles
|
||||
|
||||
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||
|
||||
class ProfilesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
mk8_profiles.PROFILES_DIR = os.path.join(self.dir, 'profiles')
|
||||
self.state = {'pineapd': 'config pineapd\n\toption x y\n',
|
||||
'wireless': 'config wireless\n',
|
||||
'network': 'config network\n'}
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a[:2] == ['uci', 'import']:
|
||||
self.imports = getattr(self, 'imports', [])
|
||||
self.imports.append((a[2], input_data))
|
||||
return (0, '', '')
|
||||
if a[:2] == ['uci', 'export']:
|
||||
return (0, self.state.get(a[2], ''), '')
|
||||
if a[:2] == ['uci', 'commit']:
|
||||
return (0, '', '')
|
||||
return (0, '', '')
|
||||
self.runs = []
|
||||
mk8_profiles.run_cmd = lambda args, timeout=20, input_data=None: (
|
||||
self.runs.append(list(args)) or fake_run(args, timeout, input_data))
|
||||
|
||||
def test_snapshot_and_list(self):
|
||||
self.assertTrue(mk8_profiles.snapshot('testprof'))
|
||||
self.assertIn('testprof', mk8_profiles.list_profiles())
|
||||
|
||||
def test_restore_issues_import_per_config(self):
|
||||
mk8_profiles.snapshot('p1')
|
||||
ok = mk8_profiles.restore('p1')
|
||||
self.assertTrue(ok['ok'])
|
||||
imported = [r for r in self.runs if r[:2] == ['uci', 'import']]
|
||||
self.assertEqual(len(imported), len(CONFIGS))
|
||||
commits = [r for r in self.runs if r[:2] == ['uci', 'commit']]
|
||||
self.assertGreaterEqual(len(commits), 1)
|
||||
self.assertIn(('pineapd', 'config pineapd\n\toption x y\n'),
|
||||
self.imports)
|
||||
self.assertEqual(ok['restored'], list(CONFIGS))
|
||||
|
||||
def test_auto_name_format(self):
|
||||
name = mk8_profiles.auto_name('client_connect')
|
||||
self.assertTrue(name.startswith('pre-client_connect-'))
|
||||
|
||||
def test_promote_lastknown_good(self):
|
||||
self.assertTrue(mk8_profiles.promote_lastknown_good())
|
||||
self.assertIn(mk8_profiles.LASTKNOWN_GOOD,
|
||||
mk8_profiles.list_profiles())
|
||||
again = mk8_profiles.promote_lastknown_good()
|
||||
self.assertTrue(again)
|
||||
|
||||
def test_snapshot_false_when_nothing_written(self):
|
||||
self.state = {}
|
||||
self.assertFalse(mk8_profiles.snapshot('empty'))
|
||||
|
||||
def test_path_rejects_traversal_and_bad_names(self):
|
||||
for bad in ('../x', '..', 'a/b', '', 'a' * 65, './x', 'x/..',
|
||||
'a b', 'a;b', None):
|
||||
with self.assertRaises(ValueError):
|
||||
mk8_profiles._path(bad)
|
||||
|
||||
def test_path_accepts_safe_names(self):
|
||||
for good in ('p', 'pre-client_connect-123', 'lastknown-good',
|
||||
'A.b-c_d', 'x' * 64, '0'):
|
||||
path = mk8_profiles._path(good)
|
||||
self.assertEqual(path, os.path.join(mk8_profiles.PROFILES_DIR,
|
||||
good))
|
||||
|
||||
def test_snapshot_rejects_bad_name_without_side_effects(self):
|
||||
with self.assertRaises(ValueError):
|
||||
mk8_profiles.snapshot('../evil')
|
||||
self.assertEqual(mk8_profiles.list_profiles(), [])
|
||||
|
||||
def test_list_profiles_skips_invalid_dirnames(self):
|
||||
mk8_profiles.snapshot('good')
|
||||
os.mkdir(os.path.join(mk8_profiles.PROFILES_DIR, 'bad name'))
|
||||
self.assertEqual(mk8_profiles.list_profiles(), ['good'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,327 @@
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload',
|
||||
'user', 'remote_access', 'pager-webui'))
|
||||
import importlib
|
||||
import server
|
||||
import mk8_rfplan
|
||||
|
||||
|
||||
def setUpModule():
|
||||
importlib.reload(server)
|
||||
|
||||
|
||||
class CtxStub(object):
|
||||
def __init__(self, body=None):
|
||||
self.body = body or {}
|
||||
|
||||
|
||||
class RfPlanTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runs = []
|
||||
self.seq = []
|
||||
self.paused = 0
|
||||
self.resumed = 0
|
||||
self.sleeps = []
|
||||
self.uci_show = {}
|
||||
self.gets = {}
|
||||
self.iw_fail_left = 0
|
||||
self._old = (server.device_run, server._pause_hop,
|
||||
server._resume_hop, server._read_hop,
|
||||
mk8_rfplan.time.sleep)
|
||||
|
||||
def fake_get(key):
|
||||
if key == 'pineapd.wlan1mon.hop':
|
||||
return 0, '0\n', ''
|
||||
return self.gets.get(key, (1, '', 'entry not found'))
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
args = list(args)
|
||||
self.seq.append('cmd:' + ' '.join(str(a) for a in args))
|
||||
self.runs.append((args, timeout))
|
||||
if args == ['iw', 'dev']:
|
||||
# interface enumeration: radio1 STA comes up as phy1-sta0
|
||||
return 0, ('Interface wlan1mon\n'
|
||||
'\ttype monitor\n'
|
||||
'Interface phy1-sta0\n'
|
||||
'\ttype managed\n'), ''
|
||||
if args[0] == 'readlink' and 'phy80211' in args[1]:
|
||||
return 0, '../../devices/platform/usb/phy1\n', ''
|
||||
if args[0] == 'iw':
|
||||
# iw dev <iface> link
|
||||
if self.iw_fail_left > 0:
|
||||
self.iw_fail_left -= 1
|
||||
return 0, 'Not connected.\n', ''
|
||||
return 0, ('Connected to aa:bb:cc:dd:ee:ff (on wlan1up)\n'
|
||||
'\tSSID: Net\n'), ''
|
||||
if args[:2] == ['uci', 'show']:
|
||||
cfg = self.uci_show.get(args[2])
|
||||
if cfg is None:
|
||||
return 1, '', 'entry not found'
|
||||
out = ''.join("%s.%s='%s'\n" % (args[2], k, v)
|
||||
for k, v in sorted(cfg.items()))
|
||||
return 0, out, ''
|
||||
if args[0] == 'uci' and args[1] == '-q':
|
||||
return fake_get(args[3])
|
||||
if args[:2] == ['uci', 'get']:
|
||||
return fake_get(args[2])
|
||||
return 0, '', ''
|
||||
|
||||
def fake_pause():
|
||||
self.paused += 1
|
||||
self.seq.append('pause')
|
||||
|
||||
def fake_resume():
|
||||
self.resumed += 1
|
||||
self.seq.append('resume')
|
||||
|
||||
server.device_run = fake_run
|
||||
server._pause_hop = fake_pause
|
||||
server._resume_hop = fake_resume
|
||||
server._read_hop = lambda: '0'
|
||||
mk8_rfplan.time.sleep = lambda s: self.sleeps.append(s)
|
||||
|
||||
def tearDown(self):
|
||||
(server.device_run, server._pause_hop,
|
||||
server._resume_hop, server._read_hop,
|
||||
mk8_rfplan.time.sleep) = self._old
|
||||
|
||||
@property
|
||||
def cmds(self):
|
||||
return [s[len('cmd:'):] for s in self.seq if s.startswith('cmd:')]
|
||||
|
||||
def mutations(self):
|
||||
return [c for c in self.cmds
|
||||
if c.startswith(('uci set', 'uci commit'))]
|
||||
|
||||
def test_current_role_reads_uci(self):
|
||||
cases = [
|
||||
({'mode': 'sta', 'disabled': '0'}, 'uplink'),
|
||||
({'mode': 'sta', 'disabled': '1'}, 'idle'),
|
||||
({'mode': 'ap'}, 'idle'),
|
||||
({}, 'idle'),
|
||||
]
|
||||
for cfg, want in cases:
|
||||
self.uci_show.clear()
|
||||
self.uci_show['wireless.wlan1up'] = cfg
|
||||
self.assertEqual(mk8_rfplan.current_role(), want, cfg)
|
||||
self.uci_show.clear()
|
||||
self.assertEqual(mk8_rfplan.current_role(), 'idle')
|
||||
|
||||
def test_uplink_sets_sta_section_and_pauses_hop(self):
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='key')
|
||||
self.assertTrue(result.get('ok'), result)
|
||||
cmds = self.cmds
|
||||
for expected in (
|
||||
'uci set wireless.wlan1up=wifi-iface',
|
||||
'uci set wireless.wlan1up.device=radio1',
|
||||
'uci set wireless.wlan1up.mode=sta',
|
||||
'uci set wireless.wlan1up.network=cli',
|
||||
'uci set wireless.wlan1up.ssid=Net',
|
||||
'uci set wireless.wlan1up.encryption=sae-mixed',
|
||||
'uci set wireless.wlan1up.key=key',
|
||||
'uci set wireless.wlan1up.disabled=0',
|
||||
'uci set network.cli=interface',
|
||||
'uci set network.cli.proto=dhcp',
|
||||
'uci commit network'):
|
||||
self.assertIn(expected, cmds)
|
||||
commit_net = cmds.index('uci commit network')
|
||||
commit_wireless = cmds.index('uci commit wireless')
|
||||
for c in ('uci set wireless.wlan1up.mode=sta',
|
||||
'uci set wireless.wlan1up.disabled=0',
|
||||
'uci set network.cli=interface',
|
||||
'uci set network.cli.proto=dhcp'):
|
||||
self.assertLess(cmds.index(c), commit_net)
|
||||
self.assertLess(commit_net, cmds.index('uci commit wireless'))
|
||||
self.assertLess(commit_wireless, cmds.index('wifi reload'))
|
||||
self.assertEqual(self.paused, 1)
|
||||
self.assertLess(self.seq.index('pause'),
|
||||
self.seq.index('cmd:wifi reload'))
|
||||
self.assertEqual(result['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||
|
||||
def test_uplink_enables_present_but_disabled_cli_network(self):
|
||||
self.gets['network.cli'] = (0, 'interface\n', '')
|
||||
self.gets['network.cli.disabled'] = (0, '1\n', '')
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||
self.assertTrue(result.get('ok'), result)
|
||||
cmds = self.cmds
|
||||
self.assertIn('uci set network.cli.disabled=0', cmds)
|
||||
self.assertIn('uci commit network', cmds)
|
||||
self.assertNotIn('uci set network.cli=interface', cmds)
|
||||
|
||||
def test_assoc_poll_succeeds_on_third_attempt(self):
|
||||
self.iw_fail_left = 2
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||
self.assertTrue(result.get('ok'), result)
|
||||
self.assertEqual(result['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||
self.assertEqual(len(self.sleeps), 3)
|
||||
iw_calls = [c for c in self.cmds
|
||||
if c.startswith('iw dev phy1-sta0 link')]
|
||||
self.assertEqual(len(iw_calls), 3)
|
||||
|
||||
def test_assoc_poll_exhaustion_reverts_uplink(self):
|
||||
self.iw_fail_left = 99
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('association failed', result['error'])
|
||||
self.assertEqual(len(self.sleeps), 5)
|
||||
self.assertIn('uci set wireless.wlan1up.disabled=1', self.cmds)
|
||||
self.assertEqual(self.resumed, 1)
|
||||
# Revert must converge runtime like the idle branch: a wifi reload
|
||||
# after the STA disable, with hop resumed only after the reload.
|
||||
cmds = self.cmds
|
||||
reloads = [i for i, c in enumerate(cmds) if c == 'wifi reload']
|
||||
self.assertEqual(len(reloads), 2, cmds)
|
||||
disable_set = cmds.index('uci set wireless.wlan1up.disabled=1')
|
||||
last_commit = len(cmds) - 1 - cmds[::-1].index('uci commit wireless')
|
||||
resume_idx = self.seq.index('resume')
|
||||
self.assertLess(disable_set, last_commit)
|
||||
self.assertLess(last_commit, reloads[-1])
|
||||
self.assertLess(reloads[-1], resume_idx)
|
||||
# cmds[i] maps 1:1 onto self.runs[i]; revert reload must be gated.
|
||||
self.assertEqual(self.runs[reloads[-1]], (['wifi', 'reload'], 60))
|
||||
|
||||
def test_set_role_uplink_requires_ssid(self):
|
||||
result = mk8_rfplan.set_role('uplink')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('ssid', result['error'])
|
||||
self.assertEqual(self.mutations(), [])
|
||||
self.assertEqual(self.paused, 0)
|
||||
|
||||
def test_idle_reloads_so_sta_disassociates_now(self):
|
||||
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||
result = mk8_rfplan.set_role('idle')
|
||||
self.assertTrue(result.get('ok'), result)
|
||||
self.assertIn('uci set wireless.wlan1up.disabled=1', self.cmds)
|
||||
self.assertIn('wifi reload', self.cmds)
|
||||
self.assertEqual(self.resumed, 1)
|
||||
|
||||
def test_exclusivity_switch(self):
|
||||
self.uci_show['wireless.wlan1up'] = {'mode': 'ap'}
|
||||
mk8_rfplan.ensure_attack()
|
||||
self.assertEqual(self.mutations(), [])
|
||||
self.assertEqual(self.resumed, 0)
|
||||
self.assertEqual(self.paused, 0)
|
||||
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||
mk8_rfplan.ensure_attack()
|
||||
cmds = self.cmds
|
||||
self.assertIn('uci set wireless.wlan1up.disabled=1', cmds)
|
||||
self.assertIn('uci commit wireless', cmds)
|
||||
self.assertEqual(self.resumed, 1)
|
||||
|
||||
def test_hop_paused_helper(self):
|
||||
self.assertTrue(mk8_rfplan.hop_paused())
|
||||
server._read_hop = lambda: None
|
||||
self.assertFalse(mk8_rfplan.hop_paused())
|
||||
server._read_hop = lambda: '1'
|
||||
self.assertFalse(mk8_rfplan.hop_paused())
|
||||
|
||||
def test_h_rfplan_get_and_post(self):
|
||||
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||
status, data = server.h_rfplan_get(CtxStub())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['role'], 'uplink')
|
||||
self.assertEqual(data['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||
self.assertTrue(data['hop_paused'])
|
||||
status, data = server.h_rfplan_post(CtxStub({'role': 'nope'}))
|
||||
self.assertEqual(status, 400)
|
||||
status, data = server.h_rfplan_post(CtxStub({'role': 'idle'}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['role'], 'idle')
|
||||
|
||||
def test_ensure_attack_wired_into_attacks_deploy(self):
|
||||
calls = []
|
||||
|
||||
def fake_set_role(role, ssid=None, psk=None):
|
||||
calls.append(role)
|
||||
return {'ok': True, 'role': role}
|
||||
|
||||
self._patch_rfplan(lambda: 'uplink', fake_set_role)
|
||||
old_dep = server._deploy_wpa_open
|
||||
old_state = server.update_pineap_state
|
||||
server._deploy_wpa_open = lambda kind, body: {}
|
||||
server.update_pineap_state = lambda *a, **kw: {}
|
||||
try:
|
||||
status, payload = server.h_attacks_deploy(
|
||||
CtxStub({'kind': 'wpa'}))
|
||||
finally:
|
||||
self._unpatch_rfplan()
|
||||
server._deploy_wpa_open = old_dep
|
||||
server.update_pineap_state = old_state
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls, ['attack'])
|
||||
|
||||
def test_attacks_deploy_skips_switch_when_not_uplink(self):
|
||||
calls = []
|
||||
|
||||
def fake_set_role(role, ssid=None, psk=None):
|
||||
calls.append(role)
|
||||
return {'ok': True}
|
||||
|
||||
self._patch_rfplan(lambda: 'idle', fake_set_role)
|
||||
old_dep = server._deploy_wpa_open
|
||||
old_state = server.update_pineap_state
|
||||
server._deploy_wpa_open = lambda kind, body: {}
|
||||
server.update_pineap_state = lambda *a, **kw: {}
|
||||
try:
|
||||
status, _ = server.h_attacks_deploy(CtxStub({'kind': 'wpa'}))
|
||||
finally:
|
||||
self._unpatch_rfplan()
|
||||
server._deploy_wpa_open = old_dep
|
||||
server.update_pineap_state = old_state
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_ensure_attack_wired_into_radio1_ap_request(self):
|
||||
calls = []
|
||||
|
||||
def fake_set_role(role, ssid=None, psk=None):
|
||||
calls.append(role)
|
||||
return {'ok': True, 'role': role}
|
||||
|
||||
self._patch_rfplan(lambda: 'uplink', fake_set_role)
|
||||
try:
|
||||
status, payload = server.h_pineap_wifi_set_ap(
|
||||
CtxStub({'open': {'enabled': False, 'channel': 36}}))
|
||||
finally:
|
||||
self._unpatch_rfplan()
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls, ['attack'])
|
||||
|
||||
def _patch_rfplan(self, current_role, set_role):
|
||||
self._rf_old = (mk8_rfplan.current_role, mk8_rfplan.set_role)
|
||||
mk8_rfplan.current_role = current_role
|
||||
mk8_rfplan.set_role = set_role
|
||||
|
||||
def _unpatch_rfplan(self):
|
||||
mk8_rfplan.current_role, mk8_rfplan.set_role = self._rf_old
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class PskModeChainTest(RfPlanTest):
|
||||
def test_chain_tries_next_mode_on_failure(self):
|
||||
# first mode (sae-mixed) never associates; second (sae) does
|
||||
self.iw_fail_left = 5 # fail all link polls of attempt 1
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||
self.assertTrue(result['ok'], result)
|
||||
self.assertEqual(result['mode'], 'sae')
|
||||
reloads = [c for c in self.cmds if c.startswith('wifi reload')]
|
||||
self.assertEqual(len(reloads), 2)
|
||||
|
||||
def test_chain_exhaustion_reports_modes(self):
|
||||
self.iw_fail_left = 99
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(result['tried_modes'],
|
||||
['sae-mixed', 'sae', 'psk2'])
|
||||
|
||||
def test_first_mode_success_records_mode(self):
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(result['mode'], 'sae-mixed')
|
||||
self.assertIn('uci set wireless.wlan1up.ieee80211w=1', self.cmds)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1164,6 +1164,16 @@ class ReconReportTest(unittest.TestCase):
|
||||
self.assertIn('AE:77:C0:EB:31:41', text)
|
||||
self.assertIn('handshake', text)
|
||||
self.assertNotIn('ProbeOnlySSID', text)
|
||||
def test_html_download_orders_strongest_signal_first(self):
|
||||
with mock.patch.object(server, '_gps_status_data', return_value={'lock': False}):
|
||||
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
|
||||
self.assertEqual(status, 200)
|
||||
text = payload.data.decode('utf-8')
|
||||
# Fixture: hidden AP at -64 dBm is stronger than Anderson-5 at -76 dBm.
|
||||
strong = '50:6F:9A:01:00:00'
|
||||
weak = 'C8:9E:43:64:80:80'
|
||||
self.assertLess(text.index(strong), text.index(weak))
|
||||
|
||||
def test_html_report_includes_gps_when_locked(self):
|
||||
with mock.patch.object(server, '_gps_status_data',
|
||||
return_value={'lock': True, 'lat': 37.7,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload',
|
||||
'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
|
||||
|
||||
def setUpModule():
|
||||
__import__('importlib').reload(server)
|
||||
|
||||
|
||||
class _Ctx(object):
|
||||
def __init__(self, body=None):
|
||||
self.body = body
|
||||
|
||||
|
||||
class ReliabilityApiTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runs = []
|
||||
self.old_device_run = server.device_run
|
||||
server._health.update({
|
||||
'sigsegv_last': None, 'last_fix': 0.0, 'fixes': 0,
|
||||
'last_action': None, 'pineap_up': False, 'monitor_fixes': 0})
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
self.runs.append((list(args), timeout))
|
||||
return (0, '', '')
|
||||
|
||||
server.device_run = fake_run
|
||||
|
||||
def tearDown(self):
|
||||
server.device_run = self.old_device_run
|
||||
import mk8_guard
|
||||
mk8_guard._GR_CACHE['data'] = None
|
||||
|
||||
def test_h_health_exposes_reliability_feed(self):
|
||||
status, h = server.h_health(None)
|
||||
self.assertEqual(status, 200)
|
||||
for key in ('reliability', 'events', 'guard'):
|
||||
self.assertIn(key, h)
|
||||
for counter in ('boots', 'unexpected_boots', 'rollbacks',
|
||||
'restarts', 'guard_fixes'):
|
||||
self.assertIn(counter, h['reliability'])
|
||||
self.assertIsInstance(h['events'], list)
|
||||
self.assertIn('in_sync', h['guard'])
|
||||
self.assertIn('pool_size', h['guard'])
|
||||
|
||||
def test_check_boot_marker_uses_module_marker_path(self):
|
||||
import tempfile
|
||||
marker = tempfile.mktemp()
|
||||
old = server.BOOT_MARKER
|
||||
server.BOOT_MARKER = marker
|
||||
try:
|
||||
if os.path.exists(marker):
|
||||
os.unlink(marker)
|
||||
self.assertFalse(server.check_boot_marker())
|
||||
self.assertTrue(os.path.exists(marker),
|
||||
'check_boot_marker must use server.BOOT_MARKER')
|
||||
open(marker, 'w').write('0')
|
||||
self.assertTrue(server.check_boot_marker())
|
||||
finally:
|
||||
if os.path.exists(marker):
|
||||
os.unlink(marker)
|
||||
server.BOOT_MARKER = old
|
||||
|
||||
def test_clean_cycle_boot_shutdown_next_boot_not_unexpected(self):
|
||||
import tempfile
|
||||
import threading
|
||||
import mk8_events as events_mod
|
||||
marker = tempfile.mktemp()
|
||||
old = (server.BOOT_MARKER, server.LIVE_STOP, server.HEALTH_STOP,
|
||||
server._recon_hopper_stop, events_mod.mark_boot)
|
||||
stops = (threading.Event(), threading.Event(), threading.Event())
|
||||
server.BOOT_MARKER = marker
|
||||
server.LIVE_STOP, server.HEALTH_STOP, \
|
||||
server._recon_hopper_stop = stops
|
||||
booted = []
|
||||
events_mod.mark_boot = \
|
||||
lambda unexpected=False: booted.append(unexpected)
|
||||
try:
|
||||
# Simulate a previous run's marker left behind: boot is unexpected.
|
||||
open(marker, 'w').write('0')
|
||||
self.assertTrue(server.check_boot_marker())
|
||||
self.assertEqual(booted, [True])
|
||||
# Clean shutdown clears the marker...
|
||||
self.assertTrue(os.path.exists(marker))
|
||||
server._request_shutdown()
|
||||
self.assertFalse(os.path.exists(marker),
|
||||
'graceful shutdown must clear the boot marker')
|
||||
for ev in stops:
|
||||
self.assertTrue(ev.is_set())
|
||||
# ...so the next boot is clean and re-arms the marker.
|
||||
self.assertFalse(server.check_boot_marker())
|
||||
self.assertEqual(booted[-1], False)
|
||||
self.assertTrue(os.path.exists(marker))
|
||||
# _clear_boot_marker is best-effort on missing/None markers.
|
||||
server._clear_boot_marker()
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
server.BOOT_MARKER = None
|
||||
server._clear_boot_marker()
|
||||
finally:
|
||||
(server.BOOT_MARKER, server.LIVE_STOP, server.HEALTH_STOP,
|
||||
server._recon_hopper_stop, events_mod.mark_boot) = old
|
||||
if os.path.exists(marker):
|
||||
os.unlink(marker)
|
||||
|
||||
def test_profile_routes_registered(self):
|
||||
handler, _ = server.ROUTER.dispatch('GET',
|
||||
'/api/reliability/profiles')
|
||||
self.assertEqual(handler, server.h_profiles_get)
|
||||
handler, _ = server.ROUTER.dispatch('POST', '/api/reliability/profile')
|
||||
self.assertEqual(handler, server.h_profile_save)
|
||||
handler, _ = server.ROUTER.dispatch('POST', '/api/reliability/restore')
|
||||
self.assertEqual(handler, server.h_profile_restore)
|
||||
handler, _ = server.ROUTER.dispatch('GET', '/api/reliability/nope')
|
||||
self.assertIsNone(handler)
|
||||
|
||||
def test_h_profiles_get_lists_profiles(self):
|
||||
import mk8_profiles
|
||||
old = mk8_profiles.list_profiles
|
||||
mk8_profiles.list_profiles = lambda: ['a', 'b']
|
||||
try:
|
||||
status, data = server.h_profiles_get(None)
|
||||
finally:
|
||||
mk8_profiles.list_profiles = old
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data, {'profiles': ['a', 'b']})
|
||||
|
||||
def test_h_profile_save_validates_saves_and_journals(self):
|
||||
import mk8_events
|
||||
import mk8_profiles
|
||||
calls = {'snapshots': []}
|
||||
events = []
|
||||
old_snapshot, old_log = mk8_profiles.snapshot, mk8_events.log_event
|
||||
|
||||
def fake_snapshot(name):
|
||||
if not all(c.isalnum() or c in '._-' for c in name) \
|
||||
or name in ('.', '..') or len(name) > 64:
|
||||
raise ValueError('invalid profile name')
|
||||
calls['snapshots'].append(name)
|
||||
return True
|
||||
|
||||
def fake_log(kind, **kw):
|
||||
events.append((kind, kw))
|
||||
|
||||
mk8_profiles.snapshot = fake_snapshot
|
||||
mk8_events.log_event = fake_log
|
||||
try:
|
||||
status, data = server.h_profile_save(_Ctx({'name': ' pre-x-1 '}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data, {'ok': True})
|
||||
self.assertEqual(calls['snapshots'], ['pre-x-1'])
|
||||
self.assertEqual(events[-1][0], 'profile_save')
|
||||
|
||||
status, data = server.h_profile_save(_Ctx({'name': ' '}))
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn('error', data)
|
||||
|
||||
status, data = server.h_profile_save(_Ctx({'name': '../evil'}))
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn('error', data)
|
||||
self.assertEqual(calls['snapshots'], ['pre-x-1'])
|
||||
|
||||
status, data = server.h_profile_save(_Ctx({}))
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def failed_snapshot(name):
|
||||
calls['snapshots'].append(name)
|
||||
return False
|
||||
mk8_profiles.snapshot = failed_snapshot
|
||||
status, data = server.h_profile_save(_Ctx({'name': 'p2'}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertFalse(data['ok'])
|
||||
self.assertEqual(events[-1][0], 'profile_save')
|
||||
self.assertEqual(events[-1][1].get('sev'), 'warn')
|
||||
finally:
|
||||
mk8_profiles.snapshot = old_snapshot
|
||||
mk8_events.log_event = old_log
|
||||
|
||||
def test_h_profile_restore_gated_reload_journal(self):
|
||||
import mk8_gate
|
||||
import mk8_events
|
||||
import mk8_profiles
|
||||
calls = {'gate': [], 'events': []}
|
||||
olds = (mk8_gate.enter, mk8_profiles.restore, mk8_events.log_event)
|
||||
|
||||
def fake_enter(op):
|
||||
calls['gate'].append(op)
|
||||
return 'snap-1'
|
||||
|
||||
def fake_restore(name):
|
||||
calls['restored'] = name
|
||||
return {'ok': True, 'restored': ['wireless']}
|
||||
|
||||
def fake_log(kind, **kw):
|
||||
calls['events'].append((kind, kw))
|
||||
|
||||
mk8_gate.enter = fake_enter
|
||||
mk8_profiles.restore = fake_restore
|
||||
mk8_events.log_event = fake_log
|
||||
try:
|
||||
status, result = server.h_profile_restore(_Ctx({'name': 'p1'}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(result, {'ok': True, 'restored': ['wireless']})
|
||||
self.assertEqual(calls['gate'], ['restore_profile'])
|
||||
self.assertEqual(calls['restored'], 'p1')
|
||||
reloads = [r for r in self.runs if r[0][:2] == ['wifi', 'reload']]
|
||||
self.assertEqual(len(reloads), 1)
|
||||
# post-restore convergence parks the factory-enabled dummy STA
|
||||
parked = [r for r in self.runs
|
||||
if r[0][:3] == ['uci', 'set',
|
||||
'wireless.dummy_radio0.disabled=1']]
|
||||
self.assertEqual(len(parked), 1)
|
||||
downs = [r for r in self.runs if r[0][:4] == ['ip', 'link',
|
||||
'set', 'wlan0']]
|
||||
self.assertTrue(downs)
|
||||
raises = [r for r in self.runs if r[0][:4] == ['ip', 'link',
|
||||
'set', 'wlan0mon']]
|
||||
self.assertTrue(raises)
|
||||
self.assertEqual(calls['events'][-1][0], 'profile_restore')
|
||||
|
||||
def missing_restore(name):
|
||||
calls['restored'] = name
|
||||
return {'ok': False, 'restored': [], 'error': 'not found'}
|
||||
mk8_profiles.restore = missing_restore
|
||||
status, result = server.h_profile_restore(
|
||||
_Ctx({'name': 'missing'}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(calls['events'][-1][0], 'profile_restore')
|
||||
self.assertEqual(calls['events'][-1][1].get('sev'), 'warn')
|
||||
|
||||
status, result = server.h_profile_restore(_Ctx({'name': ''}))
|
||||
self.assertEqual(status, 400)
|
||||
finally:
|
||||
(mk8_gate.enter, mk8_profiles.restore,
|
||||
mk8_events.log_event) = olds
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user