diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c988ab..4ca4533 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,11 +50,11 @@ These are not blocked, just reviewed more carefully. ## Workflow -1. Fork the repo (or, if you're a collaborator, push a feature branch directly to `KMX415/meshpoint`) -2. Create a branch from `main` +1. Fork the repo +2. Create a branch from main 3. Make your change 4. Test it -5. Open a pull request **targeting `KMX415/meshpoint:main`** +5. Open a pull request Example branch names: @@ -65,12 +65,6 @@ docs/setup refactor/config-loader ``` -When you open the PR from a fork, **leave "Allow edits from maintainers" checked** (GitHub's default). It lets the maintainer push small fixes (lint, rebases on top of newly-landed work) directly onto your PR branch without a back-and-forth review cycle. - -PRs land on `main` via **Squash and merge** by default, so each merged PR becomes one commit. Keep your PR commit message in the PR description (the merge UI uses it). - -If `main` moves while your PR is open and conflicts appear, rebase your branch on the new `main` and force-push to your fork. Don't merge `main` into your PR branch -- it produces a noisy history that breaks the squash-merge convention. - --- ## Pull request expectations @@ -134,15 +128,12 @@ Rules: ## Before opening a PR - Code builds -- Tests pass: `python -m pytest tests/ -q` -- Lint clean: `python -m ruff check src/ tests/` +- Tests pass (if present) - Docs updated if needed - Config changes documented - Hardware/region impact noted - PR description is clear -CI runs the same `ruff` + `pytest` jobs on every PR (see `.github/workflows/ci.yml`). PRs cannot merge until that check passes. - --- Meshpoint is evolving quickly. Process will stay simple for now. diff --git a/README.md b/README.md index 9f17983..4d86e3b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![GitHub stars](https://img.shields.io/github/stars/KMX415/meshpoint?style=flat&color=yellow)](https://github.com/KMX415/meshpoint/stargazers) [![GitHub issues](https://img.shields.io/github/issues/KMX415/meshpoint)](https://github.com/KMX415/meshpoint/issues) [![Last commit](https://img.shields.io/github/last-commit/KMX415/meshpoint)](https://github.com/KMX415/meshpoint/commits/main) -[![Version](https://img.shields.io/badge/version-0.7.3.1-orange.svg)](docs/CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-0.7.2-orange.svg)](docs/CHANGELOG.md) ### Meshradar Cloud Dashboard ![Meshradar Cloud Dashboard](Meshradar414.png) @@ -137,7 +137,7 @@ sudo meshpoint setup # interactive config wizard meshpoint status # verify everything is running ``` -Open `http://:8080` for the local dashboard. On first visit (and after upgrading from v0.7.2 or earlier) you'll be prompted to set an admin password at `/setup` (8-character minimum). After that, all dashboard access requires sign-in. If you forget the password, recover via SSH with `sudo meshpoint reset-password` -- the command prompts interactively, rotates the JWT secret, and invalidates any open browser sessions. +Open `http://:8080` for the local dashboard. > **First time?** The [Onboarding Guide](docs/ONBOARDING.md) walks through everything from flashing the SD card to verifying your first captured packets. @@ -210,33 +210,24 @@ sudo git pull origin main sudo systemctl restart meshpoint ``` -The local dashboard shows an orange update indicator when a new version is available. After every update, hard-refresh the dashboard tab (Ctrl+Shift+R / Cmd+Shift+R) so the browser pulls the new frontend JS instead of a cached copy. +The local dashboard shows an orange update indicator when a new version is available. -### Upgrading from v0.7.2 or earlier to v0.7.3 (one-time) +### Updating to v0.6.0 (one-time steps) -v0.7.3 adds local dashboard authentication and pulls in two new Python dependencies (`bcrypt`, `PyJWT`). `git pull` alone is not sufficient: the service will fail to start with `ModuleNotFoundError: No module named 'bcrypt'` (or `'jwt'`) until the venv is refreshed. Re-run `install.sh`: +v0.6.0 adds native TX support, which requires a one-time HAL recompile and two config files: ```bash cd /opt/meshpoint sudo git pull origin main -sudo bash scripts/install.sh +sudo bash /opt/meshpoint/scripts/patch_hal.sh +sudo cp config/sudoers-meshpoint /etc/sudoers.d/meshpoint +sudo chmod 440 /etc/sudoers.d/meshpoint +sudo cp scripts/meshpoint.service /etc/systemd/system/meshpoint.service +sudo systemctl daemon-reload sudo systemctl restart meshpoint ``` -After restart, open `http://:8080` and you'll be redirected to `/setup` to set an admin password (8-character minimum). All subsequent dashboard access requires sign-in. Forgot the password? `sudo meshpoint reset-password` from SSH. - -### Upgrading from v0.6.x or earlier (one-time) - -v0.7.0 ships the core modules as Python source instead of pre-compiled `.so` binaries. If your install predates v0.7.0, `git pull` alone is not sufficient: Python's import machinery would prefer the stale `.cpython-*.so` files over the new source and you'd silently keep running v0.6.x code. Re-run `install.sh` after pulling to clean them up: - -```bash -cd /opt/meshpoint -sudo git pull origin main -sudo bash scripts/install.sh -sudo systemctl restart meshpoint -``` - -`install.sh` is idempotent and also subsumes the older v0.6.0-era one-time steps (HAL TX sync word patch, sudoers rule, systemd service install) in the same pass, so this single command covers any path from v0.5.x or v0.6.x up to current. Future updates from v0.7.0 onward go back to plain `git pull` + `restart`. +`patch_hal.sh` patches the concentrator HAL for Meshtastic-compatible TX sync words and recompiles (takes about 2 minutes). The sudoers rule allows the dashboard to restart the service when you change settings. Both only need to run once. Future updates go back to `git pull` + `restart`. --- diff --git a/config/default.yaml b/config/default.yaml index e8deee2..8c26fba 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -50,7 +50,7 @@ device: longitude: 0.0 altitude: 25 hardware_description: "RAK2287 + Raspberry Pi 4" - firmware_version: "0.7.3.1" + firmware_version: "0.7.2" relay: enabled: false @@ -81,20 +81,6 @@ transmit: interval_minutes: 180 # 5..1440 min, default 180 (3 hr); 0 = disabled startup_delay_seconds: 60 # delay before first broadcast on boot -web_auth: - # First-run state: hashes are empty -> dashboard forces /setup. - # After setup, the admin hash is written via the API and the JWT - # secret is auto-generated. Rotating jwt_secret (e.g. via - # `meshpoint reset-password`) invalidates every active session. - admin_password_hash: "" - viewer_password_hash: "" - jwt_secret: "" - jwt_expiry_minutes: 60 - allow_read_only: false - lockout_attempts: 5 - lockout_cooldown_minutes: 5 - session_version: 1 - mqtt: enabled: false broker: "mqtt.meshtastic.org" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2894337..af44a10 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,32 +1,5 @@ # Changelog -### Unreleased - -Queued for the next version bump. Bullets in this section will be folded into the release header (and dated) when the version is cut. - -### v0.7.3.1 (May 13, 2026) - -Hotfix on top of v0.7.3 the same day. Reported by Willard on Discord ~3h after release: dashboard stuck on "Reconnecting..." with no data after upgrading. Two compounding bugs in the new auth path; a stale browser tab against an auth-required server is enough to trigger both. - -- **WS auth close frame now actually reaches the browser.** `src/api/server.py` was calling `await websocket.close(code=4401)` *before* `await websocket.accept()`, which causes Starlette to fail the WebSocket handshake with HTTP 403 instead of completing the handshake and sending a close frame. Browsers translate that to close code `1006` (Abnormal Closure) on the JS side, and our `frontend/js/websocket_client.js` only special-cases `4401` for the redirect-to-/login path -- so unauthenticated WS connections fell through to the generic reconnect loop and stuck forever. Fix: `accept()` first, then `close(code=WS_AUTH_CLOSE_CODE)`. Validated end-to-end on .141 (cookie cleared, dashboard refreshed → bounces to /login as intended). -- **Dashboard root (`/`) now redirects unauthenticated requests to `/login`.** The `StaticFiles(directory=..., html=True)` mount on `/` was serving `index.html` to everyone with no auth check, so a stale browser tab could load the new SPA JS and immediately fight the now-auth-required `/ws`. New explicit `@app.get("/")` route registered ahead of the static mount: 302s unauthenticated requests to `/login` (or `/setup` if no admin password is set yet), serves `index.html` for valid sessions. Static asset paths (`/css`, `/js`, `/assets`, etc.) still fall through to the existing mount. -- **Client defense in depth.** `frontend/js/websocket_client.js` now tracks whether the socket reached the `open` state. If `onclose` fires *without* a prior `onopen`, the handshake failed before the close frame could be delivered. The client probes an auth-gated endpoint (`/api/device/status`); the global 401 interceptor in `app.js` then handles the redirect if it's an auth-shaped failure, while real network blips fall through to the existing reconnect schedule. Belt-and-suspenders coverage so a future server-side regression in the close-code path can't strand users again. -- **Internal:** new `tests/test_dashboard_root_route.py` (unauthenticated GET `/` → 302 to `/login`; GET `/` with no admin password yet → 302 to `/setup`; GET `/` with valid cookie → 200 + index.html). New `tests/test_websocket_auth_close_code.py` asserts the WS handshake completes and the close frame's code is exactly `4401` for both no-cookie and bad-cookie cases (regression for the `accept()`-before-`close()` requirement). 403 tests passing, ruff clean. - -### v0.7.3 (May 13, 2026) - -Local-dashboard authentication, dashboard branding polish, and the second-leg phantom-node leak fix. Auth lands as a hard requirement on every Meshpoint: existing devices upgrading from v0.7.2 will be prompted to set an admin password the first time the dashboard is opened after the upgrade. Pure-Python where it counts; `install.sh` re-run picks up two new dependencies (`bcrypt`, `PyJWT`). - -- **Local dashboard authentication.** First-visit redirects to `/setup`, where you set an admin password (bcrypt-hashed, never stored in plaintext, never logged). Subsequent visits land on `/login`. Sessions are stateless JWTs in an HttpOnly + SameSite=Lax cookie; the JWT secret is auto-generated on the device and persisted to `local.yaml` only when `/setup` completes (a fresh-SD install with no admin password yet leaves `local.yaml` untouched, so the existing setup wizard's "Existing config found" detection still works). All `/api/*` routes, the dashboard pages, and the `/ws` WebSocket are now behind `Depends(require_auth)`; unauthenticated calls return 401 (HTTP) or close code 4401 (WebSocket) and the dashboard JS auto-redirects to `/login?next=...`. Failed-login lockout (`web_auth.lockout_attempts`, default 5; `web_auth.lockout_cooldown_minutes`, default 5) is per-username, in-memory, and surfaces a live countdown on the login page via the `Retry-After` header. Optional viewer role for read-only access via `web_auth.viewer_password_hash` + `web_auth.allow_read_only: true`. -- **`meshpoint reset-password` recovery.** New CLI command for the "I forgot the dashboard password" path. Hashes the new password, rotates `web_auth.jwt_secret`, bumps `web_auth.session_version`, and writes everything to `local.yaml` in one operation: every existing browser session is invalidated and the new credentials work immediately. Run via SSH (`sudo /opt/meshpoint/venv/bin/python -m src.cli.main reset-password `); requires no service restart. -- **Sign-out in the topbar.** Door-out icon at the far right of the dashboard header. Clicking it POSTs `/api/auth/logout`, the cookie is cleared, and the browser redirects to `/login`. Hover tints accent-cyan; safe under network failure (still redirects, the global 401 interceptor catches any lingering authenticated call). -- **Auth pages get the radar treatment.** `/setup` and `/login` ship a slowly rotating cyan sweep over a deep-navy radar disc with a live identity strip (device name, firmware version, online dot) so you can confirm you're talking to the right Meshpoint before entering credentials. Same `--bg-primary` / Inter / JetBrains Mono palette as the dashboard, single `auth-` BEM prefix, full reduced-motion support. The radar's blip layer is intentionally unwired in v0.7.3: blips are reserved for real concentrator RX events once a deliberately-public scrubbed feed lands in v0.7.4, rather than ship cosmetic randomness today. -- **Dashboard branding.** Topbar now carries the actual Meshpoint logo (40px, rounded-tile gradient mark) where the placeholder trigram glyph was, plus a 256x256 favicon used on `/`, `/setup`, and `/login`. iOS home-screen icon (`apple-touch-icon`) keeps the rounded-tile mark for nicer bookmark rendering. Establishes `frontend/assets/` as the canonical asset folder. -- **Phantom-node leak: drop `STAT_NO_CRC` and unknown-status packets at the HAL boundary.** v0.7.2 closed the `STAT_CRC_BAD` leg of the leak but `STAT_NO_CRC` packets still flowed into the decoder, where the random bytes after the LoRa header parsed as a "valid" Meshtastic packet and produced a phantom node row in the local SQLite (one packet, no name, no role, never heard again). Fleet diagnostics on a high-traffic Meshpoint (nopemesh, v0.7.2 baseline) measured 4 NO_CRC packets per 30 minutes alongside 108 CRC_BAD and 72 CRC_OK, with the resulting rows accumulating into a 92%-phantom local node table (~72k of ~78k total nodes). `SX1302Wrapper.receive()` now drops `STAT_NO_CRC` with a counted WARNING (`RX NO_CRC if=N sf? bw=? rssi=? snr=? size=? (total NO_CRC: N)`) and additionally drops any packet whose status is neither `CRC_OK`, `CRC_BAD`, nor `NO_CRC` so that future HAL revisions introducing a new status code cannot silently re-open the leak. New `no_crc_count` and `unknown_status_count` properties on the wrapper for observability. -- **Defense in depth: drop Meshtastic headers with `hop_limit > hop_start`.** A Meshtastic packet originates with `hop_limit == hop_start` and decrements `hop_limit` at each relay while `hop_start` stays fixed, so `hop_limit > hop_start` is mathematically impossible for an honestly-originated packet. `MeshtasticDecoder._parse_header()` now returns `None` for that combination so the corrupted bytes never reach the storage layer. Caught two of the five fresh phantoms on the kmax test Pi and four of four on nopemesh in fleet diagnostics, independent of the wrapper-level status filter. Zero false-positive risk by construction. -- **Hardware-validated three ways.** Fresh-SD install (Meshpoint-MNTD-RAKV2 .49) exercises the full `install.sh` path with `bcrypt` + `PyJWT` deps and the bootstrap → `/setup` → `local.yaml`-creation flow end to end. Upgrade from v0.7.2 (Sensecap M1) confirms the existing `local.yaml` is preserved untouched on service start, and the `web_auth` block is appended atomically when the user completes `/setup`. RAK V2 .141 confirms the upgrade-on-top-of-RC path. All three flows green; no phantom rows observed on the upgraded high-traffic device since the no-crc fix landed. -- **Internal:** new `requirements.txt` deps `bcrypt>=4.2.0` and `PyJWT>=2.10.0`. New `src/api/auth/` package: `password_hasher`, `jwt_session`, `lockout_tracker`, `auth_service`, `auth_bootstrap`, `dependencies`, `ws_guard`. New routes `src/api/routes/auth_routes.py` and `src/api/routes/identity_routes.py`. New CLI `src/cli/reset_password_command.py`. New frontend `frontend/auth/{setup,login}.html`, `frontend/css/auth.css`, `frontend/js/auth.js`, `frontend/js/signout_controller.js`. New tests: `test_password_hasher`, `test_jwt_session`, `test_lockout_tracker`, `test_auth_service`, `test_auth_dependencies`, `test_auth_routes`, `test_auth_page_serving`, `test_auth_bootstrap`, `test_identity_route`, `test_protected_router_wiring`, `test_reset_password_command`, plus the no-crc test additions in `test_sx1302_wrapper.py` and the new `test_meshtastic_decoder_header_validity.py`. `tests/test_relay_node_header.py` default `flags` byte moved from `0x03` (hop_limit=3, hop_start=0, structurally impossible) to `0x63` (hop_limit=3, hop_start=3, valid direct packet) so existing relay-node tests pass under the new validity check. 401 tests passing, ruff clean, bandit clean. - ### v0.7.2 (May 5, 2026) Two-fix bundle on top of v0.7.1. One small UX feature for hop-chain debugging, one quiet-but-important correctness fix that was inflating node counts on the cloud catalog and producing intermittent garbled-but-readable text on the local mesh. Both ride together because they touch the same RX path. Pure-Python, no recompile needed. diff --git a/docs/COMMON-ERRORS.md b/docs/COMMON-ERRORS.md index 34dafc1..05615be 100644 --- a/docs/COMMON-ERRORS.md +++ b/docs/COMMON-ERRORS.md @@ -95,50 +95,6 @@ loading the stale binaries from the previous release. `git pull` when crossing the v0.6.x to v0.7.0 boundary. The installer is idempotent and safe to re-run on any release. -### Service won't start after upgrading to v0.7.3 (`ModuleNotFoundError: No module named 'bcrypt'` or `'jwt'`) - -**Cause:** v0.7.3 added local dashboard authentication, which requires -two new Python dependencies (`bcrypt>=4.2.0` and `PyJWT>=2.10.0`). -`git pull` alone fetches the new source code but does not refresh the -venv -- the service then crashes at import time on the missing -modules. The dashboard never opens port 8080, so the symptom looks -like "dashboard unreachable after upgrade", not the v0.7.3.1 WS bug. - -The `docs/ONBOARDING.md#updating` and `README.md#updating` sections -were missing this gotcha through the v0.7.3.0 release; they were -patched the same day v0.7.3 shipped after a user hit it. - -**Fix:** Re-run `install.sh` to refresh the venv, then restart: - -```bash -cd /opt/meshpoint -sudo bash scripts/install.sh -sudo systemctl restart meshpoint -meshpoint status -``` - -`install.sh` is idempotent and reuses the existing venv, so this is -fast (no system-package re-install). After the service comes back up, -hard-refresh the dashboard tab (Ctrl+Shift+R / Cmd+Shift+R) and you -should be redirected to `/login` (or `/setup` if you cleared -`local.yaml` somehow). To confirm the missing-module symptom before -running the fix: - -```bash -sudo journalctl -u meshpoint -n 30 --no-pager | grep -iE "modulenotfound|importerror" -``` - -You should see one of: - -``` -ModuleNotFoundError: No module named 'bcrypt' -ModuleNotFoundError: No module named 'jwt' -``` - -Future updates inside the v0.7.3+ series go back to plain `git pull + -systemctl restart` unless a release explicitly notes new dependencies -in its CHANGELOG entry. - ### `install.sh` told me to reboot after an upgrade. Do I have to? **Pre-v0.7.1 only.** The install.sh on v0.7.0 always printed the @@ -287,56 +243,6 @@ antenna away from RF noise sources or run on a less-congested channel. The running `total CRC_BAD` counter in the warning resets on every service restart. -### Repeated WARN: `RX NO_CRC if=N sf? bw=? ...` or `RX unknown status=0xNN ...` - -**Cause:** The chip received a packet but the LoRa header CRC bit was off -(`NO_CRC`) or the chip returned a status code the wrapper does not -recognize (`unknown status`). On a Meshtastic-configured concentrator -(CRC always enabled in the outbound LoRa header by spec), `NO_CRC` -typically indicates corrupted bytes at the noise floor. Pre-v0.7.3 -these flowed into the decoder and produced phantom node rows in the -local SQLite (a one-packet entry with no name and no role, never heard -again). v0.7.3 drops them at the wrapper with these counted WARNINGs. - -**Fix:** No action needed; the WARNINGs are diagnostic, not actionable. -Counts running into the hundreds per hour suggest your antenna is sitting -in a high-RF-noise environment; the same antenna placement guidance as -for `CRC_BAD` applies. The counters reset on service restart. - -### Phantom nodes pre-v0.7.3 (`packet_count = 0`, no `long_name`) - -**Cause:** Meshpoints running v0.7.2 or earlier accepted `STAT_NO_CRC` -packets from the concentrator and produced phantom node rows in the -local `nodes` SQLite table. On low-traffic Meshpoints this typically -adds tens to hundreds of phantoms over a week. On high-traffic -Meshpoints it can grow into the tens of thousands and dominate the -node table (one production v0.7.2 Meshpoint reached ~72k phantoms out -of ~78k total nodes before the fix shipped). - -**Fix:** Update to v0.7.3 or later to stop the bleed. To clean out -existing phantom rows accumulated under earlier versions, the safest -filter waits 7 days (a real one-shot lurker would have either sent -another packet by then or genuinely vanished, so deletion is safe): - -```bash -sudo python3 -c " -import sqlite3 -con = sqlite3.connect('/opt/meshpoint/data/concentrator.db') -n = con.execute(''' - DELETE FROM nodes - WHERE packet_count = 0 - AND long_name IS NULL - AND julianday(last_heard) < julianday(\"now\", \"-7 days\") -''').rowcount -con.commit() -print(f'Removed {n} stale phantom node row(s).') -" -sudo systemctl restart meshpoint -``` - -The cloud-side phantom rows in DynamoDB age out automatically via the -30-day TTL once edge devices stop pushing them. - ### `Chip version 0x00` **Cause:** Concentrator is not responding on SPI. Either not seated, SPI @@ -531,100 +437,6 @@ interfaces. `127.0.0.1` only allows access from the Pi itself. --- -## Authentication - -### Dashboard redirects to `/setup` after upgrade - -**Cause:** v0.7.3 added local dashboard authentication. Every Meshpoint -upgrading from v0.7.2 or earlier hits `/setup` once on the first browser -visit after the upgrade, where you set an admin password. This is -expected and one-time per device. - -**Fix:** Set a password (8-character minimum, no charset complexity -required, max 256) and continue. The password is bcrypt-hashed into -`web_auth.admin_password_hash` in `local.yaml`; subsequent visits land -on `/login`. Sessions last 24 hours by default -(`web_auth.session_ttl_hours`). - -If you would rather not see the prompt today, downgrade to v0.7.2 -(`git checkout v0.7.2 && sudo /opt/meshpoint/scripts/install.sh`). -Disabling auth in v0.7.3 is not supported on purpose: there is no -read-only fallback for an unauthenticated dashboard. - -### Locked out after too many failed login attempts - -**Cause:** Five consecutive bad password attempts within five minutes -(default `web_auth.lockout_attempts: 5`, -`web_auth.lockout_cooldown_minutes: 5`) trip a per-username in-memory -lockout. The login page shows a live countdown driven by the -`Retry-After` header on the 429 response. - -**Fix:** Wait out the countdown (default 5 minutes) and try again. -Restarting the service (`sudo systemctl restart meshpoint`) also clears -the lockout because the tracker is in-memory only. If you genuinely -forgot the password, use `meshpoint reset-password` instead of -brute-forcing it. - -### Forgot the dashboard password (`meshpoint reset-password`) - -**Cause:** No password recovery email, no security questions: the -admin password is stored only as a bcrypt hash and there is no way to -read it back. v0.7.3 ships a host-level recovery CLI that you run from -SSH. - -**Fix:** SSH into the Pi and run: - -```bash -sudo meshpoint reset-password -``` - -The command prompts twice for the new password (8-character minimum), -hashes it, rotates `web_auth.jwt_secret`, bumps -`web_auth.session_version` (which invalidates every existing browser -session), and writes everything to `local.yaml` atomically. No service -restart required. Open `/login` and sign in with the new password. - -If you also lost SSH access, the only path forward is to re-image the -SD card and re-run `meshpoint setup`. There is no way to recover an -admin password without host-level access by design. - -### Setup wizard says "Existing config/local.yaml found" on a fresh SD - -**Cause:** Pre-v0.7.3 RC builds eagerly persisted the auto-generated -`web_auth.jwt_secret` to `local.yaml` on first service start, before -the user had set a password. The setup wizard then saw the file and -warned about overwriting an "existing" config, even on a brand-new SD. - -**Fix:** Update to v0.7.3 (or any commit at or after the -`fix(auth): defer jwt_secret persist to /setup` commit). The bootstrap -now keeps the secret in memory until `/setup` actually completes; -`local.yaml` stays absent on a fresh install. If you already have a -polluted `local.yaml` from an RC build, delete it before re-running -the wizard: - -```bash -sudo systemctl stop meshpoint -sudo rm -f /opt/meshpoint/config/local.yaml -sudo systemctl start meshpoint -sudo meshpoint setup -``` - -### `4401` close code on the WebSocket / dashboard kicked back to `/login` - -**Cause:** Your session cookie expired, was rotated by a -`reset-password` run, or the JWT failed verification (algorithm pinned -to HS256, signed with `web_auth.jwt_secret`). v0.7.3 maps WebSocket -auth failures to close code 4401 and the dashboard's WS client -auto-redirects to `/login?next=/`. - -**Fix:** Sign in again. If it happens repeatedly without an idle -session in between, check `meshpoint logs | grep -i jwt` for clock -skew or secret-rotation events. A common trigger is two browsers -sharing a session where one ran `reset-password` -- expected behavior, -the other browser will get bumped. - ---- - ## MeshCore companion ### MeshCore companion not receiving packets @@ -769,6 +581,16 @@ Most-common silent failure: missing `paho-mqtt` package after a `git pull` that did not re-run `pip install`. See [Configuration > MQTT](CONFIGURATION.md#mqtt-feed) for full configuration reference. +Quick checks when `started as` appears but publish lines do not: + +```bash +sudo grep -A20 '^mqtt:' /opt/meshpoint/config/local.yaml +``` + +Confirm `publish_channels` includes at least one active channel (commonly +`LongFast`) while testing. Private-channel packets remain blocked unless +you explicitly allow those channel names. + ### MQTT topics show `chXX` instead of `LongFast` **Cause:** Pre-v0.6.2 bug. The channel hash was used in the topic instead diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ad0e736..1654564 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -338,8 +338,8 @@ mqtt: port: 1883 # broker port username: "meshdev" # broker credentials password: "large4cats" - topic_root: "msh" # MQTT topic prefix - region: "US" # used in topic path + topic_root: "msh" # MQTT prefix root (can include path segments) + region: "US" # topic path region segment (can be hierarchical: US/FL) publish_channels: # Gate 2: only these channels are published - "LongFast" publish_json: false # also publish JSON on /json/ topic @@ -347,6 +347,21 @@ mqtt: homeassistant_discovery: false # publish HA auto-discovery configs ``` +Topic path prefix composition is: + +```text +//2/... +``` + +Examples: + +- `topic_root: "msh"`, `region: "US"` -> `msh/US/2/...` +- `topic_root: "msh"`, `region: "US/FL"` -> `msh/US/FL/2/...` +- `topic_root: "msh/US"`, `region: "FL"` -> `msh/US/FL/2/...` + +If `topic_root` already ends with the same `region`, Meshpoint avoids +duplicating that segment. + ### Location Precision Control how much location detail leaves the device via MQTT: diff --git a/docs/FAQ.md b/docs/FAQ.md index a184965..4bc6ef8 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -198,11 +198,9 @@ sudo /opt/meshpoint/venv/bin/pip install -r requirements.txt sudo systemctl restart meshpoint ``` -If you are upgrading from v0.6.x or earlier for the first time, re-run -`install.sh` instead of just restarting (it cleans up stale `.cpython-*.so` -binaries from pre-v0.7.0 installs and re-applies HAL patches, sudoers, -and the systemd service in one idempotent pass). See -[README > Upgrading from v0.6.x or earlier](../README.md#upgrading-from-v06x-or-earlier-one-time). +If you are upgrading from v0.5.x to v0.6.x for the first time, also run +the one-time HAL recompile and sudoers steps documented in +[README > Updating to v0.6.0](../README.md#updating-to-v060-one-time-steps). --- diff --git a/docs/MQTT-AND-MESHRADAR.md b/docs/MQTT-AND-MESHRADAR.md index 3cdc7e1..85da125 100644 --- a/docs/MQTT-AND-MESHRADAR.md +++ b/docs/MQTT-AND-MESHRADAR.md @@ -138,6 +138,35 @@ the most common cause is the missing `paho-mqtt` package after a [Common Errors > MQTT enabled but no traffic on the broker](COMMON-ERRORS.md#mqtt-enabled-but-no-traffic-on-the-broker-no-mqtt-lines-in-logs) for the full diagnostic table. +Expected healthy snippet: + +```text +MQTT publisher started as !9A1BCDEF +MQTT pub rc=0 topic=msh/US/2/e/LongFast/!9A1BCDEF +MQTT pub rc=0 topic=msh/US/2/e/LongFast/!C001D00D +``` + +If you need a hierarchical region/topic path (for example `msh/US/FL`), +either set: + +```yaml +mqtt: + topic_root: "msh" + region: "US/FL" +``` + +or split it across both keys: + +```yaml +mqtt: + topic_root: "msh/US" + region: "FL" +``` + +If startup appears but there are no `MQTT pub rc=0` lines, your +`mqtt.publish_channels` allowlist is filtering all observed traffic. +Temporarily include `LongFast` while validating the pipeline end-to-end. + ### Publishing private channels (your own broker) If you want to publish a private channel into your own MQTT broker diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index acc70d5..bd8091d 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -350,35 +350,22 @@ sudo systemctl restart meshpoint The local dashboard shows an orange update indicator when a new version is available on GitHub. -> **Hard-refresh the browser after every update.** The dashboard SPA is heavily cached. After `systemctl restart meshpoint`, press Ctrl+Shift+R (Cmd+Shift+R on macOS) on each open dashboard tab so the browser pulls the new frontend JS instead of the stale copy. Skipping this is the most common cause of "looks broken after upgrade" reports. +### Updating to v0.6.0 (one-time steps) -### Upgrading from v0.7.2 or earlier to v0.7.3 (one-time) - -v0.7.3 adds local dashboard authentication, which requires two new Python dependencies (`bcrypt` and `PyJWT`). A bare `git pull` does **not** install them: the service will fail to start with `ModuleNotFoundError: No module named 'bcrypt'` (or `'jwt'`) until the venv is refreshed. Re-run `install.sh` after pulling: - -```bash -cd /opt/meshpoint -sudo git pull origin main -sudo bash scripts/install.sh -sudo systemctl restart meshpoint -``` - -`install.sh` is idempotent: it reuses the existing venv, just runs `pip install -r requirements.txt` to pick up the new wheels, and reinstalls the systemd unit. After restart, open `http://:8080` in your browser and you'll be redirected to `/setup` to set an admin password (8-character minimum). All subsequent dashboard access requires sign-in. If you forget the password later, recover via SSH: `sudo meshpoint reset-password`. - -Once you're on v0.7.3 or later, future minor updates inside the v0.7.x series go back to plain `git pull + restart` unless a release explicitly notes new dependencies. - -### Upgrading from v0.6.x or earlier (one-time) - -v0.7.0 ships the core modules as Python source instead of pre-compiled `.so` binaries. If your install predates v0.7.0, `git pull` alone is not sufficient: Python's import machinery would prefer the stale `.cpython-*.so` files over the new source and you'd silently keep running v0.6.x code. Re-run `install.sh` after pulling to clean them up: +v0.6.0 adds native TX support, which requires a one-time HAL recompile and two config files. Run these after `git pull`: ```bash cd /opt/meshpoint sudo git pull origin main -sudo bash scripts/install.sh +sudo bash /opt/meshpoint/scripts/patch_hal.sh +sudo cp config/sudoers-meshpoint /etc/sudoers.d/meshpoint +sudo chmod 440 /etc/sudoers.d/meshpoint +sudo cp scripts/meshpoint.service /etc/systemd/system/meshpoint.service +sudo systemctl daemon-reload sudo systemctl restart meshpoint ``` -`install.sh` is idempotent and also subsumes the older v0.6.0-era one-time steps (HAL TX sync word patch, sudoers rule, systemd service install) in the same pass, so this single command covers any path from v0.5.x or v0.6.x up to current. Future updates from v0.7.0 onward go back to plain `git pull` + `restart`. +`patch_hal.sh` patches the concentrator HAL for Meshtastic-compatible TX sync words and recompiles (about 2 minutes). The sudoers rule allows the dashboard to restart the service when you change settings. The updated service file auto-fixes config directory permissions on startup. All one-time: future updates go back to `git pull` + `restart`. A reboot ensures all changes take effect cleanly (kernel modules, SPI state, MeshCore companion). Reboots are safe: the systemd service holds the concentrator in reset during shutdown to prevent SPI bus latch. diff --git a/docs/RADIO-CONFIG-EXPLAINED.md b/docs/RADIO-CONFIG-EXPLAINED.md index bc7aaf4..c6049f1 100644 --- a/docs/RADIO-CONFIG-EXPLAINED.md +++ b/docs/RADIO-CONFIG-EXPLAINED.md @@ -346,11 +346,11 @@ radio: preamble_length: 16 ``` -`sync_word: 0x2B` is the Meshtastic standard. `scripts/patch_hal.sh` -(invoked automatically by `install.sh`) patches the libloragw HAL to -use this sync word for both RX and TX. **Do not change** unless you -know exactly why. Changing it will make your Meshpoint invisible to -the public mesh. +`sync_word: 0x2B` is the Meshtastic standard. The `scripts/patch_hal.sh` +step in the v0.6.0 update specifically patches the libloragw HAL to use +this sync word for both RX and TX. **Do not change** unless you know +exactly why. Changing it will make your Meshpoint invisible to the +public mesh. `preamble_length: 16` is the Meshtastic standard. Same advice. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3c2c561..fe20b4d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -61,7 +61,7 @@ The SX1250's digital SPI interface can recover while the RF receive path remains ### TX not working (messages not received by other nodes) 1. Verify TX is enabled in the Radio settings page on the dashboard -2. Confirm the HAL TX sync word patch was applied (this happens automatically when you run `install.sh`): `meshpoint logs | grep -i "tx\|transmit"`. If TX is silently failing, re-run `sudo bash /opt/meshpoint/scripts/install.sh` to re-apply the patch idempotently. +2. Check that `patch_hal.sh` was run after install: `meshpoint logs | grep -i "tx\|transmit"` 3. Verify the modem preset matches the mesh network you're targeting (e.g. LongFast) 4. Check that the antenna is connected: transmitting without an antenna damages the radio diff --git a/frontend/assets/favicon.png b/frontend/assets/favicon.png deleted file mode 100644 index c79a80d..0000000 Binary files a/frontend/assets/favicon.png and /dev/null differ diff --git a/frontend/assets/meshpoint-logo.png b/frontend/assets/meshpoint-logo.png deleted file mode 100644 index 0635043..0000000 Binary files a/frontend/assets/meshpoint-logo.png and /dev/null differ diff --git a/frontend/assets/meshpoint-mark.svg b/frontend/assets/meshpoint-mark.svg new file mode 100644 index 0000000..9156ae5 --- /dev/null +++ b/frontend/assets/meshpoint-mark.svg @@ -0,0 +1,5 @@ + diff --git a/frontend/auth/login.html b/frontend/auth/login.html deleted file mode 100644 index 043aa1d..0000000 --- a/frontend/auth/login.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - Meshpoint Sign In - - - - - - - - - - -
-
-
- - - - - - - - - -
-
-
- -
-
Meshpoint
-
- v... - . - . Online -
-
- -
-
- -
-
- -
- - - - -
- -
- Forgot it? - ssh pi@<your-meshpoint-ip> - sudo meshpoint reset-password -
-
- - -
- - - - diff --git a/frontend/auth/setup.html b/frontend/auth/setup.html deleted file mode 100644 index 9525eda..0000000 --- a/frontend/auth/setup.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - Meshpoint Setup - - - - - - - - - - -
-
-
- - - - - - - - - -
-
-
- -
-
First-time setup
-
- Set an admin password.
- You'll need this every time you open the dashboard. -
-
- -
-
Meshpoint
-
- v... - . - . Online -
-
- -
-
- -
-
- -
- -
-
- - At least 8 characters -
-
- - Both passwords match -
-
- - Stored hashed (bcrypt) on this device only -
-
- - - - -
-
- - -
- - - - diff --git a/frontend/css/auth.css b/frontend/css/auth.css deleted file mode 100644 index b3c9f3f..0000000 --- a/frontend/css/auth.css +++ /dev/null @@ -1,374 +0,0 @@ -/* ---------------------------------------------------------------- - * Meshpoint local-dashboard auth pages (/setup and /login). - * - * Design-system compliance: - * - Reuses canonical tokens declared in dashboard.css (no new - * :root, no new fonts). - * - Single component prefix `auth-` (BEM). - * - Cyan/green over deep navy palette only. - * - Boot-in fade is the personality detail for these pages, - * reinforced by the radar sweep and the live identity strip. - * ---------------------------------------------------------------- */ - -html.auth-shell, html.auth-shell body { - height: 100vh; - overflow: hidden; -} - -body.auth-body { - background: var(--bg-primary); - color: var(--text-primary); - font-family: var(--font-sans); -} - -/* ---- Stage + card ---- */ -.auth-stage { - position: relative; - z-index: 1; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 2rem 1rem; - gap: 1.75rem; - animation: auth-boot 480ms ease-out both; -} - -@keyframes auth-boot { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } -} - -.auth-card { - width: 100%; - max-width: 440px; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 1.5rem; -} - -/* ---- Radar visual ---- */ -.auth-radar { - width: 280px; - height: 280px; - margin: 0 auto; - position: relative; - border-radius: 50%; -} - -.auth-radar__rings { - position: absolute; - inset: 0; - pointer-events: none; -} - -.auth-radar__sweep { - position: absolute; - inset: 0; - border-radius: 50%; - background: conic-gradient( - from 0deg, - rgba(6, 182, 212, 0.18) 0deg, - rgba(6, 182, 212, 0.10) 30deg, - rgba(6, 182, 212, 0.04) 60deg, - rgba(6, 182, 212, 0.0) 90deg, - rgba(6, 182, 212, 0.0) 360deg - ); - animation: auth-radar-rotate 6s linear infinite; - transform-origin: center; - mask: radial-gradient(circle at center, black 0%, black 99%, transparent 100%); - -webkit-mask: radial-gradient(circle at center, black 0%, black 99%, transparent 100%); -} - -@keyframes auth-radar-rotate { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -.auth-radar__center { - position: absolute; - top: 50%; - left: 50%; - width: 6px; - height: 6px; - background: var(--accent-green); - border-radius: 50%; - transform: translate(-50%, -50%); - box-shadow: 0 0 6px var(--accent-green), 0 0 14px rgba(0, 229, 160, 0.5); - z-index: 2; -} - -.auth-radar__center::after { - content: ''; - position: absolute; - inset: -8px; - border-radius: 50%; - border: 1px solid rgba(0, 229, 160, 0.4); - animation: auth-radar-pulse 2.4s ease-out infinite; -} - -@keyframes auth-radar-pulse { - 0% { transform: scale(0.5); opacity: 1; } - 100% { transform: scale(2.5); opacity: 0; } -} - -/* ---- Identity strip ---- */ -.auth-identity { - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); - padding: 0.65rem 0; - text-align: center; - font-family: var(--font-mono); - line-height: 1.6; -} - -.auth-identity__name { - font-size: 0.8rem; - color: var(--text-primary); - font-weight: 600; - letter-spacing: 0.02em; -} - -.auth-identity__meta { - font-size: 0.7rem; - color: var(--text-muted); -} - -.auth-identity__val { color: var(--accent-cyan); } -.auth-identity__live { color: var(--accent-green); } - -.auth-identity__sep { - color: var(--text-muted); - margin: 0 0.4rem; - opacity: 0.6; -} - -/* ---- Heading + copy ---- */ -.auth-heading { - text-align: center; - margin-bottom: -0.25rem; -} - -.auth-heading__title { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent-cyan); - margin-bottom: 0.35rem; -} - -.auth-heading__sub { - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.45; -} - -/* ---- Form ---- */ -.auth-form { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.auth-input-wrap { position: relative; } - -.auth-input { - width: 100%; - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text-primary); - font-family: var(--font-mono); - font-size: 0.85rem; - padding: 0.7rem 0.9rem; - outline: none; - transition: border-color 0.15s, box-shadow 0.15s; -} - -.auth-input:focus { - border-color: var(--accent-cyan); - box-shadow: var(--glow-cyan); -} - -.auth-input--error { - border-color: var(--accent-red); - animation: auth-input-shake 0.35s ease-out; -} - -@keyframes auth-input-shake { - 0%, 100% { transform: translateX(0); } - 20%, 60% { transform: translateX(-6px); } - 40%, 80% { transform: translateX(6px); } -} - -.auth-error { - font-family: var(--font-mono); - font-size: 0.7rem; - color: var(--accent-red); - min-height: 1rem; - margin-top: -0.25rem; - padding-left: 0.2rem; - line-height: 1.5; -} - -/* ---- Setup-only password rules ---- */ -.auth-rules { - display: flex; - flex-direction: column; - gap: 0.3rem; - padding: 0.25rem 0.2rem 0; -} - -.auth-rule { - font-size: 0.7rem; - color: var(--text-muted); - display: flex; - align-items: center; - gap: 0.5rem; - transition: color 0.2s; -} - -.auth-rule__dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--text-muted); - opacity: 0.5; - transition: background 0.2s, opacity 0.2s, box-shadow 0.2s; - flex-shrink: 0; -} - -.auth-rule--ok { color: var(--accent-green); } -.auth-rule--ok .auth-rule__dot { - background: var(--accent-green); - opacity: 1; - box-shadow: 0 0 5px var(--accent-green); -} - -/* ---- Submit button ---- */ -.auth-button { - width: 100%; - padding: 0.85rem; - background: var(--accent-cyan); - color: var(--bg-primary); - border: none; - border-radius: var(--radius); - font-family: var(--font-sans); - font-size: 0.85rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - transition: box-shadow 0.15s, opacity 0.15s, background 0.15s, transform 0.05s; -} - -.auth-button:hover:not(:disabled) { - box-shadow: 0 0 14px rgba(6, 182, 212, 0.55); -} - -.auth-button:active:not(:disabled) { - transform: translateY(1px); -} - -.auth-button:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.auth-button--loading .auth-button__label, -.auth-button--loading .auth-button__arrow { display: none; } -.auth-button--loading .auth-button__dots { display: inline-flex; } - -.auth-button__dots { display: none; gap: 0.4rem; } - -.auth-button__dots span { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--bg-primary); - animation: auth-dot-pulse 1.1s ease-in-out infinite; -} - -.auth-button__dots span:nth-child(2) { animation-delay: 0.2s; } -.auth-button__dots span:nth-child(3) { animation-delay: 0.4s; } - -@keyframes auth-dot-pulse { - 0%, 80%, 100% { opacity: 0.3; } - 40% { opacity: 1; } -} - -/* ---- Recovery / SSH hint ---- */ -.auth-recovery { - border-top: 1px dashed var(--border); - padding-top: 0.85rem; - margin-top: 0.25rem; - text-align: center; - font-family: var(--font-mono); - font-size: 0.7rem; - color: var(--text-muted); - line-height: 1.55; -} - -.auth-recovery__label { - display: block; - color: var(--text-secondary); - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 0.4rem; -} - -.auth-recovery__cmd { - display: block; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--accent-cyan); - padding: 0.4rem 0.6rem; - margin: 0.15rem auto; - width: fit-content; - max-width: 100%; - overflow-x: auto; - white-space: nowrap; - font-size: 0.7rem; -} - -/* ---- Footer ---- */ -.auth-footer { - text-align: center; - font-family: var(--font-mono); - font-size: 0.65rem; - color: var(--text-muted); - letter-spacing: 0.04em; - z-index: 1; -} - -.auth-footer__sep { margin: 0 0.5rem; opacity: 0.6; } - -.auth-footer a { - color: var(--text-muted); - text-decoration: none; - border-bottom: 1px dotted var(--text-muted); -} - -.auth-footer a:hover { - color: var(--accent-cyan); - border-bottom-color: var(--accent-cyan); -} - -/* ---- Reduced motion respect ---- */ -@media (prefers-reduced-motion: reduce) { - .auth-radar__sweep, - .auth-radar__center::after, - .auth-button__dots span, - .auth-stage { - animation: none !important; - } -} diff --git a/frontend/css/dashboard.css b/frontend/css/dashboard.css index 0cab9c3..d02ad83 100644 --- a/frontend/css/dashboard.css +++ b/frontend/css/dashboard.css @@ -54,11 +54,8 @@ html, body { } .top-bar__icon { - width: 40px; - height: 40px; - border-radius: 8px; - display: block; - flex-shrink: 0; + font-size: 1.5rem; + color: var(--accent-cyan); } .top-bar__brand h1 { @@ -147,30 +144,6 @@ html, body { animation: updatePulse 2s ease-in-out infinite; } -.top-bar__signout { - background: none; - border: none; - padding: 0.2rem 0.3rem; - cursor: pointer; - color: var(--text-muted); - display: inline-flex; - align-items: center; - border-radius: var(--radius-sm); - transition: color 0.15s, background 0.15s; -} - -.top-bar__signout:hover, -.top-bar__signout:focus-visible { - color: var(--accent-cyan); - background: rgba(6, 182, 212, 0.08); - outline: none; -} - -.top-bar__signout:disabled { - opacity: 0.4; - cursor: not-allowed; -} - .hidden { display: none !important; } @keyframes updatePulse { @@ -503,6 +476,37 @@ html, body { border-radius: 8px; } +.packet-header__controls { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.packet-filter-label { + font-size: 0.62rem; + color: var(--text-muted); + letter-spacing: 0.04em; +} + +.packet-filter-select { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.12rem 0.35rem; + font-size: 0.7rem; + font-family: var(--font-mono); +} + +.packet-filter-select:focus { + outline: none; + border-color: var(--accent-cyan); +} + +.packet-row--hidden { + display: none; +} + @keyframes packetFlash { 0% { background: rgba(6, 182, 212, 0.1); } 100% { background: transparent; } diff --git a/frontend/css/meshpoint-compliant.css b/frontend/css/meshpoint-compliant.css new file mode 100644 index 0000000..1df62c7 --- /dev/null +++ b/frontend/css/meshpoint-compliant.css @@ -0,0 +1,236 @@ +/* Shell enhancements from meshpoint-compliant reference (tokens: dashboard.css :root) */ + +.top-bar.top-bar--compliant { + min-height: 52px; + padding: 0 20px; + gap: 12px; + flex-wrap: wrap; + row-gap: 8px; +} + +.top-bar--compliant .top-bar__hybrid-mid { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1 1 200px; + min-width: 0; + flex-wrap: wrap; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-secondary); +} + +.top-bar--compliant .top-bar__signout { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 6px; + margin: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.top-bar--compliant .top-bar__signout:hover, +.top-bar--compliant .top-bar__signout:focus-visible { + color: var(--text-primary); + border-color: var(--accent-cyan); + background: rgba(6, 182, 212, 0.06); + outline: none; +} + +.top-bar--compliant .top-bar__brand { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-mono); + font-size: 1.05rem; + font-weight: 600; + color: var(--text-primary); + letter-spacing: 0.04em; + margin-right: 4px; +} + +.top-bar__heartbeat { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-cyan); + box-shadow: var(--glow-cyan); + animation: hb-pulse 2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes hb-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.3; transform: scale(0.6); } +} + +.top-bar--compliant .top-bar__brand-title { + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0.04em; + margin: 0; + font-family: var(--font-mono); +} + +.top-bar__radio { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-muted); + padding-left: 16px; + border-left: 1px solid var(--border); + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.top-bar__stats { + display: flex; + gap: 22px; + margin-left: auto; + align-items: center; + flex-shrink: 0; +} + +/* Column layout only for stat cards in the metrics cluster (not status-row badges). */ +.top-bar__stats > .top-bar__stat { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.top-bar__stat-value { + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 600; + color: var(--accent-cyan); +} + +.top-bar__stat-label { + font-size: 0.6rem; + font-weight: 400; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.top-bar__pill { + display: flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 20px; + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.05em; + border: 1px solid; +} + +.top-bar__pill::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: hb-pulse 1.6s ease-in-out infinite; +} + +.top-bar__pill--live { + background: rgba(0, 229, 160, 0.06); + border-color: rgba(0, 229, 160, 0.3); + color: var(--accent-green); +} + +.top-bar__pill--warn { + background: rgba(245, 158, 11, 0.06); + border-color: rgba(245, 158, 11, 0.3); + color: var(--accent-amber); +} + +.top-bar__pill--err { + background: rgba(239, 68, 68, 0.06); + border-color: rgba(239, 68, 68, 0.3); + color: var(--accent-red); +} + +/* System tab fills stage; drop outer padding so sub-nav aligns with shell */ +#tab-system.tab-content { + padding: 0; +} + +/* System tab sub-navigation */ +.sys-nav { + display: flex; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 0 16px; + flex-shrink: 0; +} + +.sys-nav__btn { + padding: 0 14px; + height: 36px; + font-family: var(--font-sans); + font-size: 0.78rem; + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + transition: color 0.1s, border-color 0.1s; + cursor: pointer; +} + +.sys-nav__btn:hover { + color: var(--text-primary); +} + +.sys-nav__btn--active { + color: var(--accent-cyan); + border-bottom-color: var(--accent-cyan); +} + +.sys-panel { + display: none; + flex: 1; + overflow-y: auto; + flex-direction: column; + min-height: 0; +} + +.sys-panel--active { + display: flex; +} + +.sys-panel-placeholder { + padding: 1.25rem 1rem; + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.5; + max-width: 42rem; +} + +/* Scrollbar (matches compliant reference) */ +::-webkit-scrollbar { + width: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} diff --git a/frontend/css/meshradar.css b/frontend/css/meshradar.css new file mode 100644 index 0000000..4ab40bc --- /dev/null +++ b/frontend/css/meshradar.css @@ -0,0 +1,499 @@ +/* ---- Meshradar local dashboard shell (composes design tokens + BEM ms-*) ---- */ + +.ms-root { + display: flex; + flex-direction: column; + height: 100vh; + min-height: 0; + overflow: hidden; +} + +/* Shell tab bar: canonical tokens over messaging.css fallbacks */ +.ms-root > .tab-bar { + flex-shrink: 0; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.ms-root > .tab-bar .tab-bar__btn:hover { + color: var(--text-primary); +} + +.ms-root > .tab-bar .tab-bar__btn--active { + color: var(--accent-cyan); + border-bottom-color: var(--accent-cyan); + box-shadow: none; +} + +.ms-workspace { + display: flex; + flex: 1; + min-height: 0; + gap: 0; +} + +.ms-rail { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + min-height: 0; +} + +.ms-rail__section { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + padding: 0.65rem 0.85rem 0.35rem; +} + +.ms-rail__section--spaced { + margin-top: 0.35rem; +} + +.ms-node-source { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0 0.85rem 0.5rem; +} + +.ms-rail__list { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.ms-rail__footer { + flex-shrink: 0; + border-top: 1px solid var(--border); + padding: 0.5rem 0.75rem; + font-size: 0.72rem; +} + +.ms-rail__hint { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-muted); + padding: 0.35rem 0.75rem 0.5rem; + border-top: 1px solid var(--border); + line-height: 1.35; +} + +.ms-stage { + flex: 1; + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: 1fr minmax(240px, 300px); +} + +.ms-stage--single { + grid-template-columns: 1fr; +} + +.ms-stage--single .ms-detail { + display: none; +} + +.ms-stage-main { + position: relative; + min-width: 0; + min-height: 0; + background: var(--bg-primary); +} + +.ms-stage-main > .tab-content { + position: absolute; + inset: 0; + display: none; + overflow: hidden; + flex-direction: column; + padding: 0.5rem; + box-sizing: border-box; +} + +.ms-stage-main > .tab-content.tab-content--active { + display: flex; + height: 100%; + max-height: 100%; +} + +.ms-fill { + flex: 1; + min-height: 0; + height: 100%; +} + +#topo-wrap { + position: relative; + flex: 1; + min-height: 0; + background: var(--bg-primary); +} + +#topoCanvas { + display: block; + width: 100%; + height: 100%; +} + +.ms-detail { + min-height: 0; + display: flex; + flex-direction: column; + background: var(--bg-primary); + border-left: 1px solid var(--border); +} + +.ms-detail .panel { + height: 100%; + min-height: 0; +} + +.ms-detail__hint { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 400; + color: var(--text-muted); +} + +.ms-detail-empty { + padding: 1.25rem 0.85rem; + color: var(--text-muted); + font-size: 0.8rem; +} + +.ms-detail-hero { + padding: 0.75rem 0.85rem; + border-bottom: 1px solid var(--border); +} + +.ms-detail-hero__title { + font-family: var(--font-mono); + font-size: 1.1rem; + font-weight: 600; + color: var(--accent-cyan); +} + +.ms-detail-hero__meta { + font-size: 0.68rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +.ms-detail-body { + padding: 0.75rem 0.85rem; +} + +.ms-detail-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.35rem; +} + +.ms-detail-row__key { + font-size: 0.75rem; + color: var(--text-muted); +} + +.ms-detail-row__val { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-primary); +} + +/* Node list (rail) */ +.ms-node-entry { + padding: 0.45rem 0.75rem; + cursor: pointer; + border-left: 2px solid transparent; +} + +.ms-node-entry:hover { + background: rgba(6, 182, 212, 0.06); +} + +.ms-node-entry--selected { + background: rgba(6, 182, 212, 0.08); + border-left-color: var(--accent-cyan); +} + +.ms-node-entry__line { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ms-node-entry__line--meta { + margin-top: 0.15rem; +} + +.ms-node-callsign { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-primary); + font-weight: 500; +} + +.ms-node-source-pill { + display: inline-block; + margin-left: 0.35rem; + padding: 0.05rem 0.35rem; + border-radius: var(--radius-sm); + font-size: 0.55rem; + font-family: var(--font-mono); + font-weight: 600; + line-height: 1.35; + border: 1px solid transparent; + vertical-align: middle; +} + +.ms-node-source-pill--rf { + background: rgba(0, 229, 160, 0.1); + color: var(--accent-green); + border-color: rgba(0, 229, 160, 0.25); +} + +.ms-node-source-pill--mqtt { + background: rgba(59, 130, 246, 0.12); + color: var(--accent-blue); + border-color: rgba(59, 130, 246, 0.25); +} + +.ms-node-source-pill--unknown { + background: rgba(245, 158, 11, 0.1); + color: var(--accent-amber); + border-color: rgba(245, 158, 11, 0.22); +} + +.ms-node-meta { + font-size: 0.65rem; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Connection status */ +.ms-conn-pill { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.65rem; + font-weight: 600; + font-family: var(--font-mono); + letter-spacing: 0.04em; + border: 1px solid var(--border); + margin-left: 0.5rem; +} + +.ms-conn-pill--live { + background: rgba(0, 229, 160, 0.1); + border-color: rgba(0, 229, 160, 0.35); + color: var(--accent-green); + box-shadow: var(--glow-green); +} + +.ms-conn-pill--connecting { + background: rgba(245, 158, 11, 0.1); + border-color: rgba(245, 158, 11, 0.35); + color: var(--accent-amber); +} + +.ms-conn-pill--offline { + background: rgba(239, 68, 68, 0.08); + border-color: rgba(239, 68, 68, 0.35); + color: var(--accent-red); +} + +/* Packet feed rows */ +.ms-pkt-row { + display: grid; + grid-template-columns: 62px 50px 50px 1fr 56px; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.72rem; + align-items: center; +} + +.ms-pkt-row:hover { + background: rgba(6, 182, 212, 0.04); +} + +.ms-pkt-type { + display: inline-block; + padding: 0.05rem 0.35rem; + border-radius: var(--radius-sm); + font-size: 0.58rem; + font-weight: 600; +} + +.ms-pkt-type--text { + background: rgba(0, 229, 160, 0.12); + color: var(--accent-green); +} + +.ms-pkt-type--pos { + background: rgba(245, 158, 11, 0.12); + color: var(--accent-amber); +} + +.ms-pkt-type--telem { + background: rgba(6, 182, 212, 0.12); + color: var(--accent-cyan); +} + +.ms-pkt-type--node { + background: rgba(168, 85, 247, 0.12); + color: var(--accent-purple); +} + +.ms-pkt-type--route { + background: rgba(59, 130, 246, 0.12); + color: var(--accent-blue); +} + +.ms-pkt-type--admin { + background: rgba(100, 116, 139, 0.15); + color: var(--text-secondary); +} + +.ms-pkt-type--enc { + background: rgba(239, 68, 68, 0.1); + color: var(--accent-red); +} + +.ms-pkt-body { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; +} + +.ms-pkt-content { + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ms-pkt-time, +.ms-pkt-from, +.ms-pkt-via, +.ms-pkt-rssi { + color: var(--text-secondary); +} + +/* Signal tab */ +.ms-signal-block { + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--border); +} + +.ms-signal-block__label { + font-size: 0.62rem; + color: var(--text-muted); + margin-bottom: 0.5rem; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.ms-signal-chart-wrap { + position: relative; + height: 180px; +} + +.ms-signal-chart-wrap--traffic { + height: 140px; +} + +.ms-signal-node-row { + display: flex; + justify-content: space-between; + padding: 0.2rem 0; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-secondary); +} + +/* Traceroute */ +.ms-trace-row { + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); +} + +.ms-trace-row__meta { + display: flex; + justify-content: space-between; + color: var(--text-muted); + font-size: 0.68rem; +} + +.ms-trace-row__path { + margin-top: 0.35rem; + color: var(--text-primary); + font-size: 0.78rem; +} + +/* Messaging / radio / terminal fill parent panel */ +#tab-messages .panel__body, +#tab-radio .panel__body, +#tab-terminal .panel__body { + padding: 0; + background: var(--bg-primary); +} + +#messaging-panel, +#radio-panel, +#terminal-panel { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.packet-filter { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.packet-total-toggle { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.62rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + font-family: var(--font-sans); +} + +.packet-total-toggle input { + accent-color: var(--accent-cyan); +} + +#tab-messages .messaging { + height: 100%; + min-height: 0; + max-height: none; +} + +#tab-messages .messaging, +#tab-messages .msg-sidebar, +#tab-messages .msg-chat { + background: var(--bg-primary); +} + +#tab-messages .msg-sidebar, +#tab-messages .msg-chat { + border-color: var(--border); +} diff --git a/frontend/css/responsive.css b/frontend/css/responsive.css index a0dd08d..df4a558 100644 --- a/frontend/css/responsive.css +++ b/frontend/css/responsive.css @@ -19,7 +19,8 @@ @media (max-width: 720px) { .top-bar { padding: 0.5rem 0.75rem; } - .top-bar__status { + .top-bar__status, + .top-bar__hybrid-mid { flex-wrap: wrap; row-gap: 0.25rem; column-gap: 0.5rem; @@ -47,7 +48,8 @@ align-items: flex-start; } .top-bar__brand { width: 100%; } - .top-bar__status { width: 100%; } + .top-bar__status, + .top-bar__hybrid-mid { width: 100%; } .top-bar__brand h1 { font-size: 0.95rem; } .packet-table { font-size: 0.7rem; } .packet-table th, .packet-table td { padding: 0.3rem 0.4rem; } diff --git a/frontend/css/terminal.css b/frontend/css/terminal.css new file mode 100644 index 0000000..9213aa0 --- /dev/null +++ b/frontend/css/terminal.css @@ -0,0 +1,580 @@ +/* Terminal tab — layout from New Terminal_MP/terminal.css (BEM: terminal__) */ +/* Uses dashboard.css :root tokens */ + +#terminal-panel .terminal { + flex: 1; + min-height: 0; + max-height: 100%; +} + +.terminal { + display: flex; + flex-direction: column; + background: var(--bg-primary); + font-family: var(--font-mono); + overflow: hidden; +} + +.terminal__status-strip { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.45rem 1.25rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-muted); + flex-shrink: 0; + flex-wrap: wrap; +} + +.terminal__status-strip-item { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.terminal__status-lamp { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.terminal__status-lamp--ok { + background: var(--accent-green); + box-shadow: var(--glow-green); +} + +.terminal__status-lamp--warn { + background: var(--accent-amber); + box-shadow: var(--glow-amber); +} + +.terminal__status-lamp--err { + background: var(--accent-red); + box-shadow: var(--glow-red); +} + +.terminal__status-label { + color: var(--text-muted); +} + +.terminal__status-value { + color: var(--text-secondary); + font-weight: 600; +} + +.terminal__status-value--cyan { + color: var(--accent-cyan); +} + +.terminal__status-value--green { + color: var(--accent-green); +} + +.terminal__status-strip-spacer { + flex: 1; + min-width: 0.5rem; +} + +.terminal__status-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.terminal__strip-btn { + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.62rem; + padding: 0.15rem 0.45rem; + cursor: pointer; +} + +.terminal__strip-btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.terminal__ppm { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.terminal__ppm-sparkline { + display: flex; + align-items: flex-end; + gap: 1px; + height: 14px; +} + +.terminal__ppm-bar { + width: 3px; + background: var(--accent-cyan); + opacity: 0.5; + border-radius: 1px; + transition: height 0.4s ease; +} + +.terminal__body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.terminal__rail { + width: 200px; + flex-shrink: 0; + border-right: 1px solid var(--border); + background: var(--bg-secondary); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.terminal__rail-header { + padding: 0.6rem 0.85rem; + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.terminal__rail-subhdr { + padding: 0.5rem 0.85rem 0.2rem; + font-size: 0.55rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.terminal__rail-list { + flex: 1; + overflow-y: auto; + padding: 0.25rem 0 0.4rem; + min-height: 0; +} + +.terminal__rail-list::-webkit-scrollbar { + width: 4px; +} + +.terminal__rail-list::-webkit-scrollbar-track { + background: transparent; +} + +.terminal__rail-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.terminal__cmd-item { + padding: 0.3rem 0.85rem; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + border-left: 2px solid transparent; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} + +.terminal__cmd-item:hover { + background: var(--bg-card); + color: var(--text-primary); + border-left-color: var(--accent-cyan); +} + +.terminal__cmd-item--active { + background: var(--bg-card); + color: var(--accent-cyan); + border-left-color: var(--accent-cyan); +} + +.terminal__cmd-sigil { + color: var(--accent-cyan); + opacity: 0.6; + font-size: 0.65rem; +} + +.terminal__main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-primary); + min-width: 0; +} + +.terminal__output { + flex: 1; + overflow-y: auto; + padding: 1rem 1.25rem 0.5rem; + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.6; + color: var(--text-secondary); + scroll-behavior: smooth; + min-height: 0; +} + +.terminal__output::-webkit-scrollbar { + width: 4px; +} + +.terminal__output::-webkit-scrollbar-track { + background: transparent; +} + +.terminal__output::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.terminal__boot-banner { + color: var(--accent-cyan); + opacity: 0.8; + margin-bottom: 0.5rem; + line-height: 1.2; + font-size: 0.7rem; +} + +.terminal__boot-line { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.05rem 0; + opacity: 0; + transform: translateY(2px); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.terminal__boot-line--visible { + opacity: 1; + transform: translateY(0); +} + +.terminal__boot-check { + color: var(--accent-green); + font-size: 0.75rem; +} + +.terminal__boot-label { + color: var(--text-muted); +} + +.terminal__boot-ok { + color: var(--accent-green); + font-weight: 600; + margin-left: auto; +} + +.terminal__boot-sep { + border: none; + border-top: 1px solid var(--border); + margin: 0.6rem 0; +} + +.terminal__line { + display: flex; + gap: 0.5rem; + padding: 0.05rem 0; + animation: terminal-fadein 0.1s ease; +} + +@keyframes terminal-fadein { + from { opacity: 0; transform: translateY(1px); } + to { opacity: 1; transform: translateY(0); } +} + +.terminal__line--prompt .terminal__line-prompt { + color: var(--accent-cyan); + flex-shrink: 0; + font-weight: 600; +} + +.terminal__line--prompt .terminal__line-cmd { + color: var(--text-primary); +} + +.terminal__line-text { + flex: 1; + min-width: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.terminal__line--info .terminal__line-text { + color: var(--text-secondary); +} + +.terminal__line--success .terminal__line-text { + color: var(--accent-green); +} + +.terminal__line--warn .terminal__line-text { + color: var(--accent-amber); +} + +.terminal__line--error .terminal__line-text { + color: var(--accent-red); +} + +.terminal__line--muted .terminal__line-text { + color: var(--text-muted); +} + +.terminal__line--cyan .terminal__line-text { + color: var(--accent-cyan); +} + +.terminal__line--blank .terminal__line-text { + color: transparent; + user-select: none; +} + +.terminal__table { + width: 100%; + border-collapse: collapse; + margin: 0.25rem 0; + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.terminal__table th { + text-align: left; + color: var(--text-muted); + font-weight: 600; + font-size: 0.65rem; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 0.2rem 0.75rem 0.2rem 0; + border-bottom: 1px solid var(--border); +} + +.terminal__table td { + padding: 0.22rem 0.75rem 0.22rem 0; + color: var(--text-secondary); + vertical-align: middle; +} + +.terminal__table tr:hover td { + color: var(--text-primary); +} + +.terminal__badge { + display: inline-block; + padding: 0.05rem 0.4rem; + border-radius: var(--radius-sm); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.terminal__badge--blue { + background: rgba(59, 130, 246, 0.15); + color: var(--accent-blue); +} + +.terminal__badge--purple { + background: rgba(168, 85, 247, 0.15); + color: var(--accent-purple); +} + +.terminal__badge--green { + background: rgba(0, 229, 160, 0.12); + color: var(--accent-green); +} + +.terminal__badge--amber { + background: rgba(245, 158, 11, 0.15); + color: var(--accent-amber); +} + +.terminal__badge--red { + background: rgba(239, 68, 68, 0.12); + color: var(--accent-red); +} + +.terminal__badge--cyan { + background: rgba(6, 182, 212, 0.12); + color: var(--accent-cyan); +} + +.terminal__input-wrap { + position: relative; + flex-shrink: 0; +} + +.terminal__input-row { + display: flex; + align-items: center; + gap: 0; + padding: 0.65rem 1.25rem; + border-top: 1px solid var(--border); + background: var(--bg-secondary); +} + +.terminal__prompt-prefix { + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 600; + color: var(--accent-cyan); + white-space: nowrap; + flex-shrink: 0; + user-select: none; +} + +.terminal__input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-primary); + caret-color: var(--accent-cyan); + padding: 0 0.4rem; + min-width: 0; +} + +.terminal__input::placeholder { + color: var(--text-muted); + opacity: 0.5; +} + +.terminal__input:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.terminal__autocomplete-list { + display: none; + position: absolute; + bottom: 100%; + left: 1.25rem; + right: 1.25rem; + margin-bottom: 2px; + background: var(--bg-card); + border: 1px solid var(--border-glow); + border-radius: var(--radius-sm); + z-index: 100; + overflow: hidden; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.35); + max-height: 200px; + overflow-y: auto; +} + +.terminal__autocomplete-list--open { + display: block; +} + +.terminal__autocomplete-item { + padding: 0.3rem 0.7rem; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.terminal__autocomplete-item:hover, +.terminal__autocomplete-item--focused { + background: rgba(6, 182, 212, 0.08); + color: var(--accent-cyan); +} + +.terminal__autocomplete-hint { + color: var(--text-muted); + font-size: 0.65rem; + margin-left: auto; +} + +.terminal__footer { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.3rem 1.25rem; + background: var(--bg-primary); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-muted); + flex-shrink: 0; + flex-wrap: wrap; +} + +.terminal__key { + display: inline-block; + padding: 0.05rem 0.3rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.6rem; + color: var(--text-secondary); + margin-right: 0.2rem; + line-height: 1.4; +} + +.terminal__footer-spacer { + flex: 1; +} + +.terminal__footer-ready { + display: flex; + align-items: center; + gap: 0.35rem; + color: var(--accent-green); + font-weight: 600; +} + +.terminal__footer-ready--offline { + color: var(--accent-amber); +} + +.terminal__footer-ready-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent-green); + box-shadow: var(--glow-green); +} + +.terminal__footer-ready--offline .terminal__footer-ready-dot { + background: var(--accent-amber); + box-shadow: var(--glow-amber); +} + +.terminal__footer-advanced { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.62rem; + color: var(--text-muted); + user-select: none; + cursor: pointer; +} + +.terminal__footer-advanced input { + cursor: pointer; + accent-color: var(--accent-amber); + width: 11px; + height: 11px; +} diff --git a/frontend/index.html b/frontend/index.html index 4ed2b35..e4fb432 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,17 +1,22 @@ - + - - - + + + Meshpoint Dashboard - - - - - - + + + + + @@ -23,174 +28,422 @@ - - -
+ + + + + +
+
- Meshpoint -

- Meshradar - / Meshpoint -

+ +

+ Meshradar + / Meshpoint +

+
-
- - Connecting... - | - 0 nodes - | - 0 packets - | - - - -- - - | - + N/A +
+ + Connecting… + + -- + + -- + + + + -- + + +
-
- - - -
-
-
-
-
Nodes Discovered
-
--
-
24h / total
-
-
-
Total Packets
-
--
-
-
-
Packets / Min
-
--
+
+
+ -- + Nodes (24h) +
+
+ -- + Packets +
+
+ -- + Pkt/min +
+ CONNECTING +
+
+ +
+ +
+
+
+
+
+ Network topology + 0 nodes +
+
+ +
+
-
-
RAM
-
--
-
+
+
+
+ Live packet feed +
+ + + 0 +
+
+
+
-
-
Disk
-
--
-
+
+
+
+ Signal analytics + Last 60 min +
+
+
-
-
CPU Temp
-
--
+
+
+
+ Trace routes + 0 +
+
+
- - -
-
-
-
Node Map
-
+
+
+
+ Messages + Chat
-
- -
-
-
- Nodes - -
-
+
+
+
+
+
+
+ Radio + Config
- -
- -
-
+
+
+
+
+
- Packets - 0 + Terminal + Shell
-
- - - - - - - - - - - - - - - - - -
TimeProtocolSourceDestTypeRSSISNRFreqSFHopsDetails
+
+
+
+
+ +
+

+ Live CPU, temperature, uptime, and capture sources stay in the + Nodes in range rail on the left. Deeper + system controls from the compliant mock (services, config + editor, log tail, diagnostics) can be wired to the API here + when endpoints are available. +

+
+
+

+ Service control (start/stop/restart) is not connected yet. +

+
+
+

+ Radio and upstream configuration UI is not connected yet. Use + the Radio tab for on-device radio settings + where supported. +

+
+
+

+ Live log tail is not connected yet. +

+
+
+

+ Diagnostics (ping, trace route, upstream check) is not + connected yet. Use Trace route for live + traceroute packets from the mesh. +

+
+
+
+
- -
-
-
Loading stats...
+
+
- -
-
-
- -
-
-
- -
-
- - - - - - - - @@ -198,8 +451,7 @@

- - - - + + + diff --git a/frontend/js/app.js b/frontend/js/app.js index b8e3010..dabead9 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,43 +1,8 @@ /** * Single-page controller for the local Meshpoint dashboard. * Wires up map, node list, packet feed, stat cards, and WebSocket. - * - * Auth boundary: - * - install401Redirect intercepts every same-origin /api/* fetch - * and bounces to /login?next=... when the session has expired. - * - DOMContentLoaded does a quick /api/identity probe so a fresh - * install (no admin password yet) lands on /setup, not on a - * blank dashboard with broken API calls. */ - -(function install401Redirect() { - const originalFetch = window.fetch.bind(window); - window.fetch = async (input, init) => { - const res = await originalFetch(input, init); - if (res.status === 401 && _isLocalApiRequest(input)) { - const next = encodeURIComponent(location.pathname + location.search); - location.assign(`/login?next=${next}`); - } - return res; - }; - - function _isLocalApiRequest(input) { - const raw = typeof input === 'string' ? input : (input && input.url) || ''; - try { - const parsed = new URL(raw, location.origin); - return parsed.origin === location.origin - && parsed.pathname.startsWith('/api/'); - } catch (_) { - return false; - } - } -})(); - document.addEventListener('DOMContentLoaded', async () => { - if (await _redirectIfSetupRequired()) return; - - new SignOutController('signout-btn').bind(); - const nodeMap = new NodeMap('map'); const packetFeed = new SimplePacketFeed('packet-tbody'); @@ -271,6 +236,9 @@ function _setupTabs() { if (tabId === 'stats' && window.statsTab) { window.statsTab.refresh(); } + if (tabId === 'terminal' && window.meshTerminal) { + window.meshTerminal.onActivated(); + } }); }); } @@ -279,18 +247,3 @@ function _setText(id, value) { const el = document.getElementById(id); if (el) el.textContent = value; } - -async function _redirectIfSetupRequired() { - try { - const res = await fetch('/api/identity', { credentials: 'same-origin' }); - if (!res.ok) return false; - const data = await res.json(); - if (data.setup_required) { - location.replace('/setup'); - return true; - } - } catch (_) { - /* silent: the dashboard handles its own auth via 401 interception */ - } - return false; -} diff --git a/frontend/js/auth.js b/frontend/js/auth.js deleted file mode 100644 index c02d805..0000000 --- a/frontend/js/auth.js +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Live auth controller for /setup and /login pages. - * - * Mode is read from . The classes below are - * single-responsibility: IdentityLoader populates the identity strip - * from the public /api/identity probe, AuthForm submits credentials - * and translates server reasons into messages. The page picks the - * right form variant via the AuthFormFactory. - * - * The radar visual ships sweep + center pulse only in v0.7.3. The - * design intent is to paint cyan blips from real concentrator RX - * events, which requires a deliberately-public, scrubbed event feed - * (no node ids, no payloads, rate-limited). That public feed is - * scoped to v0.7.4 so the pre-auth surface gets its own privacy - * review rather than being bolted onto the auth release. - */ - -class IdentityLoader { - constructor({ nameId, versionId, mode, redirectOnMismatch = true }) { - this.nameEl = document.getElementById(nameId); - this.versionEl = document.getElementById(versionId); - this.mode = mode; - this.redirectOnMismatch = redirectOnMismatch; - } - - async load() { - try { - const res = await fetch('/api/identity', { credentials: 'same-origin' }); - if (!res.ok) return; - const data = await res.json(); - this._render(data); - this._enforceMode(data); - } catch (_) { - /* identity probe is decorative -- silent failure is fine */ - } - } - - _render(data) { - if (this.nameEl && data.device_name) this.nameEl.textContent = data.device_name; - if (this.versionEl && data.firmware_version) { - this.versionEl.textContent = `v${data.firmware_version}`; - } - } - - _enforceMode(data) { - if (!this.redirectOnMismatch) return; - if (this.mode === 'login' && data.setup_required) { - window.location.replace('/setup'); - return; - } - if (this.mode === 'setup' && !data.setup_required) { - window.location.replace('/login'); - } - } -} - -function copyForReason(reason) { - switch (reason) { - case 'password_too_short': return 'Must be at least 8 characters.'; - case 'password_too_long': return 'That entry is too long.'; - case 'invalid_password': return 'That entry is not valid.'; - case 'already_set': return 'Setup is already complete. Redirecting to sign in...'; - case 'setup_required': return 'No admin is configured yet. Redirecting to setup...'; - case 'invalid_credentials': return 'Wrong credentials.'; - case 'locked_out': return 'Too many attempts. Try again shortly.'; - case 'network_error': return 'Network error. Check the device connection.'; - default: return 'Something went wrong. Try again.'; - } -} - -class AuthForm { - constructor() { - this.form = document.getElementById('auth-form'); - this.passwordInput = document.getElementById('password-input'); - this.errorEl = document.getElementById('error-msg'); - this.submitBtn = document.getElementById('submit-btn'); - this._lockoutTimer = null; - } - - bindCommon() { - this.form.addEventListener('submit', (e) => { - e.preventDefault(); - this._submit(); - }); - this.passwordInput.addEventListener('input', () => this._clearError()); - } - - _setLoading(on) { - this.submitBtn.classList.toggle('auth-button--loading', on); - this.submitBtn.disabled = on; - } - - _showError(reason, retryAfterSeconds) { - if (this._lockoutTimer) { - clearInterval(this._lockoutTimer); - this._lockoutTimer = null; - } - if (reason === 'locked_out' && retryAfterSeconds) { - this._startLockoutCountdown(retryAfterSeconds); - } else { - this.errorEl.textContent = copyForReason(reason); - } - this.passwordInput.classList.add('auth-input--error'); - setTimeout(() => this.passwordInput.classList.remove('auth-input--error'), 400); - } - - _startLockoutCountdown(seconds) { - this.submitBtn.disabled = true; - let remaining = seconds; - const render = () => { - const m = Math.floor(remaining / 60); - const s = String(remaining % 60).padStart(2, '0'); - this.errorEl.textContent = `Locked. Try again in ${m}:${s}`; - }; - render(); - this._lockoutTimer = setInterval(() => { - remaining -= 1; - if (remaining <= 0) { - clearInterval(this._lockoutTimer); - this._lockoutTimer = null; - this.errorEl.textContent = ''; - this.submitBtn.disabled = false; - } else { - render(); - } - }, 1000); - } - - _clearError() { - if (this._lockoutTimer) return; - this.errorEl.textContent = ''; - this.passwordInput.classList.remove('auth-input--error'); - } - - async _postJson(url, body) { - try { - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - credentials: 'same-origin', - }); - const retryAfter = res.headers.get('Retry-After'); - const text = await res.text(); - const payload = text ? JSON.parse(text) : {}; - return { - ok: res.ok, - status: res.status, - detail: payload.detail || 'unknown', - retryAfter: retryAfter ? parseInt(retryAfter, 10) : null, - }; - } catch (_) { - return { ok: false, status: 0, detail: 'network_error', retryAfter: null }; - } - } - - _redirectAfterAuth() { - window.location.replace(_safeNextPath(window.location.search)); - } -} - -/** - * Strict allowlist for the post-auth redirect target. - * - * Accepts only paths that are unambiguously same-origin: must start - * with `/`, must not start with `//` or `/\\` (protocol-relative or - * backslash injection), must not contain a scheme separator, and - * must match a conservative character class. Anything else falls - * back to `/` so a hostile `?next=` cannot bounce the freshly - * authenticated session off the device. - */ -function _safeNextPath(search) { - const params = new URLSearchParams(search); - const next = params.get('next'); - if (typeof next !== 'string' || next.length === 0 || next.length > 512) return '/'; - if (next[0] !== '/') return '/'; - if (next.startsWith('//') || next.startsWith('/\\')) return '/'; - if (next.includes('://')) return '/'; - if (!/^[A-Za-z0-9._~/?=&%#-]+$/.test(next)) return '/'; - return next; -} - -class SetupForm extends AuthForm { - constructor() { - super(); - this.confirmInput = document.getElementById('confirm-input'); - this.lengthRule = document.querySelector('[data-rule="length"]'); - this.matchRule = document.querySelector('[data-rule="match"]'); - } - - bind() { - this.bindCommon(); - const apply = () => this._applyRules(); - this.passwordInput.addEventListener('input', apply); - this.confirmInput.addEventListener('input', apply); - apply(); - } - - _applyRules() { - const pw = this.passwordInput.value; - const cf = this.confirmInput.value; - const lengthOk = pw.length >= 8; - const matchOk = pw.length > 0 && pw === cf; - this.lengthRule.classList.toggle('auth-rule--ok', lengthOk); - this.matchRule.classList.toggle('auth-rule--ok', matchOk); - } - - async _submit() { - const pw = this.passwordInput.value; - const cf = this.confirmInput.value; - if (pw.length < 8) return this._showError('password_too_short'); - if (pw !== cf) return this._showError('invalid_password'); - this._setLoading(true); - const result = await this._postJson('/api/auth/setup', { password: pw }); - this._setLoading(false); - if (result.ok) { - this._redirectAfterAuth(); - return; - } - if (result.detail === 'already_set') { - this._showError('already_set'); - setTimeout(() => window.location.replace('/login'), 1200); - return; - } - this._showError(result.detail, result.retryAfter); - } -} - -class LoginForm extends AuthForm { - constructor() { - super(); - this.usernameInput = document.getElementById('username-input'); - } - - bind() { - this.bindCommon(); - } - - async _submit() { - const username = (this.usernameInput.value || '').trim(); - const password = this.passwordInput.value; - if (!username || !password) return this._showError('invalid_credentials'); - this._setLoading(true); - const result = await this._postJson('/api/auth/login', { username, password }); - this._setLoading(false); - if (result.ok) { - this._redirectAfterAuth(); - return; - } - if (result.detail === 'setup_required') { - this._showError('setup_required'); - setTimeout(() => window.location.replace('/setup'), 1200); - return; - } - this._showError(result.detail, result.retryAfter); - } -} - -class AuthFormFactory { - static build(mode) { - if (mode === 'setup') return new SetupForm(); - return new LoginForm(); - } -} - -document.addEventListener('DOMContentLoaded', () => { - const mode = document.body.dataset.authMode || 'login'; - new IdentityLoader({ - nameId: 'identity-name', - versionId: 'identity-version', - mode, - }).load(); - - AuthFormFactory.build(mode).bind(); -}); diff --git a/frontend/js/meshradar/api.js b/frontend/js/meshradar/api.js new file mode 100644 index 0000000..39dadd1 --- /dev/null +++ b/frontend/js/meshradar/api.js @@ -0,0 +1,150 @@ +import { CFG } from "./config.js"; + +async function apiFetch(path) { + const res = await fetch(`${CFG.apiBase}${path}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +function toUnixSeconds(value) { + if (!value) return Math.floor(Date.now() / 1000); + if (typeof value === "number") return value > 1e12 ? Math.floor(value / 1000) : value; + const d = Date.parse(value); + return Number.isNaN(d) ? Math.floor(Date.now() / 1000) : Math.floor(d / 1000); +} + +function normalizeRole(role) { + if (!role) return "CLIENT"; + return String(role).toUpperCase(); +} + +function inferNodeSourceType(node) { + const candidates = [ + node?.last_seen_via, + node?.last_heard_via, + node?.source, + node?.source_type, + node?.transport, + node?.ingress, + node?.rx_source, + node?.latest_signal?.source, + node?.latest_signal?.transport, + ].filter(Boolean).map((v) => String(v).toLowerCase()); + const text = candidates.join(" "); + if (text.includes("mqtt")) return "MQTT"; + if (text.includes("lora") || text.includes("rf") || text.includes("radio")) return "RF"; + return "UNKNOWN"; +} + +export async function getNodes() { + const rows = await apiFetch("/nodes?enrich=true"); + return (rows || []).map((n) => { + const sig = n.latest_signal || {}; + const tel = n.latest_telemetry || {}; + return { + id: n.node_id || "", + long_name: n.long_name || "", + short_name: n.short_name || "", + hw_model: n.hardware_model || "", + role: normalizeRole(n.role), + lat: n.latitude, + lon: n.longitude, + altitude: n.altitude, + rssi: sig.rssi ?? n.rssi ?? -120, + snr: sig.snr ?? n.snr ?? 0, + battery_level: tel.battery_level ?? null, + last_heard: toUnixSeconds(n.last_heard), + hops_away: n.hops_away ?? 1, + neighbors: n.neighbors || [], + firmware: n.firmware_version || "", + air_util_tx: tel.air_util_tx ?? null, + channel_util: tel.channel_utilization ?? null, + source_type: inferNodeSourceType(n), + }; + }); +} + +function normalizePacket(p) { + const sig = p.signal || {}; + const source = p.source_id || p.from_id || ""; + const dest = p.destination_id || p.to_id || "^all"; + const pktType = String(p.packet_type || p.portnum || "unknown").toLowerCase(); + const portnumMap = { + text: "TEXT_MESSAGE_APP", + position: "POSITION_APP", + telemetry: "TELEMETRY_APP", + nodeinfo: "NODEINFO_APP", + traceroute: "TRACEROUTE_APP", + routing: "ROUTING_APP", + admin: "ADMIN_APP", + encrypted: "UNKNOWN_APP", + }; + const decoded = p.decoded_payload || p.decoded || {}; + return { + id: p.packet_id || p.id || `${source}-${Date.now()}`, + timestamp: p.timestamp ? Date.parse(p.timestamp) || Date.now() : Date.now(), + from_id: source, + to_id: dest, + via_id: null, + portnum: portnumMap[pktType] || String(p.portnum || "UNKNOWN_APP"), + portnum_int: 0, + decoded, + rssi: sig.rssi ?? p.rssi ?? -120, + snr: sig.snr ?? p.snr ?? 0, + hop_limit: p.hop_limit ?? 0, + hop_start: p.hop_start ?? 0, + rx_time: toUnixSeconds(p.timestamp), + channel: 0, + encrypted: p.decrypted === false, + }; +} + +export async function getPackets({ limit = 50 } = {}) { + const rows = await apiFetch(`/packets?limit=${limit}`); + const packets = (rows || []).map(normalizePacket); + return { packets, total: packets.length, page: 1 }; +} + +export async function getTraffic() { + const data = await apiFetch("/analytics/traffic"); + return { + rate_per_min: data.packets_per_minute || 0, + counts: data.counts || [], + timestamps: data.timestamps || [], + }; +} + +export async function getRssiDistribution() { + const data = await apiFetch("/analytics/signal/rssi"); + return { + distribution: data.distribution || data || {}, + mean: data.mean ?? null, + min: data.min ?? null, + max: data.max ?? null, + }; +} + +export async function getDeviceStatus() { + const [status, device] = await Promise.all([ + apiFetch("/device/status"), + apiFetch("/device").catch(() => ({})), + ]); + const metrics = await apiFetch("/device/metrics").catch(() => ({})); + return { + cpu_percent: metrics.cpu_percent ?? 0, + temp_c: metrics.cpu_temp_c ?? 0, + uptime_seconds: status.uptime_seconds ?? 0, + sources: ["concentrator", "meshcore_usb"], + region: "US", + frequency_mhz: 906.875, + device_id: status.device_id ?? device.device_id ?? "", + device_name: device.device_name ?? "Meshpoint", + firmware_version: status.firmware_version ?? device.firmware_version ?? "", + }; +} + +export async function getUpdateCheck() { + return apiFetch("/device/update-check"); +} + +export { normalizePacket }; diff --git a/frontend/js/meshradar/config.js b/frontend/js/meshradar/config.js new file mode 100644 index 0000000..539dcc3 --- /dev/null +++ b/frontend/js/meshradar/config.js @@ -0,0 +1,15 @@ +const host = location.hostname || "localhost"; +const port = location.port || "8080"; +const base = `${location.protocol}//${host}:${port}`; +const wsProto = location.protocol === "https:" ? "wss" : "ws"; + +export const CFG = { + apiBase: `${base}/api`, + wsUrl: `${wsProto}://${host}:${port}/ws`, + poll: { + nodes: 10000, + deviceStatus: 15000, + traffic: 5000, + }, + packetFeedMax: 120, +}; diff --git a/frontend/js/meshradar/main.js b/frontend/js/meshradar/main.js new file mode 100644 index 0000000..59307d2 --- /dev/null +++ b/frontend/js/meshradar/main.js @@ -0,0 +1,351 @@ +import { CFG } from "./config.js"; +import { + getNodes, + getPackets, + getTraffic, + getRssiDistribution, + getDeviceStatus, + getUpdateCheck, +} from "./api.js"; +import { meshWS, WS_STATE } from "./ws.js"; +import { Topology } from "./topology.js"; +import { PacketFeed } from "./packets.js"; +import { SignalPanel } from "./signal.js"; +import { TraceRoutePanel } from "./traceroute.js"; +import { initSystemShell } from "./system_shell.js"; + +let nodes = []; +let nodeMap = {}; +let selectedId = null; +let pktTotal = 0; +let nodeSourceFilter = "ALL"; + +const topo = new Topology(document.getElementById("topoCanvas"), { onNodeSelect }); +const feed = new PacketFeed(document.getElementById("pkt-feed-container"), { maxRows: CFG.packetFeedMax, onPacketClick }); +const signal = new SignalPanel(document.getElementById("signal-container")); +const traces = new TraceRoutePanel(document.getElementById("trace-container")); +const packetTypeFilterEl = document.getElementById("pkt-portnum-filter"); +const packetCountShowTotalEl = document.getElementById("pkt-count-show-total"); +const nodeSourceFilterEl = document.getElementById("node-source-filter"); + +function getFilteredNodes() { + if (nodeSourceFilter === "ALL") return nodes; + return nodes.filter((n) => (n.source_type || "UNKNOWN") === nodeSourceFilter); +} + +function sourcePillClass(sourceType) { + if (sourceType === "RF") return "ms-node-source-pill--rf"; + if (sourceType === "MQTT") return "ms-node-source-pill--mqtt"; + return "ms-node-source-pill--unknown"; +} + +function updatePacketCountBadge() { + const badge = document.getElementById("pkt-count-badge"); + if (!badge) return; + const selectedType = packetTypeFilterEl?.value; + const showTotal = Boolean(packetCountShowTotalEl?.checked); + if (selectedType && showTotal) { + badge.textContent = `${feed.visibleCount()}/${pktTotal}`; + return; + } + badge.textContent = selectedType ? String(feed.visibleCount()) : String(pktTotal); +} + +if (packetTypeFilterEl) { + packetTypeFilterEl.addEventListener("change", () => { + feed.setFilter(packetTypeFilterEl.value || null); + updatePacketCountBadge(); + }); +} + +if (packetCountShowTotalEl) { + packetCountShowTotalEl.addEventListener("change", () => updatePacketCountBadge()); +} + +if (nodeSourceFilterEl) { + nodeSourceFilterEl.addEventListener("change", () => { + nodeSourceFilter = nodeSourceFilterEl.value || "ALL"; + const visibleNodes = getFilteredNodes(); + topo.setNodes(visibleNodes); + if (selectedId && !visibleNodes.some((n) => n.id === selectedId)) { + selectedId = null; + const hint = document.getElementById("detail-hint"); + if (hint) hint.textContent = "Select a node"; + const dc = document.getElementById("detail-content"); + if (dc) dc.innerHTML = "
Select a node to view details.
"; + } + renderNodeList(); + }); +} + +function setView(name) { + document.querySelectorAll(".tab-content").forEach((p) => { + const on = p.id === `tab-${name}`; + p.classList.toggle("tab-content--active", on); + }); + document.querySelectorAll(".tab-bar__btn").forEach((t) => { + const on = t.dataset.view === name; + t.classList.toggle("tab-bar__btn--active", on); + if (t.getAttribute("role") === "tab") t.setAttribute("aria-selected", on ? "true" : "false"); + }); + const stage = document.getElementById("ms-stage"); + if (stage) { + const single = name === "messages" || name === "radio" || name === "terminal" || name === "system"; + stage.classList.toggle("ms-stage--single", single); + } + if (name === "messages" && window.messagingPanel) window.messagingPanel.onActivated(); + if (name === "radio" && window.radioSettings) window.radioSettings.onActivated(); + if (name === "terminal" && window.meshTerminal) window.meshTerminal.onActivated(); +} + +document.querySelectorAll(".tab-bar__btn").forEach((el) => { + el.addEventListener("click", () => setView(el.dataset.view)); +}); + +initSystemShell(); + +function ageStr(lastHeard) { + const s = Math.max(0, Math.round(Date.now() / 1000 - lastHeard)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + return `${Math.floor(s / 3600)}h`; +} + +function renderNodeList() { + const el = document.getElementById("node-list"); + const visibleNodes = getFilteredNodes(); + el.innerHTML = visibleNodes.map((n) => ` +
+
+ ${n.short_name || n.long_name || n.id} + ${n.source_type || "UNKNOWN"} +
+
+ ${Math.round(n.rssi)} dBm + ${ageStr(n.last_heard)} ago +
+
+ `).join(""); + el.querySelectorAll(".ms-node-entry").forEach((entry) => entry.addEventListener("click", () => onNodeSelect(nodeMap[entry.dataset.id]))); +} + +function onNodeSelect(node) { + if (!node) return; + selectedId = node.id; + topo.selectNode(node.id); + renderNodeList(); + document.getElementById("detail-hint").textContent = node.short_name || node.long_name || node.id; + document.getElementById("detail-content").innerHTML = ` +
+
${node.long_name || node.short_name || node.id}
+
${node.hw_model || "unknown hw"} · ${node.role}
+
+
+
Node ID${node.id}
+
RSSI${Math.round(node.rssi)} dBm
+
SNR${(node.snr || 0).toFixed(1)} dB
+
Seen via${node.source_type || "UNKNOWN"}
+
Last heard${ageStr(node.last_heard)} ago
+
+ `; +} + +function onPacketClick(pkt) { + const node = nodeMap[pkt.from_id]; + if (node) onNodeSelect(node); +} + +function syncTopSummaryBadges() { + const nodes = document.getElementById("stat-nodes")?.textContent ?? "--"; + const pkts = document.getElementById("stat-pkts")?.textContent ?? "--"; + const nEl = document.getElementById("node-count-badge"); + const pEl = document.getElementById("packet-count-badge"); + if (nEl) nEl.textContent = `${nodes} nodes (24h)`; + if (pEl) pEl.textContent = `${pkts} packets`; +} + +function updateConnPill(state) { + const pill = document.getElementById("conn-pill"); + if (!pill) return; + const labels = { + [WS_STATE.CONNECTED]: "LIVE", + [WS_STATE.CONNECTING]: "CONNECTING", + [WS_STATE.RECONNECTING]: "RECONNECTING", + [WS_STATE.CLOSED]: "OFFLINE", + }; + const mod = { + [WS_STATE.CONNECTED]: "live", + [WS_STATE.CONNECTING]: "warn", + [WS_STATE.RECONNECTING]: "warn", + [WS_STATE.CLOSED]: "err", + }; + pill.textContent = labels[state] || String(state); + pill.className = `top-bar__pill top-bar__pill--${mod[state] || "err"}`; + + const dot = document.getElementById("ws-status"); + const label = document.getElementById("ws-label"); + if (dot && label) { + const connected = state === WS_STATE.CONNECTED; + dot.className = connected ? "status-dot status-dot--connected" : "status-dot status-dot--disconnected"; + const wsLabels = { + [WS_STATE.CONNECTED]: "Connected", + [WS_STATE.CONNECTING]: "Connecting…", + [WS_STATE.RECONNECTING]: "Reconnecting…", + [WS_STATE.CLOSED]: "Offline", + }; + label.textContent = wsLabels[state] || String(state); + } +} + +meshWS.on("state", updateConnPill); +meshWS.on("packet", (pkt) => { + pktTotal += 1; + document.getElementById("stat-pkts").textContent = String(pktTotal); + syncTopSummaryBadges(); + feed.addPacket(pkt); + updatePacketCountBadge(); +}); +meshWS.on("packet:TRACEROUTE_APP", (pkt) => { + traces.addPacket(pkt); + const c = document.getElementById("trace-count"); + c.textContent = String((parseInt(c.textContent, 10) || 0) + 1); +}); + +async function updateDevice() { + const s = await getDeviceStatus(); + document.getElementById("dev-cpu").textContent = `${(s.cpu_percent || 0).toFixed(1)}%`; + document.getElementById("dev-temp").textContent = `${(s.temp_c || 0).toFixed(1)}°C`; + const h = Math.floor((s.uptime_seconds || 0) / 3600); + const m = Math.floor(((s.uptime_seconds || 0) % 3600) / 60); + document.getElementById("dev-uptime").textContent = `${h}h ${m}m`; + document.getElementById("dev-sources").textContent = (s.sources || []).join("+"); + document.getElementById("top-freq").textContent = `${s.region || "US"} · ${s.frequency_mhz || "N/A"} MHz`; + + const nameEl = document.getElementById("device-name"); + if (nameEl) nameEl.textContent = s.device_name || "Meshpoint"; + + const idEl = document.getElementById("device-id"); + if (idEl && s.device_id) { + const full = s.device_id; + const short = full.slice(0, 8); + idEl.dataset.fullId = full; + idEl.textContent = short; + idEl.title = full; + } + + const verEl = document.getElementById("version-badge"); + if (verEl) { + verEl.textContent = s.firmware_version ? `v${s.firmware_version}` : "--"; + } +} + +function wireHybridChromeOnce() { + if (document.body.dataset.hybridChromeWired === "1") return; + document.body.dataset.hybridChromeWired = "1"; + + const idEl = document.getElementById("device-id"); + if (idEl) { + idEl.addEventListener("click", () => { + const full = idEl.dataset.fullId; + if (!full || !navigator.clipboard?.writeText) return; + const short = full.slice(0, 8); + navigator.clipboard.writeText(full).then(() => { + idEl.textContent = "copied!"; + setTimeout(() => { idEl.textContent = short; }, 1500); + }).catch(() => {}); + }); + } + + document.getElementById("signout-btn")?.addEventListener("click", async () => { + try { + const r = await fetch(`${CFG.apiBase}/auth/logout`, { method: "POST", credentials: "same-origin" }); + if (r.ok) window.location.reload(); + } catch { + /* no auth route in this build */ + } + }); +} + +async function refreshUpdateBadge() { + const badge = document.getElementById("update-badge"); + if (!badge) return; + try { + const data = await getUpdateCheck(); + if (data.update_available) { + badge.classList.remove("hidden"); + badge.title = `Update available (local: ${data.local_version}, remote: ${data.remote_version})`; + } else { + badge.classList.add("hidden"); + } + } catch { + badge.classList.add("hidden"); + } +} + +async function boot() { + meshWS.connect(); + try { + nodes = await getNodes(); + nodeMap = Object.fromEntries(nodes.map((n) => [n.id, n])); + topo.setNodes(getFilteredNodes()); + traces.setNodeMap(nodeMap); + signal.setNodes(nodes); + renderNodeList(); + document.getElementById("topo-count").textContent = `${nodes.length} nodes`; + document.getElementById("stat-nodes").textContent = String(nodes.filter((n) => (Date.now() / 1000 - n.last_heard) < 86400).length); + } catch (e) { console.error(e); } + + try { + const { packets, total } = await getPackets({ limit: 60 }); + feed.loadHistory(packets); + pktTotal = total; + document.getElementById("stat-pkts").textContent = String(total); + updatePacketCountBadge(); + const tracePackets = packets.filter((p) => p.portnum === "TRACEROUTE_APP"); + traces.loadHistory(tracePackets); + document.getElementById("trace-count").textContent = String(tracePackets.length); + } catch (e) { console.error(e); } + + try { + const [rssi, traffic] = await Promise.all([getRssiDistribution(), getTraffic()]); + signal.setRssiDistribution(rssi); + signal.setTraffic(traffic); + document.getElementById("stat-rate").textContent = `${(traffic.rate_per_min || 0).toFixed(1)}`; + } catch (e) { console.error(e); } + + try { await updateDevice(); } catch (e) { console.error(e); } + + wireHybridChromeOnce(); + syncTopSummaryBadges(); + try { await refreshUpdateBadge(); } catch (e) { console.error(e); } +} + +setInterval(async () => { + try { + nodes = await getNodes(); + nodeMap = Object.fromEntries(nodes.map((n) => [n.id, n])); + topo.setNodes(getFilteredNodes()); + signal.setNodes(nodes); + traces.setNodeMap(nodeMap); + renderNodeList(); + const sn = document.getElementById("stat-nodes"); + if (sn) sn.textContent = String(nodes.filter((n) => (Date.now() / 1000 - n.last_heard) < 86400).length); + const tc = document.getElementById("topo-count"); + if (tc) tc.textContent = `${nodes.length} nodes`; + syncTopSummaryBadges(); + } catch {} +}, CFG.poll.nodes); + +setInterval(async () => { + try { + const t = await getTraffic(); + signal.pushTrafficPoint(t.rate_per_min || 0); + document.getElementById("stat-rate").textContent = `${(t.rate_per_min || 0).toFixed(1)}`; + } catch {} +}, CFG.poll.traffic); + +setInterval(async () => { try { await updateDevice(); } catch {} }, CFG.poll.deviceStatus); + +setInterval(() => { refreshUpdateBadge().catch(() => {}); }, 300_000); + +boot(); diff --git a/frontend/js/meshradar/packets.js b/frontend/js/meshradar/packets.js new file mode 100644 index 0000000..44c407c --- /dev/null +++ b/frontend/js/meshradar/packets.js @@ -0,0 +1,91 @@ +export const PORTNUM_META = { + TEXT_MESSAGE_APP: { label: "TEXT", cls: "ms-pkt-type--text" }, + POSITION_APP: { label: "POS", cls: "ms-pkt-type--pos" }, + TELEMETRY_APP: { label: "TELEM", cls: "ms-pkt-type--telem" }, + NODEINFO_APP: { label: "NODE", cls: "ms-pkt-type--node" }, + TRACEROUTE_APP: { label: "TRACE", cls: "ms-pkt-type--route" }, + ADMIN_APP: { label: "ADMIN", cls: "ms-pkt-type--admin" }, + UNKNOWN_APP: { label: "ENC", cls: "ms-pkt-type--enc" }, +}; + +const ENCRYPTED_FILTER_VALUE = "__ENCRYPTED__"; + +export function getPortnumMeta(portnum) { + return PORTNUM_META[portnum] || { label: "UNK", cls: "ms-pkt-type--enc" }; +} + +function formatTime(ts) { + const d = new Date(ts); + return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":"); +} + +function shortId(id) { + if (!id || id === "^all") return id === "^all" ? "all" : "--"; + return id.replace("!", "").slice(-4).toUpperCase(); +} + +function formatDecoded(pkt) { + if (!pkt.decoded) return pkt.encrypted ? "[encrypted]" : ""; + if (pkt.decoded.text) return pkt.decoded.text; + if (pkt.decoded.long_name) return pkt.decoded.long_name; + return JSON.stringify(pkt.decoded).slice(0, 80); +} + +export class PacketFeed { + constructor(container, { maxRows = 120, onPacketClick = null } = {}) { + this.container = container; + this.maxRows = maxRows; + this.onPacketClick = onPacketClick; + this._packets = []; + this._filter = null; + this._nodeFilter = null; + } + + loadHistory(packets) { + this._packets = [...packets].sort((a, b) => b.timestamp - a.timestamp); + this._rerender(); + } + + addPacket(pkt) { + this._packets.unshift(pkt); + this._rerender(); + } + + setFilter(portnum) { this._filter = portnum || null; this._rerender(); } + setNodeFilter(nodeId) { this._nodeFilter = nodeId || null; this._rerender(); } + visibleCount() { return this._visiblePackets().length; } + + _visiblePackets() { + return this._packets.filter((p) => { + if (this._filter) { + if (this._filter === ENCRYPTED_FILTER_VALUE) { + if (!p.encrypted && p.portnum !== "UNKNOWN_APP") return false; + } else if (p.portnum !== this._filter) return false; + } + if (this._nodeFilter && p.from_id !== this._nodeFilter && p.to_id !== this._nodeFilter) return false; + return true; + }).slice(0, this.maxRows); + } + + _rerender() { + const list = this._visiblePackets(); + this.container.innerHTML = ""; + for (const pkt of list) { + const meta = getPortnumMeta(pkt.portnum); + const el = document.createElement("div"); + el.className = "ms-pkt-row"; + el.innerHTML = ` + ${formatTime(pkt.timestamp)} + ${shortId(pkt.from_id)} + ${shortId(pkt.via_id)} + + ${meta.label} + ${formatDecoded(pkt)} + + ${Math.round(pkt.rssi || -120)} + `; + el.addEventListener("click", () => this.onPacketClick?.(pkt)); + this.container.appendChild(el); + } + } +} diff --git a/frontend/js/meshradar/signal.js b/frontend/js/meshradar/signal.js new file mode 100644 index 0000000..c3674f8 --- /dev/null +++ b/frontend/js/meshradar/signal.js @@ -0,0 +1,112 @@ +function cssColor(varName, fallback) { + const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + return raw || fallback; +} + +export class SignalPanel { + constructor(container) { + this.container = container; + this._traffic = []; + this._render(); + } + + _render() { + const cyan = cssColor("--accent-cyan", "#06b6d4"); + const muted = cssColor("--text-muted", "#64748b"); + const barRgb = "rgba(6, 182, 212, 0.45)"; + this.container.innerHTML = ` +
+
RSSI distribution
+
+
+
+
Traffic rate
+
+
+
+ `; + this._rssiChart = new Chart(document.getElementById("mp-rssi-chart"), { + type: "bar", + data: { + labels: [], + datasets: [{ + data: [], + backgroundColor: barRgb, + borderColor: cyan, + borderWidth: 1, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: muted, font: { family: "JetBrains Mono", size: 10 } } }, + y: { ticks: { color: muted, font: { family: "JetBrains Mono", size: 10 } } }, + }, + }, + }); + this._trafficChart = new Chart(document.getElementById("mp-traffic-chart"), { + type: "line", + data: { + labels: [], + datasets: [{ + data: [], + borderColor: cyan, + backgroundColor: "rgba(6, 182, 212, 0.08)", + fill: true, + pointRadius: 0, + tension: 0.35, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: muted, font: { family: "JetBrains Mono", size: 9 } } }, + y: { ticks: { color: muted, font: { family: "JetBrains Mono", size: 10 } } }, + }, + }, + }); + } + + setRssiDistribution(data) { + const entries = Object.entries(data.distribution || {}).map(([k, v]) => [Number(k), v]).sort((a, b) => a[0] - b[0]); + this._rssiChart.data.labels = entries.map(([k]) => `${k}`); + this._rssiChart.data.datasets[0].data = entries.map(([, v]) => v); + this._rssiChart.update("none"); + } + + setTraffic(data) { + this._traffic = [...(data.counts || [])].slice(-60); + this._trafficChart.data.labels = this._traffic.map((_, i) => `-${this._traffic.length - 1 - i}m`); + this._trafficChart.data.datasets[0].data = this._traffic; + this._trafficChart.update("none"); + } + + pushTrafficPoint(value) { + this._traffic.push(value); + if (this._traffic.length > 60) this._traffic.shift(); + this._trafficChart.data.labels = this._traffic.map((_, i) => `-${this._traffic.length - 1 - i}m`); + this._trafficChart.data.datasets[0].data = this._traffic; + this._trafficChart.update("none"); + } + + setNodes(nodes) { + const sorted = [...(nodes || [])].sort((a, b) => (b.snr || 0) - (a.snr || 0)).slice(0, 12); + const el = document.getElementById("mp-snr-bars"); + if (!el) return; + el.innerHTML = ` +
SNR (top nodes)
+ ${sorted.map((n) => ` +
+ ${n.short_name || n.long_name || n.id} + ${(n.snr || 0).toFixed(1)} dB +
+ `).join("")} + `; + } +} diff --git a/frontend/js/meshradar/system_shell.js b/frontend/js/meshradar/system_shell.js new file mode 100644 index 0000000..6776e3c --- /dev/null +++ b/frontend/js/meshradar/system_shell.js @@ -0,0 +1,20 @@ +/** + * Sub-tabs inside System view (shell from meshpoint-compliant reference). + */ +export function initSystemShell() { + const root = document.getElementById("tab-system"); + if (!root) return; + + root.querySelectorAll(".sys-nav__btn").forEach((btn) => { + btn.addEventListener("click", () => { + const name = btn.dataset.sys; + if (!name) return; + root.querySelectorAll(".sys-nav__btn").forEach((b) => { + b.classList.toggle("sys-nav__btn--active", b === btn); + }); + root.querySelectorAll(".sys-panel").forEach((p) => { + p.classList.toggle("sys-panel--active", p.id === `sys-${name}`); + }); + }); + }); +} diff --git a/frontend/js/meshradar/topology.js b/frontend/js/meshradar/topology.js new file mode 100644 index 0000000..c9894a9 --- /dev/null +++ b/frontend/js/meshradar/topology.js @@ -0,0 +1,79 @@ +export class Topology { + constructor(canvas, { onNodeSelect = null } = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext("2d"); + this.onNodeSelect = onNodeSelect; + this.nodes = []; + this.positions = {}; + this.selectedId = null; + this.scale = 1; + this.offsetX = 0; + this.offsetY = 0; + this._resize(); + window.addEventListener("resize", () => this._resize()); + this.canvas.addEventListener("click", (e) => this._click(e)); + requestAnimationFrame(() => this._drawLoop()); + } + + setNodes(nodes) { + this.nodes = nodes || []; + const cx = this.w / 2; + const cy = this.h / 2; + this.nodes.forEach((n, i) => { + if (!this.positions[n.id]) { + const a = (i / Math.max(this.nodes.length, 1)) * Math.PI * 2; + const r = 120 + ((n.hops_away || 1) * 45); + this.positions[n.id] = { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }; + } + }); + } + + selectNode(id) { this.selectedId = id; } + zoom(factor) { this.scale = Math.max(0.2, Math.min(4, this.scale * factor)); } + fitAll() { this.scale = 1; this.offsetX = 0; this.offsetY = 0; } + + _resize() { + const rect = this.canvas.parentElement.getBoundingClientRect(); + this.w = rect.width; + this.h = rect.height; + this.canvas.width = this.w; + this.canvas.height = this.h; + } + + _drawLoop() { + this.ctx.clearRect(0, 0, this.w, this.h); + this.ctx.save(); + this.ctx.translate(this.offsetX, this.offsetY); + this.ctx.scale(this.scale, this.scale); + for (const n of this.nodes) { + const p = this.positions[n.id]; + if (!p) continue; + this.ctx.beginPath(); + this.ctx.arc(p.x, p.y, n.id === this.selectedId ? 10 : 7, 0, Math.PI * 2); + this.ctx.fillStyle = n.hops_away === 0 ? "#e8931a" : "#1cb89a"; + this.ctx.fill(); + this.ctx.fillStyle = "#b0c0d0"; + this.ctx.font = "10px monospace"; + this.ctx.textAlign = "center"; + this.ctx.fillText(n.short_name || n.long_name || n.id.slice(-4), p.x, p.y + 18); + } + this.ctx.restore(); + requestAnimationFrame(() => this._drawLoop()); + } + + _click(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left - this.offsetX) / this.scale; + const y = (e.clientY - rect.top - this.offsetY) / this.scale; + for (const n of this.nodes) { + const p = this.positions[n.id]; + if (!p) continue; + const d2 = (x - p.x) ** 2 + (y - p.y) ** 2; + if (d2 <= 12 ** 2) { + this.selectedId = n.id; + this.onNodeSelect?.(n); + break; + } + } + } +} diff --git a/frontend/js/meshradar/traceroute.js b/frontend/js/meshradar/traceroute.js new file mode 100644 index 0000000..03f4216 --- /dev/null +++ b/frontend/js/meshradar/traceroute.js @@ -0,0 +1,36 @@ +export class TraceRoutePanel { + constructor(container) { + this.container = container; + this._routes = []; + this._nodeMap = {}; + } + + setNodeMap(nodeMap) { + this._nodeMap = nodeMap || {}; + this._rerender(); + } + + loadHistory(packets) { + this._routes = (packets || []).filter((p) => p.portnum === "TRACEROUTE_APP").slice(0, 30); + this._rerender(); + } + + addPacket(pkt) { + if (pkt.portnum !== "TRACEROUTE_APP") return; + this._routes.unshift(pkt); + if (this._routes.length > 30) this._routes.pop(); + this._rerender(); + } + + _rerender() { + this.container.innerHTML = this._routes.map((p) => { + const time = new Date(p.timestamp).toLocaleTimeString([], { hour12: false }); + const from = this._nodeMap[p.from_id]?.short_name || p.from_id; + const to = this._nodeMap[p.to_id]?.short_name || p.to_id; + return `
+
${time}${Math.round(p.rssi || -120)} dBm
+
${from} to ${to}
+
`; + }).join(""); + } +} diff --git a/frontend/js/meshradar/ws.js b/frontend/js/meshradar/ws.js new file mode 100644 index 0000000..408b07e --- /dev/null +++ b/frontend/js/meshradar/ws.js @@ -0,0 +1,66 @@ +import { CFG } from "./config.js"; +import { normalizePacket } from "./api.js"; + +export const WS_STATE = { + CONNECTING: "connecting", + CONNECTED: "connected", + RECONNECTING: "reconnecting", + CLOSED: "closed", +}; + +class MeshWS { + constructor() { + this._ws = null; + this._listeners = new Map(); + this._retry = 0; + this._closed = false; + } + + on(event, fn) { + if (!this._listeners.has(event)) this._listeners.set(event, new Set()); + this._listeners.get(event).add(fn); + return () => this._listeners.get(event)?.delete(fn); + } + + _emit(event, data) { + const set = this._listeners.get(event); + if (!set) return; + for (const fn of set) { + try { fn(data); } catch {} + } + } + + connect() { + if (this._closed) return; + this._emit("state", this._retry === 0 ? WS_STATE.CONNECTING : WS_STATE.RECONNECTING); + this._ws = new WebSocket(CFG.wsUrl); + this._ws.onopen = () => { + this._retry = 0; + this._emit("state", WS_STATE.CONNECTED); + }; + this._ws.onclose = () => { + this._emit("state", WS_STATE.RECONNECTING); + setTimeout(() => { + this._retry += 1; + this.connect(); + }, Math.min(1000 * (2 ** this._retry), 30000)); + }; + this._ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + if (msg?.type === "packet" && msg.data) { + const pkt = normalizePacket(msg.data); + this._emit("packet", pkt); + this._emit(`packet:${pkt.portnum}`, pkt); + } + }; + } + + disconnect() { + this._closed = true; + this._emit("state", WS_STATE.CLOSED); + this._ws?.close(); + } +} + +export const meshWS = new MeshWS(); diff --git a/frontend/js/signout_controller.js b/frontend/js/signout_controller.js deleted file mode 100644 index 32f4084..0000000 --- a/frontend/js/signout_controller.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Sign-out button controller for the dashboard topbar. - * - * Single responsibility: POST /api/auth/logout, then send the - * browser to /login. Survives a network failure by redirecting - * anyway -- the global 401 interceptor will catch any subsequent - * authenticated call if the cookie somehow lingered. - */ - -class SignOutController { - constructor(buttonId) { - this.button = document.getElementById(buttonId); - } - - bind() { - if (!this.button) return; - this.button.addEventListener('click', () => this._signOut()); - } - - async _signOut() { - this.button.disabled = true; - try { - await fetch('/api/auth/logout', { - method: 'POST', - credentials: 'same-origin', - }); - } catch (_) { - /* network-level failure -- still send the user to /login, - the cookie may already be invalid server-side */ - } finally { - window.location.assign('/login'); - } - } -} - -window.SignOutController = SignOutController; diff --git a/frontend/js/simple_packet_feed.js b/frontend/js/simple_packet_feed.js index 61d4a73..3e306e6 100644 --- a/frontend/js/simple_packet_feed.js +++ b/frontend/js/simple_packet_feed.js @@ -7,6 +7,17 @@ class SimplePacketFeed { this._tbody = document.getElementById(tbodyId); this._maxRows = maxRows || 200; this._count = 0; + + this._visibleCount = 0; + this._typeFilter = 'all'; + this._knownTypes = new Set(); + this._filterSelect = document.getElementById('packet-type-filter'); + if (this._filterSelect) { + this._filterSelect.addEventListener('change', () => { + this.setTypeFilter(this._filterSelect.value); + }); + } + this._nodeByLastByte = new Map(); this._onFocus = null; } @@ -26,6 +37,7 @@ class SimplePacketFeed { } addPacket(packet) { + this._registerPacketType(packet.packet_type); const tr = document.createElement('tr'); tr.classList.add('packet-row--new'); tr.addEventListener('animationend', () => tr.classList.remove('packet-row--new')); @@ -83,17 +95,43 @@ class SimplePacketFeed { tr.addEventListener('click', () => this._toggleDetail(tr, packet)); this._tbody.prepend(tr); + this._applyFilterToRow(tr, packet); this._count++; - - const countEl = document.getElementById('packet-count'); - if (countEl) countEl.textContent = this._count; + this._updateCountBadge(); while (this._tbody.children.length > this._maxRows * 2) { - this._tbody.removeChild(this._tbody.lastChild); + const last = this._tbody.lastChild; + if (last && !last.classList.contains('packet-detail-row')) { + if (!last.classList.contains('packet-row--hidden')) { + this._visibleCount = Math.max(0, this._visibleCount - 1); + } + } + this._tbody.removeChild(last); } + this._updateCountBadge(); + } + + setTypeFilter(type) { + this._typeFilter = (type || 'all').toLowerCase(); + const rows = Array.from(this._tbody.querySelectorAll('tr:not(.packet-detail-row)')); + for (const row of rows) { + const packetType = row.dataset.packetType || ''; + const visible = this._typeFilter === 'all' || packetType === this._typeFilter; + row.classList.toggle('packet-row--hidden', !visible); + + const next = row.nextElementSibling; + if (next && next.classList.contains('packet-detail-row') && !visible) { + next.remove(); + } + } + this._visibleCount = rows.filter(r => !r.classList.contains('packet-row--hidden')).length; + this._updateCountBadge(); } _toggleDetail(tr, packet) { + if (tr.classList.contains('packet-row--hidden')) { + return; + } const next = tr.nextElementSibling; if (next && next.classList.contains('packet-detail-row')) { next.remove(); @@ -174,4 +212,35 @@ class SimplePacketFeed { el.textContent = str; return el.innerHTML; } + + _registerPacketType(type) { + if (!type) return; + const normalized = String(type).toLowerCase(); + if (this._knownTypes.has(normalized)) return; + this._knownTypes.add(normalized); + if (!this._filterSelect) return; + + const option = document.createElement('option'); + option.value = normalized; + option.textContent = String(type).toUpperCase(); + this._filterSelect.appendChild(option); + } + + _applyFilterToRow(tr, packet) { + const packetType = String(packet.packet_type || '').toLowerCase(); + tr.dataset.packetType = packetType; + const visible = this._typeFilter === 'all' || packetType === this._typeFilter; + tr.classList.toggle('packet-row--hidden', !visible); + if (visible) this._visibleCount++; + } + + _updateCountBadge() { + const countEl = document.getElementById('packet-count'); + if (!countEl) return; + if (this._typeFilter === 'all') { + countEl.textContent = String(this._count); + return; + } + countEl.textContent = `${this._visibleCount}/${this._count}`; + } } diff --git a/frontend/js/terminal.js b/frontend/js/terminal.js new file mode 100644 index 0000000..f25460c --- /dev/null +++ b/frontend/js/terminal.js @@ -0,0 +1,601 @@ +/** + * MeshPoint terminal tab — shell over concentrator WebSocket. + * Layout matches New Terminal_MP (status strip, rail, output, input, footer). + * Status strip values come from /api only (no mock / random data). + */ +class MeshTerminal { + constructor() { + this._initialized = false; + this._history = []; + this._historyIdx = -1; + this._pendingId = null; + this._outputEl = null; + this._inputEl = null; + this._connected = false; + this._advancedMode = false; + this._ppmHistory = []; + this._ppmMax = 24; + this._stripTimer = null; + this._acItems = []; + this._acFocusIdx = -1; + + this._promptText = "admin@meshpoint:~$"; + + this._quickCmdsBasic = [ + { label: "meshpoint status", cmd: "meshpoint status" }, + { label: "meshpoint logs", cmd: "meshpoint logs" }, + { label: "meshpoint report", cmd: "meshpoint report" }, + { label: "meshcore radio", cmd: "meshpoint meshcore-radio" }, + { label: "service status", cmd: "systemctl status meshpoint --no-pager" }, + { label: "journal tail", cmd: "journalctl -u meshpoint -n 80 --no-pager" }, + { label: "CPU temp", cmd: "vcgencmd measure_temp" }, + { label: "disk usage", cmd: "df -h" }, + ]; + + this._quickCmdsMeshtastic = [ + { label: "Recent text packets", cmd: "journalctl -u meshpoint -n 200 --no-pager | grep -i \"text\\|packet\\|meshtastic\"" }, + { label: "Concentrator health", cmd: "journalctl -u meshpoint -n 150 --no-pager | grep -i \"sx1302\\|concentrator\\|lgw\"" }, + { label: "RSSI/SNR tail", cmd: "journalctl -u meshpoint -n 200 --no-pager | grep -i \"rssi\\|snr\"" }, + ]; + + this._quickCmdsMeshcore = [ + { label: "MeshCore logs", cmd: "journalctl -u meshpoint -n 200 --no-pager | grep -i meshcore" }, + { label: "USB devices", cmd: "ls /dev/ttyUSB* /dev/ttyACM* 2>/dev/null" }, + { label: "MeshCore reconnect clues", cmd: "journalctl -u meshpoint -n 250 --no-pager | grep -i \"meshcore\\|handshake\\|reconnect\"" }, + ]; + + this._quickCmdsAdvanced = [ + { label: "restart meshpoint", cmd: "sudo systemctl restart meshpoint" }, + { label: "restart watchdog", cmd: "sudo systemctl restart network-watchdog" }, + { label: "re-run setup", cmd: "sudo meshpoint setup" }, + { label: "last boot kernel", cmd: "dmesg | tail -80" }, + { label: "reset concentrator", cmd: "sudo bash /opt/meshpoint/scripts/reset_concentrator.sh" }, + ]; + } + + _apiBase() { + const host = location.hostname || "localhost"; + const port = location.port || "8080"; + return `${location.protocol}//${host}:${port}/api`; + } + + init() { + if (this._initialized) return; + this._initialized = true; + + const panel = document.getElementById("terminal-panel"); + if (!panel) return; + panel.innerHTML = this._buildHTML(); + + this._outputEl = panel.querySelector(".terminal__output"); + this._inputEl = panel.querySelector(".terminal__input"); + this._acListEl = panel.querySelector(".terminal__autocomplete-list"); + this._sparkEl = panel.querySelector(".terminal__ppm-sparkline"); + this._footerReady = panel.querySelector(".terminal__footer-ready"); + this._advancedToggle = panel.querySelector(".terminal__footer-advanced input"); + + panel.querySelector(".terminal__strip-btn--clear").addEventListener("click", () => this._clearOutput()); + + this._inputEl.addEventListener("keydown", (e) => this._onInputKeydown(e)); + this._inputEl.addEventListener("input", () => this._onInputChange()); + + this._advancedToggle.addEventListener("change", () => { + this._advancedMode = this._advancedToggle.checked; + this._applyAdvancedRail(panel); + this._printInfo(this._advancedMode ? "Advanced commands shown in rail." : "Advanced commands hidden."); + this._printBlank(); + }); + + this._buildCmdRail(panel); + this._setupWebSocket(); + this._refreshStatusStrip(); + this._stripTimer = setInterval(() => this._refreshStatusStrip(), 15000); + + this._printInfo("Meshpoint shell ready. Pick a command from the rail or type a shell command."); + this._printInfo("WebSocket must be connected to run commands on the device."); + this._printBlank(); + } + + onActivated() { + if (!this._initialized) this.init(); + this._inputEl?.focus(); + this._refreshStatusStrip(); + } + + _buildHTML() { + return ` +
+
+
+ + SX1302 + +
+
+ + MeshCore USB + +
+
+ RX + +
+
+ ERR + +
+
+ RF + +
+
+
+ pkt/min + + +
+
+ +
+
+
+ +
+
+
+
+
+ ${this._promptText}  + +
+
+ +
+
+
`; + } + + _allRailEntries() { + const out = []; + const push = (label, arr, cls) => { + arr.forEach((q) => out.push({ label: q.label, cmd: q.cmd, cls: cls || "" })); + }; + push("MeshPoint", this._quickCmdsBasic, ""); + push("Meshtastic", this._quickCmdsMeshtastic, ""); + push("MeshCore", this._quickCmdsMeshcore, ""); + if (this._advancedMode) push("Advanced", this._quickCmdsAdvanced, "terminal__cmd-item--advanced"); + return out; + } + + _buildCmdRail(panel) { + const rail = panel.querySelector("#term-cmd-rail"); + if (!rail) return; + const render = () => { + rail.innerHTML = ""; + const groups = [ + { title: "MeshPoint", items: this._quickCmdsBasic }, + { title: "Meshtastic", items: this._quickCmdsMeshtastic }, + { title: "MeshCore", items: this._quickCmdsMeshcore }, + ]; + if (this._advancedMode) { + groups.push({ title: "Advanced", items: this._quickCmdsAdvanced }); + } + groups.forEach((g) => { + const h = document.createElement("div"); + h.className = "terminal__rail-subhdr"; + h.textContent = g.title; + rail.appendChild(h); + g.items.forEach((q) => { + const el = document.createElement("div"); + el.className = "terminal__cmd-item"; + el.innerHTML = `>${this._esc(q.label)}`; + el.title = q.cmd; + el.addEventListener("click", () => { + rail.querySelectorAll(".terminal__cmd-item").forEach((n) => n.classList.remove("terminal__cmd-item--active")); + el.classList.add("terminal__cmd-item--active"); + this._inputEl.value = q.cmd; + this._inputEl.focus(); + this._closeAutocomplete(); + setTimeout(() => el.classList.remove("terminal__cmd-item--active"), 500); + }); + rail.appendChild(el); + }); + }); + }; + render(); + this._renderRail = render; + } + + _applyAdvancedRail(panel) { + if (typeof this._renderRail === "function") this._renderRail(); + } + + _setLamp(el, state) { + if (!el) return; + el.classList.remove("terminal__status-lamp--ok", "terminal__status-lamp--warn", "terminal__status-lamp--err"); + el.classList.add(`terminal__status-lamp--${state}`); + } + + async _refreshStatusStrip() { + const base = this._apiBase(); + let traffic = null; + let status = null; + let metrics = null; + try { + const [r0, r1, r2] = await Promise.all([ + fetch(`${base}/analytics/traffic`).then((r) => (r.ok ? r.json() : null)).catch(() => null), + fetch(`${base}/device/status`).then((r) => (r.ok ? r.json() : null)).catch(() => null), + fetch(`${base}/device/metrics`).then((r) => (r.ok ? r.json() : null)).catch(() => null), + ]); + traffic = r0; + status = r1; + metrics = r2; + } catch { + /* leave dashes */ + } + + const sxEl = document.getElementById("term-val-sx"); + const mcEl = document.getElementById("term-val-mc"); + const rxEl = document.getElementById("term-strip-rx"); + const errEl = document.getElementById("term-strip-err"); + const rfEl = document.getElementById("term-strip-rf"); + const ppmEl = document.getElementById("term-strip-ppm"); + const lampSx = document.getElementById("term-lamp-sx"); + const lampMc = document.getElementById("term-lamp-mc"); + + const sourcesRaw = status?.sources ?? metrics?.sources ?? []; + const sources = (Array.isArray(sourcesRaw) ? sourcesRaw : [sourcesRaw]) + .flat() + .map((s) => String(s).toLowerCase()); + + const sxOn = sources.some((s) => + s.includes("concentrator") || s.includes("sx1302") || s.includes("lgw") || s === "rf" || s.includes("lora")); + const mcOn = sources.some((s) => s.includes("meshcore") || s.includes("ttyusb") || s.includes("ttyacm") || s.includes("companion")); + + if (sources.length) { + this._setLamp(lampSx, sxOn ? "ok" : "warn"); + if (sxEl) sxEl.textContent = sxOn ? "ONLINE" : "OFFLINE"; + this._setLamp(lampMc, mcOn ? "ok" : "warn"); + if (mcEl) mcEl.textContent = mcOn ? "ONLINE" : "OFFLINE"; + } else { + this._setLamp(lampSx, "warn"); + if (sxEl) sxEl.textContent = "—"; + this._setLamp(lampMc, "warn"); + if (mcEl) mcEl.textContent = "—"; + } + + const rate = traffic?.packets_per_minute ?? traffic?.rate_per_min ?? null; + if (ppmEl) { + ppmEl.textContent = rate != null && Number.isFinite(Number(rate)) ? String(Number(rate).toFixed(1)) : "—"; + } + if (rate != null && Number.isFinite(Number(rate))) { + this._ppmHistory.push(Number(rate)); + if (this._ppmHistory.length > this._ppmMax) this._ppmHistory.shift(); + } + this._renderSparkline(); + + const region = status?.region ?? metrics?.region ?? ""; + const mhz = status?.frequency_mhz ?? metrics?.frequency_mhz ?? null; + if (rfEl) { + if (mhz != null && String(region)) rfEl.textContent = `${region} · ${mhz} MHz`; + else if (mhz != null) rfEl.textContent = `${mhz} MHz`; + else if (region) rfEl.textContent = String(region); + else rfEl.textContent = "—"; + } + + const totalRx = + traffic?.total_packets ?? + traffic?.packets_total ?? + traffic?.rx_total ?? + null; + if (rxEl) { + if (totalRx != null && Number.isFinite(Number(totalRx))) rxEl.textContent = `${Number(totalRx).toLocaleString()} packets`; + else rxEl.textContent = "—"; + } + if (errEl) errEl.textContent = "—"; + } + + _renderSparkline() { + if (!this._sparkEl) return; + this._sparkEl.innerHTML = ""; + if (!this._ppmHistory.length) return; + const max = Math.max(...this._ppmHistory, 1e-6); + this._ppmHistory.forEach((v) => { + const b = document.createElement("div"); + b.className = "terminal__ppm-bar"; + b.style.height = `${Math.max(2, Math.round((v / max) * 14))}px`; + this._sparkEl.appendChild(b); + }); + } + + _updateFooterReady() { + if (!this._footerReady) return; + const txt = document.getElementById("term-footer-ready-txt"); + if (this._connected) { + this._footerReady.classList.remove("terminal__footer-ready--offline"); + if (txt) txt.textContent = "READY"; + } else { + this._footerReady.classList.add("terminal__footer-ready--offline"); + if (txt) txt.textContent = "OFFLINE"; + } + } + + _setupWebSocket() { + window.concentratorWS.on("connected", () => { + this._connected = true; + this._updateFooterReady(); + }); + window.concentratorWS.on("disconnected", () => { + this._connected = false; + this._updateFooterReady(); + this._setInputBusy(false); + }); + window.concentratorWS.on("shell_output", (data) => { + if (!data) return; + const { stream, text, exit_code } = data; + if (text != null) { + String(text).split("\n").forEach((line, idx, arr) => { + if (idx === arr.length - 1 && line === "") return; + if (stream === "stderr") this._printError(line); + else this._printStdout(line); + }); + } + if (exit_code != null) { + this._printExitCode(exit_code, exit_code === 0); + this._printSeparator(); + this._setInputBusy(false); + this._pendingId = null; + } + }); + this._updateFooterReady(); + } + + _onInputKeydown(e) { + if (e.key === "Enter") { + e.preventDefault(); + this._closeAutocomplete(); + this._submit(); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + this._closeAutocomplete(); + this._historyPrev(); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + this._closeAutocomplete(); + this._historyNext(); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + this._autocompleteTab(); + return; + } + if (e.key === "Escape") { + this._closeAutocomplete(); + this._inputEl.value = ""; + this._historyIdx = -1; + return; + } + if (e.ctrlKey && e.key === "l") { + e.preventDefault(); + this._clearOutput(); + } + } + + _onInputChange() { + this._historyIdx = -1; + this._openAutocomplete(this._inputEl.value); + } + + _commandPrefixes() { + const set = new Set(); + this._allRailEntries().forEach((e) => { + const first = String(e.cmd).trim().split(/\s+/)[0]; + if (first) set.add(first); + }); + return Array.from(set).sort(); + } + + _openAutocomplete(partial) { + const prefix = partial.trim(); + if (!prefix || !this._acListEl) { + this._closeAutocomplete(); + return; + } + const lower = prefix.toLowerCase(); + this._acItems = this._commandPrefixes().filter((c) => c.toLowerCase().startsWith(lower) && c.length > lower.length); + if (!this._acItems.length) { + const exact = this._commandPrefixes().filter((c) => c.toLowerCase() === lower); + if (exact.length) { + this._closeAutocomplete(); + return; + } + } + if (!this._acItems.length) { + this._closeAutocomplete(); + return; + } + this._acFocusIdx = -1; + this._renderAutocomplete(); + this._acListEl.classList.add("terminal__autocomplete-list--open"); + } + + _renderAutocomplete() { + if (!this._acListEl) return; + this._acListEl.innerHTML = ""; + this._acItems.forEach((name, i) => { + const el = document.createElement("div"); + el.className = "terminal__autocomplete-item" + (i === this._acFocusIdx ? " terminal__autocomplete-item--focused" : ""); + el.innerHTML = `${this._esc(name)}command`; + el.addEventListener("mousedown", (ev) => { + ev.preventDefault(); + this._inputEl.value = `${name} `; + this._closeAutocomplete(); + this._inputEl.focus(); + }); + this._acListEl.appendChild(el); + }); + } + + _closeAutocomplete() { + this._acItems = []; + this._acFocusIdx = -1; + if (this._acListEl) { + this._acListEl.innerHTML = ""; + this._acListEl.classList.remove("terminal__autocomplete-list--open"); + } + } + + _autocompleteTab() { + if (!this._acItems.length) { + this._openAutocomplete(this._inputEl.value); + return; + } + this._acFocusIdx = (this._acFocusIdx + 1) % this._acItems.length; + const pick = this._acItems[this._acFocusIdx]; + this._inputEl.value = `${pick} `; + this._renderAutocomplete(); + } + + _submit() { + const raw = this._inputEl.value.trim(); + if (!raw) return; + if (!this._connected) { + this._printWarn("Not connected to device WebSocket."); + this._printBlank(); + return; + } + + this._printCmd(raw); + this._printBlank(); + this._history.unshift(raw); + if (this._history.length > 100) this._history.pop(); + this._historyIdx = -1; + this._pendingId = `cmd_${Date.now()}`; + this._setInputBusy(true); + + try { + window.concentratorWS.socket.send(JSON.stringify({ + type: "shell_command", + data: { command: raw, command_id: this._pendingId }, + })); + } catch (err) { + this._printError(`Failed to send: ${err.message}`); + this._setInputBusy(false); + } + this._inputEl.value = ""; + this._closeAutocomplete(); + } + + _setInputBusy(busy) { + this._inputEl.disabled = busy; + } + + _historyPrev() { + if (!this._history.length) return; + this._historyIdx = Math.min(this._historyIdx + 1, this._history.length - 1); + this._inputEl.value = this._history[this._historyIdx] || ""; + } + + _historyNext() { + this._historyIdx = Math.max(this._historyIdx - 1, -1); + this._inputEl.value = this._historyIdx >= 0 ? (this._history[this._historyIdx] || "") : ""; + } + + _printCmd(cmd) { + const el = document.createElement("div"); + el.className = "terminal__line terminal__line--prompt"; + el.innerHTML = `${this._esc(this._promptText)} ${this._esc(cmd)}`; + this._outputEl.appendChild(el); + this._scrollBottom(); + } + + _printStdout(text) { + this._line("info", this._esc(text)); + } + + _printError(text) { + this._line("error", this._esc(text)); + } + + _printInfo(text) { + this._line("muted", this._esc(text)); + } + + _printWarn(text) { + this._line("warn", this._esc(text)); + } + + _printBlank() { + const el = document.createElement("div"); + el.className = "terminal__line terminal__line--blank"; + el.innerHTML = ' '; + this._outputEl.appendChild(el); + this._scrollBottom(); + } + + _line(type, html) { + const el = document.createElement("div"); + el.className = `terminal__line terminal__line--${type}`; + el.innerHTML = `${html}`; + this._outputEl.appendChild(el); + this._scrollBottom(); + } + + _printExitCode(code, ok) { + const span = document.createElement("span"); + span.className = `terminal__badge ${ok ? "terminal__badge--green" : "terminal__badge--red"}`; + span.textContent = ok ? `exit ${code}` : `exit ${code}`; + const el = document.createElement("div"); + el.className = "terminal__line terminal__line--muted"; + el.appendChild(span); + this._outputEl.appendChild(el); + this._scrollBottom(); + } + + _printSeparator() { + const hr = document.createElement("hr"); + hr.className = "terminal__boot-sep"; + this._outputEl.appendChild(hr); + this._scrollBottom(); + } + + _clearOutput() { + this._outputEl.innerHTML = ""; + this._printInfo("Output cleared."); + this._printBlank(); + } + + _scrollBottom() { + this._outputEl.scrollTop = this._outputEl.scrollHeight; + } + + _esc(str) { + const el = document.createElement("span"); + el.textContent = str ?? ""; + return el.innerHTML; + } +} + +window.meshTerminal = new MeshTerminal(); diff --git a/frontend/js/websocket_client.js b/frontend/js/websocket_client.js index 2eba6d3..dea3f5b 100644 --- a/frontend/js/websocket_client.js +++ b/frontend/js/websocket_client.js @@ -5,46 +5,21 @@ class ConcentratorWebSocket { this.reconnectDelay = 2000; this.maxReconnectDelay = 30000; this.currentDelay = this.reconnectDelay; - this._everOpened = false; - this._authProbeInFlight = false; } connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${protocol}//${window.location.host}/ws`; - // Cookie auth is the primary contract: a same-origin WS - // upgrade carries the meshpoint_session cookie automatically. - // No query-string token is required for browser clients. this.socket = new WebSocket(url); - this._everOpened = false; this.socket.onopen = () => { this.currentDelay = this.reconnectDelay; - this._everOpened = true; this._emit('connected'); this._updateStatusIndicator(true); }; - this.socket.onclose = (event) => { - if (event && event.code === 4401) { - // Server rejected the upgrade (no session / expired). - // Bounce to /login with the current path as `next`. - const next = encodeURIComponent(location.pathname + location.search); - location.assign(`/login?next=${next}`); - return; - } - // Defense in depth for handshake-time failures: if the - // socket never reached the open state, the close code is - // unreliable across browsers (typically 1006 even when the - // server intended a custom code). A pre-accept reject on - // the server reads as 1006 here, identical to a real - // network blip. Probe an auth-required endpoint so the - // global 401 interceptor in app.js can redirect us if the - // failure is auth-shaped, then schedule a reconnect. - if (!this._everOpened && !this._authProbeInFlight) { - this._probeAuthAndMaybeRedirect(); - } + this.socket.onclose = () => { this._emit('disconnected'); this._updateStatusIndicator(false); this._scheduleReconnect(); @@ -85,22 +60,6 @@ class ConcentratorWebSocket { }, this.currentDelay); } - async _probeAuthAndMaybeRedirect() { - this._authProbeInFlight = true; - try { - // Any auth-gated /api endpoint works. /api/device/status - // is small, idempotent, and present on every Meshpoint. - // The global 401 interceptor in app.js performs the actual - // location.assign('/login?next=...') if this returns 401. - await fetch('/api/device/status', { credentials: 'same-origin' }); - } catch (_) { - /* network-level failure -- not an auth issue, fall through - and let the reconnect schedule keep retrying */ - } finally { - this._authProbeInFlight = false; - } - } - _updateStatusIndicator(connected) { const dot = document.getElementById('ws-status'); const label = document.getElementById('ws-label'); diff --git a/frontend/js/websocket_server_terminal_handler.js b/frontend/js/websocket_server_terminal_handler.js new file mode 100644 index 0000000..9fbe4b4 --- /dev/null +++ b/frontend/js/websocket_server_terminal_handler.js @@ -0,0 +1,90 @@ +/** + * Optional backend helper for shell command WebSocket support. + * This is a Node.js example handler for `shell_command` -> `shell_output`. + * Integrate on your server side if you want the Terminal tab to execute commands. + */ +const { spawn } = require("child_process"); + +const BLOCKED_COMMANDS = [ + "rm -rf /", + "rm -rf ~", + "mkfs", + "dd if=/dev/zero", + ":(){:|:&};:", + "chmod -R 777 /", + "chown -R", + "> /dev/sda", +]; + +const COMMAND_TIMEOUT_MS = 30000; + +function attachTerminalHandler(ws) { + let activeProcess = null; + + ws.on("message", (raw) => { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + if (msg.type !== "shell_command") return; + + const { command, command_id } = msg.data || {}; + if (!command || typeof command !== "string") return; + const cmdLower = command.trim().toLowerCase(); + + for (const blocked of BLOCKED_COMMANDS) { + if (cmdLower.includes(blocked)) { + send(ws, { stream: "stderr", text: `Blocked: "${blocked}"`, command_id }); + send(ws, { exit_code: 1, command_id }); + return; + } + } + + if (activeProcess) { + try { activeProcess.kill(); } catch {} + activeProcess = null; + } + + const proc = spawn("bash", ["-c", command], { + env: { ...process.env, TERM: "xterm-256color" }, + cwd: process.env.HOME || "/", + }); + activeProcess = proc; + + const timeoutHandle = setTimeout(() => { + if (activeProcess === proc) { + try { proc.kill("SIGTERM"); } catch {} + send(ws, { stream: "stderr", text: "Killed: command timed out.", command_id }); + send(ws, { exit_code: 124, command_id }); + activeProcess = null; + } + }, COMMAND_TIMEOUT_MS); + + proc.stdout.on("data", (chunk) => send(ws, { stream: "stdout", text: chunk.toString(), command_id })); + proc.stderr.on("data", (chunk) => send(ws, { stream: "stderr", text: chunk.toString(), command_id })); + proc.on("close", (code) => { + clearTimeout(timeoutHandle); + send(ws, { exit_code: code ?? 0, command_id }); + activeProcess = null; + }); + proc.on("error", (err) => { + clearTimeout(timeoutHandle); + send(ws, { stream: "stderr", text: `Process error: ${err.message}`, command_id }); + send(ws, { exit_code: 1, command_id }); + activeProcess = null; + }); + }); + + ws.on("close", () => { + if (activeProcess) { + try { activeProcess.kill(); } catch {} + activeProcess = null; + } + }); +} + +function send(ws, data) { + try { + if (ws.readyState === 1) ws.send(JSON.stringify({ type: "shell_output", data })); + } catch {} +} + +module.exports = { attachTerminalHandler }; diff --git a/requirements.txt b/requirements.txt index a0aa874..da76421 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,3 @@ psutil>=5.9.0 urllib3>=2.6.3 meshcore>=2.1.0 paho-mqtt>=2.1.0 -bcrypt>=4.2.0 -PyJWT>=2.10.0 diff --git a/src/api/auth/__init__.py b/src/api/auth/__init__.py deleted file mode 100644 index 48e2f18..0000000 --- a/src/api/auth/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Local dashboard authentication primitives. - -Each module in this package owns a single responsibility: - -- ``password_hasher``: bcrypt hash/verify (no I/O, no state). -- ``jwt_session``: JWT issue/decode with ``session_version`` claim. -- ``lockout_tracker``: in-memory failed-login throttling. -- ``dependencies``: FastAPI ``Depends`` shims for routes. - -Higher-level orchestration (routes, middleware, server wiring) lives -in ``src.api.routes`` and ``src.api.server`` and consumes these -modules; nothing in this package imports FastAPI app state directly. -""" diff --git a/src/api/auth/auth_bootstrap.py b/src/api/auth/auth_bootstrap.py deleted file mode 100644 index 40d2720..0000000 --- a/src/api/auth/auth_bootstrap.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Single entrypoint for assembling the auth subsystem at startup. - -Encapsulates three things the rest of ``server.py`` shouldn't have -to know about: - -1. Auto-generating ``jwt_secret`` in memory on first run when none - is configured. The secret is NOT written to ``local.yaml`` here; - ``AuthService.complete_setup`` persists it together with the - admin password hash so a fresh install with no admin password - yet leaves the on-disk config untouched (the setup wizard treats - that as a true fresh install). -2. Wiring ``PasswordHasher`` / ``JwtSessionService`` / ``LockoutTracker`` - into a single ``AuthService`` instance. -3. Returning the ``JwtSessionService`` separately so ``init_auth`` - in the dependencies module can share the exact same instance -- - no chance of two services with mismatched ``session_version``. - -Production callers use ``build_auth_subsystem(config)``. Tests build -the pieces directly so they never touch the filesystem. -""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass - -from src.api.auth.auth_service import AuthService -from src.api.auth.jwt_session import JwtSessionService -from src.api.auth.lockout_tracker import LockoutTracker -from src.api.auth.password_hasher import PasswordHasher -from src.config import AppConfig, WebAuthConfig, save_section_to_yaml - -logger = logging.getLogger(__name__) - - -@dataclass -class AuthSubsystem: - service: AuthService - jwt_service: JwtSessionService - - -def build_auth_subsystem(config: AppConfig) -> AuthSubsystem: - """Assemble the auth subsystem from a loaded ``AppConfig``.""" - web_auth = config.web_auth - _ensure_jwt_secret(web_auth) - - jwt_service = JwtSessionService( - secret=web_auth.jwt_secret, - expiry_minutes=web_auth.jwt_expiry_minutes, - session_version=web_auth.session_version, - ) - auth_service = AuthService( - web_auth=web_auth, - hasher=PasswordHasher(), - lockout=LockoutTracker( - max_attempts=web_auth.lockout_attempts, - cooldown_minutes=web_auth.lockout_cooldown_minutes, - ), - jwt_service=jwt_service, - persist=_make_persister(), - ) - return AuthSubsystem(service=auth_service, jwt_service=jwt_service) - - -def _ensure_jwt_secret(web_auth: WebAuthConfig) -> None: - """Generate a secret in memory if none is configured. - - Deliberately does NOT persist. ``AuthService.complete_setup`` - writes ``jwt_secret`` to ``local.yaml`` alongside the admin - password hash so a fresh install only creates ``local.yaml`` - when the user has actually configured something. Restarting - before ``/setup`` regenerates the in-memory secret, which is - safe because no admin password exists yet -- no session can be - issued, so there is nothing to invalidate. - """ - if web_auth.jwt_secret: - return - web_auth.jwt_secret = JwtSessionService.generate_secret() - logger.info( - "web_auth: generated jwt_secret in memory; will persist on /setup" - ) - - -def _make_persister(): - """Return a callable that writes web_auth field updates to local.yaml.""" - def _persist(values: dict) -> None: - save_section_to_yaml("web_auth", values) - return _persist diff --git a/src/api/auth/auth_service.py b/src/api/auth/auth_service.py deleted file mode 100644 index b47b6e5..0000000 --- a/src/api/auth/auth_service.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Auth orchestration: setup, login, logout flows. - -Single responsibility: glue ``PasswordHasher``, ``JwtSessionService``, -and ``LockoutTracker`` into the three operations the dashboard needs -without ever touching FastAPI itself. Routes call ``AuthService`` and -translate its return values into HTTP responses; CLI tooling -(``meshpoint reset-password``) calls the same service. - -Persistence is injected as a ``ConfigPersister`` callable so tests -never touch the filesystem and so production callers can route the -write through ``save_section_to_yaml`` (which already preserves -unrelated sections). -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Callable, Optional - -from src.api.auth.jwt_session import ( - ROLE_ADMIN, - ROLE_VIEWER, - JwtSessionService, -) -from src.api.auth.lockout_tracker import LockoutTracker -from src.api.auth.password_hasher import PasswordHasher -from src.config import WebAuthConfig - -_MIN_PASSWORD_LENGTH = 8 -_MAX_PASSWORD_LENGTH = 256 - - -ConfigPersister = Callable[[dict], None] - - -@dataclass(frozen=True) -class LoginSuccess: - token: str - role: str - - -@dataclass(frozen=True) -class LoginFailure: - reason: str - retry_after_seconds: Optional[int] = None - - -@dataclass(frozen=True) -class SetupSuccess: - token: str - - -@dataclass(frozen=True) -class SetupRejected: - reason: str - - -class AuthService: - """Stateless orchestrator over the three auth primitives. - - A single instance is created at app startup and shared across - requests. All mutable state (failed-login counters, the rolling - JWT secret) lives in the injected collaborators -- the service - itself only routes calls. - """ - - def __init__( - self, - web_auth: WebAuthConfig, - hasher: PasswordHasher, - lockout: LockoutTracker, - jwt_service: JwtSessionService, - persist: ConfigPersister, - ) -> None: - self._config = web_auth - self._hasher = hasher - self._lockout = lockout - self._jwt = jwt_service - self._persist = persist - - @property - def config(self) -> WebAuthConfig: - return self._config - - def is_setup_complete(self) -> bool: - return bool(self._config.admin_password_hash) - - def complete_setup(self, password: str) -> SetupSuccess | SetupRejected: - """Hash the supplied password and persist it as the admin hash. - - Also writes ``jwt_secret`` so a fresh install picks up its - in-memory bootstrap secret on the same disk write -- avoids - polluting ``local.yaml`` before the user has actually - configured anything (see ``auth_bootstrap``). - - Returns ``SetupRejected`` if setup has already happened (LAN - attacker prevention) or if the password fails the policy check. - """ - if self.is_setup_complete(): - return SetupRejected("already_set") - rejection = _validate_password(password) - if rejection is not None: - return SetupRejected(rejection) - - hashed = self._hasher.hash(password) - self._config.admin_password_hash = hashed - self._persist({ - "admin_password_hash": hashed, - "jwt_secret": self._config.jwt_secret, - }) - token = self._jwt.issue(subject="admin", role=ROLE_ADMIN) - return SetupSuccess(token=token) - - def login(self, username: str, password: str) -> LoginSuccess | LoginFailure: - """Validate credentials and return a session token. - - ``username`` is restricted to ``admin`` / ``viewer``. Every - other value short-circuits to ``invalid_credentials`` so we - never expose which usernames exist. - """ - if not self.is_setup_complete(): - return LoginFailure("setup_required") - - normalized = (username or "").strip().lower() - cooldown = self._lockout.remaining_seconds(normalized) - if cooldown is not None: - return LoginFailure("locked_out", retry_after_seconds=cooldown) - - stored_hash = self._hash_for(normalized) - if stored_hash is None or not self._hasher.verify(password, stored_hash): - triggered = self._lockout.register_failure(normalized) - return LoginFailure( - "invalid_credentials", retry_after_seconds=triggered - ) - - self._lockout.register_success(normalized) - role = ROLE_ADMIN if normalized == "admin" else ROLE_VIEWER - token = self._jwt.issue(subject=normalized, role=role) - return LoginSuccess(token=token, role=role) - - def _hash_for(self, normalized_username: str) -> Optional[str]: - if normalized_username == "admin": - return self._config.admin_password_hash or None - if normalized_username == "viewer": - return self._config.viewer_password_hash or None - return None - - -def _validate_password(password: str) -> Optional[str]: - if not isinstance(password, str): - return "invalid_password" - if len(password) < _MIN_PASSWORD_LENGTH: - return "password_too_short" - if len(password) > _MAX_PASSWORD_LENGTH: - return "password_too_long" - return None diff --git a/src/api/auth/dependencies.py b/src/api/auth/dependencies.py deleted file mode 100644 index 744705d..0000000 --- a/src/api/auth/dependencies.py +++ /dev/null @@ -1,111 +0,0 @@ -"""FastAPI dependencies for the local dashboard auth contract. - -Exposes three callables routes can pin via ``Depends``: - -- ``require_auth`` -- reject unless a valid session is present. -- ``require_admin`` -- reject unless the session is the admin role. -- ``optional_auth`` -- attach claims if present, never reject. - -Module-level state is injected once at app boot via ``init_auth``, -mirroring the pattern used by ``src.api.routes.messages.init_routes`` -so route modules stay simple to test (drop in a fresh service). - -Token extraction order: -1. ``Cookie: meshpoint_session=...`` (browser default; HttpOnly, - SameSite=Lax). -2. ``Authorization: Bearer `` (curl / non-browser clients). - -Failure modes uniformly produce a 401 with a static body so we don't -leak which step rejected the request. -""" - -from __future__ import annotations - -from typing import NoReturn, Optional - -from fastapi import Header, HTTPException, Request, status - -from src.api.auth.jwt_session import ( - ROLE_ADMIN, - JwtSessionService, - SessionClaims, -) - -SESSION_COOKIE_NAME = "meshpoint_session" -_BEARER_PREFIX = "Bearer " - -_jwt_service: JwtSessionService | None = None - - -def init_auth(jwt_service: JwtSessionService) -> None: - """Bind the JWT service used by all dependencies in this module.""" - global _jwt_service - _jwt_service = jwt_service - - -def reset_auth() -> None: - """Test helper: clear module-level state between cases.""" - global _jwt_service - _jwt_service = None - - -def _extract_token(request: Request, authorization: Optional[str]) -> str: - cookie_token = request.cookies.get(SESSION_COOKIE_NAME) - if cookie_token: - return cookie_token - if authorization and authorization.startswith(_BEARER_PREFIX): - return authorization[len(_BEARER_PREFIX):].strip() - return "" - - -def _claims_or_none(token: str) -> Optional[SessionClaims]: - if _jwt_service is None or not token: - return None - return _jwt_service.verify(token) - - -def _raise_unauthorized() -> NoReturn: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="authentication required", - headers={"WWW-Authenticate": "Bearer"}, - ) - - -def _raise_forbidden() -> NoReturn: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="admin role required", - ) - - -async def require_auth( - request: Request, - authorization: Optional[str] = Header(default=None), -) -> SessionClaims: - """Dependency: 401 unless a valid session is presented.""" - if _jwt_service is None: - _raise_unauthorized() - claims = _claims_or_none(_extract_token(request, authorization)) - if claims is None: - _raise_unauthorized() - return claims - - -async def require_admin( - request: Request, - authorization: Optional[str] = Header(default=None), -) -> SessionClaims: - """Dependency: 401 unless authed, 403 if authed but not admin.""" - claims = await require_auth(request, authorization) - if claims.role != ROLE_ADMIN: - _raise_forbidden() - return claims - - -async def optional_auth( - request: Request, - authorization: Optional[str] = Header(default=None), -) -> Optional[SessionClaims]: - """Dependency: returns claims if presented, ``None`` otherwise.""" - return _claims_or_none(_extract_token(request, authorization)) diff --git a/src/api/auth/jwt_session.py b/src/api/auth/jwt_session.py deleted file mode 100644 index 67bedcc..0000000 --- a/src/api/auth/jwt_session.py +++ /dev/null @@ -1,117 +0,0 @@ -"""JWT session issuance and verification. - -Single responsibility: turn a (subject, role) pair into a signed -JWT and turn a JWT back into validated ``SessionClaims`` -- or -return ``None`` for any failure case (expired, bad signature, wrong -algorithm, mismatched ``session_version``, missing claim). - -Two invalidation knobs are exposed by design: - -- ``secret`` rotation invalidates **everything** signed by the - previous secret (used by ``meshpoint reset-password``). -- ``session_version`` lets the operator bump a counter to drop all - outstanding sessions without rotating the secret -- handy after - policy changes (e.g. password rotation, role downgrade). - -Algorithm is pinned to HS256: callers supply a symmetric secret and -no public key path exists, so we never want PyJWT to silently honor -``alg: none`` or RS256-with-attacker-supplied-key. ``decode`` is -called with ``algorithms=["HS256"]`` to enforce that. -""" - -from __future__ import annotations - -import secrets -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from typing import Optional - -import jwt - -ROLE_ADMIN = "admin" -ROLE_VIEWER = "viewer" -_VALID_ROLES = frozenset({ROLE_ADMIN, ROLE_VIEWER}) - -_ALGORITHM = "HS256" -_SECRET_BYTES = 32 - - -@dataclass(frozen=True) -class SessionClaims: - """Validated claims surfaced to route-level auth dependencies.""" - - subject: str - role: str - session_version: int - - -class JwtSessionService: - """Issue and verify JWTs for the local dashboard session cookie.""" - - def __init__( - self, - secret: str, - expiry_minutes: int, - session_version: int, - ) -> None: - if not secret: - raise ValueError("jwt secret must not be empty") - if expiry_minutes <= 0: - raise ValueError("expiry_minutes must be positive") - if session_version < 1: - raise ValueError("session_version must be >= 1") - self._secret = secret - self._expiry = timedelta(minutes=expiry_minutes) - self._session_version = session_version - - @staticmethod - def generate_secret() -> str: - """Return a fresh, URL-safe secret for first-run bootstrapping.""" - return secrets.token_urlsafe(_SECRET_BYTES) - - def issue(self, subject: str, role: str) -> str: - """Sign and return a JWT for (subject, role). - - Raises ``ValueError`` for empty subject or unknown role; both - are programming errors that should never reach runtime. - """ - if not subject: - raise ValueError("subject must not be empty") - if role not in _VALID_ROLES: - raise ValueError(f"role must be one of {sorted(_VALID_ROLES)}") - now = datetime.now(timezone.utc) - payload = { - "sub": subject, - "role": role, - "sv": self._session_version, - "iat": int(now.timestamp()), - "exp": int((now + self._expiry).timestamp()), - } - return jwt.encode(payload, self._secret, algorithm=_ALGORITHM) - - def verify(self, token: str) -> Optional[SessionClaims]: - """Return validated claims, or ``None`` for any failure mode.""" - if not token: - return None - try: - payload = jwt.decode( - token, - self._secret, - algorithms=[_ALGORITHM], - options={"require": ["exp", "iat", "sub", "role", "sv"]}, - ) - except jwt.PyJWTError: - return None - - subject = payload.get("sub") - role = payload.get("role") - token_sv = payload.get("sv") - if not isinstance(subject, str) or not subject: - return None - if role not in _VALID_ROLES: - return None - if not isinstance(token_sv, int) or token_sv != self._session_version: - return None - return SessionClaims( - subject=subject, role=role, session_version=token_sv - ) diff --git a/src/api/auth/lockout_tracker.py b/src/api/auth/lockout_tracker.py deleted file mode 100644 index ba55552..0000000 --- a/src/api/auth/lockout_tracker.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Per-key failed-login lockout tracker. - -Single responsibility: keep an in-memory tally of failed login -attempts keyed by a caller-supplied identifier (typically the -attempted username, or a remote-IP fallback for anonymous probes) -and answer two questions: - -- "Is this key currently locked?" -> ``remaining_seconds()`` -- "Record a failure / success" -> ``register_failure``, ``register_success`` - -Storage is a plain dict guarded by ``threading.Lock``, so the tracker -is safe to share across the FastAPI worker threadpool. There is no -persistence: a process restart resets all counters by design (the -attacker would have already triggered a config-rotation alarm by -then). - -The clock is injected (``clock=time.monotonic`` by default) so unit -tests can advance time without sleeping. -""" - -from __future__ import annotations - -import threading -import time -from dataclasses import dataclass -from typing import Callable, Optional - -_DEFAULT_MAX_ATTEMPTS = 5 -_DEFAULT_COOLDOWN_MINUTES = 5 - - -@dataclass -class _AttemptState: - failures: int = 0 - locked_until: float = 0.0 - - -class LockoutTracker: - """Throttle repeated failed logins per key with a fixed cooldown.""" - - def __init__( - self, - max_attempts: int = _DEFAULT_MAX_ATTEMPTS, - cooldown_minutes: int = _DEFAULT_COOLDOWN_MINUTES, - clock: Callable[[], float] = time.monotonic, - ) -> None: - if max_attempts < 1: - raise ValueError("max_attempts must be >= 1") - if cooldown_minutes < 1: - raise ValueError("cooldown_minutes must be >= 1") - self._max_attempts = max_attempts - self._cooldown_seconds = cooldown_minutes * 60 - self._clock = clock - self._states: dict[str, _AttemptState] = {} - self._lock = threading.Lock() - - @property - def max_attempts(self) -> int: - return self._max_attempts - - def remaining_seconds(self, key: str) -> Optional[int]: - """Return seconds until ``key`` unlocks, or ``None`` if not locked.""" - if not key: - return None - with self._lock: - state = self._states.get(key) - if state is None or state.locked_until == 0.0: - return None - now = self._clock() - if now >= state.locked_until: - self._states.pop(key, None) - return None - return int(state.locked_until - now) + 1 - - def register_failure(self, key: str) -> Optional[int]: - """Record a failed attempt for ``key``. - - Returns the cooldown in seconds if the failure crossed the - threshold, or ``None`` while the caller still has tries left. - """ - if not key: - return None - with self._lock: - state = self._states.setdefault(key, _AttemptState()) - now = self._clock() - if state.locked_until > now: - return int(state.locked_until - now) + 1 - state.failures += 1 - if state.failures >= self._max_attempts: - state.locked_until = now + self._cooldown_seconds - return self._cooldown_seconds - return None - - def register_success(self, key: str) -> None: - """Clear all state for ``key`` after a successful login.""" - if not key: - return - with self._lock: - self._states.pop(key, None) diff --git a/src/api/auth/password_hasher.py b/src/api/auth/password_hasher.py deleted file mode 100644 index 703acd4..0000000 --- a/src/api/auth/password_hasher.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Bcrypt password hashing wrapper. - -Single responsibility: turn a plaintext password into a bcrypt hash -and verify a candidate against a stored hash without leaking timing -information. No file or network I/O, no global state -- callers -inject a ``PasswordHasher`` instance so tests can stub the work -factor down for fast unit tests. - -Production usage: - - hasher = PasswordHasher() # rounds=12 by default - stored = hasher.hash(plaintext) # store on /setup - ok = hasher.verify(candidate, stored) # check on /login - -The class deliberately swallows malformed-hash errors during -``verify`` and returns ``False`` rather than raising. That keeps -callers from leaking "this hash was malformed" vs "wrong password" -through differing exception paths. -""" - -from __future__ import annotations - -import bcrypt - -_MIN_ROUNDS = 4 -_MAX_ROUNDS = 16 -_DEFAULT_ROUNDS = 12 - - -class PasswordHasher: - """Stateless bcrypt hash + verify, with a configurable work factor. - - ``rounds`` is the bcrypt cost parameter (work factor = ``2**rounds``). - Production defaults to 12 (~250ms on modern hardware); tests pass - ``rounds=4`` to keep the suite fast. - """ - - def __init__(self, rounds: int = _DEFAULT_ROUNDS) -> None: - if not _MIN_ROUNDS <= rounds <= _MAX_ROUNDS: - raise ValueError( - f"rounds must be in [{_MIN_ROUNDS}, {_MAX_ROUNDS}], got {rounds}" - ) - self._rounds = rounds - - @property - def rounds(self) -> int: - return self._rounds - - def hash(self, plaintext: str) -> str: - """Return a bcrypt hash for ``plaintext`` as an ASCII string.""" - if not isinstance(plaintext, str): - raise TypeError("plaintext must be str") - if plaintext == "": - raise ValueError("plaintext password must not be empty") - salt = bcrypt.gensalt(rounds=self._rounds) - digest = bcrypt.hashpw(plaintext.encode("utf-8"), salt) - return digest.decode("ascii") - - def verify(self, candidate: str, stored_hash: str) -> bool: - """Return True iff ``candidate`` matches ``stored_hash``. - - Returns ``False`` (never raises) if ``stored_hash`` is empty, - malformed, or otherwise unparseable. Callers should treat - an empty stored hash as "auth not configured" upstream. - """ - if not isinstance(candidate, str) or not isinstance(stored_hash, str): - return False - if candidate == "" or stored_hash == "": - return False - try: - return bcrypt.checkpw( - candidate.encode("utf-8"), stored_hash.encode("ascii") - ) - except (ValueError, TypeError): - return False diff --git a/src/api/auth/ws_guard.py b/src/api/auth/ws_guard.py deleted file mode 100644 index b50337f..0000000 --- a/src/api/auth/ws_guard.py +++ /dev/null @@ -1,65 +0,0 @@ -"""WebSocket handshake auth gate. - -Single responsibility: turn a ``WebSocket`` upgrade request into a -validated ``SessionClaims`` (or ``None``) using the same JWT service -that protects the REST API. Keeps the auth contract -- cookie -primary, ``?token=`` fallback -- in one place so the frontend has a -stable target. - -Usage from ``server.py`` (or any other WS endpoint) is: - - claims = authenticate_websocket(websocket, jwt_service) - if claims is None: - await websocket.close(code=4401) - return - await ws_manager.connect(websocket) - -The ``4401`` close code is a private-use frame the auth-aware -frontend client interprets as "redirect to /login". -""" - -from __future__ import annotations - -import logging -from typing import Optional - -from fastapi import WebSocket - -from src.api.auth.dependencies import SESSION_COOKIE_NAME -from src.api.auth.jwt_session import JwtSessionService, SessionClaims - -logger = logging.getLogger(__name__) - -WS_AUTH_CLOSE_CODE = 4401 - - -def authenticate_websocket( - websocket: WebSocket, jwt_service: Optional[JwtSessionService] -) -> Optional[SessionClaims]: - """Return validated session claims for a WS upgrade or ``None``.""" - if jwt_service is None: - return None - token = _extract_token(websocket) - if not token: - return None - claims = jwt_service.verify(token) - if claims is None: - logger.info( - "WS auth rejected (token prefix=%s)", _safe_prefix(token) - ) - return claims - - -def _extract_token(websocket: WebSocket) -> str: - cookie = websocket.cookies.get(SESSION_COOKIE_NAME) - if cookie: - return cookie - query_token = websocket.query_params.get("token") - if query_token: - return query_token - return "" - - -def _safe_prefix(token: str, length: int = 12) -> str: - """Trim a token to a length we're comfortable putting in logs.""" - return (token[:length] + "...") if len(token) > length else "***" diff --git a/src/api/routes/auth_routes.py b/src/api/routes/auth_routes.py deleted file mode 100644 index d15b844..0000000 --- a/src/api/routes/auth_routes.py +++ /dev/null @@ -1,156 +0,0 @@ -"""HTTP shell for the local-dashboard auth flow. - -Three endpoints sit on this router: - -- ``POST /api/auth/setup`` -- one-shot first-run admin password set. -- ``POST /api/auth/login`` -- credential validation + cookie issue. -- ``POST /api/auth/logout`` -- cookie clear (JWT is stateless on the - server -- ``session_version`` rotation is the global revocation). - -The handlers stay thin: they delegate every decision to -``AuthService`` and translate ``LoginFailure`` / ``SetupRejected`` -return values into HTTP responses. All cookie hardening (HttpOnly, -SameSite=Lax, Secure-when-HTTPS) is applied here in one place so the -contract is auditable. -""" - -from __future__ import annotations - -import logging - -from fastapi import APIRouter, HTTPException, Request, Response, status -from pydantic import BaseModel, Field - -from src.api.auth.auth_service import ( - AuthService, - LoginFailure, - LoginSuccess, - SetupRejected, - SetupSuccess, -) -from src.api.auth.dependencies import SESSION_COOKIE_NAME - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/auth", tags=["auth"]) - -_auth_service: AuthService | None = None - - -def init_routes(auth_service: AuthService) -> None: - """Bind the AuthService used by every handler in this module.""" - global _auth_service - _auth_service = auth_service - - -def reset_routes() -> None: - """Test helper: clear module-level state between cases.""" - global _auth_service - _auth_service = None - - -class SetupRequest(BaseModel): - password: str = Field(..., min_length=1, max_length=512) - - -class LoginRequest(BaseModel): - username: str = Field(..., min_length=1, max_length=64) - password: str = Field(..., min_length=1, max_length=512) - - -def _service() -> AuthService: - if _auth_service is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="auth not initialized", - ) - return _auth_service - - -def _set_session_cookie( - request: Request, response: Response, token: str -) -> None: - """Apply hardened cookie attributes for the session JWT.""" - is_https = request.url.scheme == "https" - expiry_minutes = _service().config.jwt_expiry_minutes - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=token, - max_age=expiry_minutes * 60, - httponly=True, - secure=is_https, - samesite="lax", - path="/", - ) - - -def _clear_session_cookie(response: Response) -> None: - response.delete_cookie( - key=SESSION_COOKIE_NAME, - path="/", - ) - - -def _reject_setup(rejection: SetupRejected) -> HTTPException: - if rejection.reason == "already_set": - return HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="already_set", - ) - return HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=rejection.reason, - ) - - -def _reject_login(failure: LoginFailure) -> HTTPException: - if failure.reason == "locked_out": - headers = ( - {"Retry-After": str(failure.retry_after_seconds)} - if failure.retry_after_seconds is not None - else None - ) - return HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="locked_out", - headers=headers, - ) - if failure.reason == "setup_required": - return HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="setup_required", - ) - return HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="invalid_credentials", - ) - - -@router.post("/setup") -async def setup_password( - payload: SetupRequest, request: Request, response: Response -) -> dict: - result = _service().complete_setup(payload.password) - if isinstance(result, SetupSuccess): - _set_session_cookie(request, response, result.token) - logger.info("admin password set via /api/auth/setup") - return {"role": "admin"} - raise _reject_setup(result) - - -@router.post("/login") -async def login( - payload: LoginRequest, request: Request, response: Response -) -> dict: - result = _service().login(payload.username, payload.password) - if isinstance(result, LoginSuccess): - _set_session_cookie(request, response, result.token) - logger.info("user logged in (role=%s)", result.role) - return {"role": result.role} - raise _reject_login(result) - - -@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) -async def logout(response: Response) -> None: - _clear_session_cookie(response) - response.status_code = status.HTTP_204_NO_CONTENT diff --git a/src/api/routes/identity_routes.py b/src/api/routes/identity_routes.py deleted file mode 100644 index aaf7db9..0000000 --- a/src/api/routes/identity_routes.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Public identity endpoint: ``GET /api/identity``. - -Read-only, **always allowlisted** (no auth required) so the auth -pages can render the identity strip and decide whether to redirect -to ``/setup`` before any session exists. - -Strict allowlist of fields -- nothing here may leak node IDs, -positions, hardware fingerprints, or activation tokens. The auth -plan calls this out as a security boundary: anyone on the LAN can -hit ``/api/identity``, so the response must be safe for that -audience. -""" - -from __future__ import annotations - -from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel - -from src.api.auth.auth_service import AuthService -from src.models.device_identity import DeviceIdentity - -router = APIRouter(prefix="/api", tags=["identity"]) - -_identity: DeviceIdentity | None = None -_auth_service: AuthService | None = None - - -def init_routes(identity: DeviceIdentity, auth_service: AuthService) -> None: - """Bind device identity + auth service used by the handler.""" - global _identity, _auth_service - _identity = identity - _auth_service = auth_service - - -def reset_routes() -> None: - """Test helper: clear module-level state between cases.""" - global _identity, _auth_service - _identity = None - _auth_service = None - - -class IdentityResponse(BaseModel): - device_name: str - firmware_version: str - setup_required: bool - - -@router.get("/identity", response_model=IdentityResponse) -async def get_identity() -> IdentityResponse: - if _identity is None or _auth_service is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="identity not initialized", - ) - return IdentityResponse( - device_name=_identity.device_name, - firmware_version=_identity.firmware_version, - setup_required=not _auth_service.is_setup_complete(), - ) diff --git a/src/api/server.py b/src/api/server.py index 49d977e..011c7df 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -1,23 +1,20 @@ from __future__ import annotations +import asyncio +import json import logging +import re from contextlib import asynccontextmanager from pathlib import Path -from fastapi import Depends, FastAPI, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse, RedirectResponse +from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.staticfiles import StaticFiles from src._so_compat_check import warn_if_stale_so_files from src.analytics.network_mapper import NetworkMapper from src.analytics.signal_analyzer import SignalAnalyzer from src.analytics.traffic_monitor import TrafficMonitor -from src.api.auth import dependencies as auth_deps -from src.api.auth.auth_bootstrap import AuthSubsystem, build_auth_subsystem -from src.api.auth.dependencies import SESSION_COOKIE_NAME, require_auth -from src.api.auth.jwt_session import JwtSessionService -from src.api.auth.ws_guard import WS_AUTH_CLOSE_CODE, authenticate_websocket -from src.api.routes import analytics, auth_routes, config_routes, device, identity_routes, messages, nodeinfo_routes, nodes, packets, stats_routes, system_metrics, telemetry, update_check +from src.api.routes import analytics, config_routes, device, messages, nodeinfo_routes, nodes, packets, stats_routes, system_metrics, telemetry, update_check from src.api.upstream_client import UpstreamClient from src.api.websocket_manager import WebSocketManager from src.config import AppConfig, load_config, validate_activation @@ -41,15 +38,23 @@ upstream: UpstreamClient | None = None nodeinfo_broadcaster: NodeInfoBroadcaster | None = None +_SHELL_BLOCK_PATTERNS = ( + r"rm\s+-rf\s+/", + r"mkfs", + r"dd\s+if=/dev/zero", + r":\(\)\{:\|:&\};:", + r"chmod\s+-R\s+777\s+/", + r"chown\s+-R\s+/", + r">\s*/dev/sd[a-z]", +) + +_SHELL_TIMEOUT_SECONDS = 30 + def create_app(config: AppConfig | None = None) -> FastAPI: if config is None: config = load_config() - auth_subsystem = build_auth_subsystem(config) - auth_routes.init_routes(auth_subsystem.service) - auth_deps.init_auth(auth_subsystem.jwt_service) - @asynccontextmanager async def lifespan(app: FastAPI): global pipeline, upstream, nodeinfo_broadcaster @@ -99,9 +104,7 @@ async def lifespan(app: FastAPI): if nodeinfo_broadcaster is not None: await nodeinfo_broadcaster.start() - _init_routes( - pipeline, config, identity, auth_subsystem, tx_service, message_repo - ) + _init_routes(pipeline, config, identity, tx_service, message_repo) print_banner(config) logger.info("Meshpoint started -- listening for packets") yield @@ -117,60 +120,29 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) - app.include_router(auth_routes.router) - app.include_router(identity_routes.router) - - protected = [Depends(require_auth)] - app.include_router(nodes.router, dependencies=protected) - app.include_router(packets.router, dependencies=protected) - app.include_router(analytics.router, dependencies=protected) - app.include_router(device.router, dependencies=protected) - app.include_router(system_metrics.router, dependencies=protected) - app.include_router(telemetry.router, dependencies=protected) - app.include_router(update_check.router, dependencies=protected) - app.include_router(messages.router, dependencies=protected) - app.include_router(nodeinfo_routes.router, dependencies=protected) - app.include_router(config_routes.router, dependencies=protected) - app.include_router(stats_routes.router, dependencies=protected) + app.include_router(nodes.router) + app.include_router(packets.router) + app.include_router(analytics.router) + app.include_router(device.router) + app.include_router(system_metrics.router) + app.include_router(telemetry.router) + app.include_router(update_check.router) + app.include_router(messages.router) + app.include_router(nodeinfo_routes.router) + app.include_router(config_routes.router) + app.include_router(stats_routes.router) @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): - if not await _gate_ws_or_close( - websocket, auth_subsystem.jwt_service - ): - return await ws_manager.connect(websocket) try: while True: - await websocket.receive_text() + raw = await websocket.receive_text() + await _handle_ws_message(websocket, raw) except WebSocketDisconnect: await ws_manager.disconnect(websocket) static_dir = Path(config.dashboard.static_dir) - - @app.get("/setup", include_in_schema=False) - async def serve_setup_page(): - return _serve_auth_page(static_dir, "setup.html") - - @app.get("/login", include_in_schema=False) - async def serve_login_page(): - return _serve_auth_page(static_dir, "login.html") - - @app.get("/", include_in_schema=False) - async def serve_dashboard_root(request: Request): - # Gate the dashboard HTML behind auth with a 302 redirect so - # the browser lands on /login (or /setup) instead of loading - # cached SPA JS that then fights an unauthenticated /ws upgrade. - # Registered BEFORE the StaticFiles mount so this route wins. - if not _request_has_valid_session( - request, auth_subsystem.jwt_service - ): - target = "/login" if auth_subsystem.service.is_setup_complete() else "/setup" - return RedirectResponse(url=target, status_code=302) - return FileResponse( - str(static_dir / "index.html"), media_type="text/html" - ) - if static_dir.exists(): app.mount("/", StaticFiles(directory=str(static_dir), html=True)) @@ -715,11 +687,9 @@ def _init_routes( coord: PipelineCoordinator, config: AppConfig, identity: DeviceIdentity, - auth_subsystem: AuthSubsystem, tx_service: TxService | None = None, message_repo: MessageRepository | None = None, ) -> None: - identity_routes.init_routes(identity, auth_subsystem.service) network_mapper = NetworkMapper(coord.node_repo) signal_analyzer = SignalAnalyzer(coord.packet_repo) traffic_monitor = TrafficMonitor(coord.packet_repo) @@ -763,56 +733,6 @@ def _init_routes( ) -async def _gate_ws_or_close( - websocket: WebSocket, jwt_service: JwtSessionService | None -) -> bool: - """Authenticate a WS upgrade; return True iff the caller may proceed. - - On rejection: completes the WS handshake with ``accept()`` BEFORE - closing with the custom 4401 code. This sequencing matters -- - closing pre-accept causes Starlette to fail the upgrade with HTTP - 403, which browsers translate to JS close code ``1006`` (Abnormal - Closure) instead of the negotiated ``4401``. The dashboard's WS - client only redirects to ``/login`` on ``4401``, so the pre-accept - close stranded users in an indefinite reconnect loop. Caught in - the wild on v0.7.3 (Willard, Discord, 2026-05-13). - """ - claims = authenticate_websocket(websocket, jwt_service) - if claims is not None: - return True - await websocket.accept() - await websocket.close(code=WS_AUTH_CLOSE_CODE) - return False - - -def _request_has_valid_session( - request: Request, jwt_service: JwtSessionService | None -) -> bool: - """Quick cookie-only check used by the dashboard root gate. - - Mirrors ``dependencies._claims_or_none`` but takes the service - explicitly so the route can run even before ``init_auth`` (e.g. - in tests that exercise pre-bootstrap states). Cookie-only by - design: bearer-token clients have no business hitting the SPA - root, they should call ``/api/...`` directly. - """ - if jwt_service is None: - return False - token = request.cookies.get(SESSION_COOKIE_NAME) - if not token: - return False - return jwt_service.verify(token) is not None - - -def _serve_auth_page(static_dir: Path, filename: str) -> FileResponse: - """Return one of the two pre-auth HTML pages from frontend/auth/.""" - page = static_dir / "auth" / filename - if not page.exists(): - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="auth page not found") - return FileResponse(str(page), media_type="text/html") - - def _on_packet_received(packet: Packet) -> None: import asyncio try: @@ -820,3 +740,118 @@ def _on_packet_received(packet: Packet) -> None: loop.create_task(ws_manager.broadcast("packet", packet.to_dict())) except RuntimeError: pass + + +async def _handle_ws_message(websocket: WebSocket, raw: str) -> None: + """Handle inbound dashboard websocket messages.""" + try: + message = json.loads(raw) + except json.JSONDecodeError: + return + + event_type = message.get("type") + data = message.get("data") or {} + + if event_type == "shell_command": + command = data.get("command", "") + command_id = data.get("command_id", "") + await _run_shell_command(websocket, command, command_id) + + +async def _run_shell_command( + websocket: WebSocket, + command: str, + command_id: str, +) -> None: + """Execute a bounded shell command and stream output to caller.""" + if not command or not isinstance(command, str): + return + + cmd_lower = command.strip().lower() + for pattern in _SHELL_BLOCK_PATTERNS: + if re.search(pattern, cmd_lower): + await _send_shell_output( + websocket, + stream="stderr", + text=f'Blocked command pattern: "{pattern}"', + command_id=command_id, + ) + await _send_shell_output( + websocket, + exit_code=1, + command_id=command_id, + ) + return + + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd="/opt/meshpoint", + ) + + async def _stream_output(stream, stream_name: str) -> None: + while True: + line = await stream.readline() + if not line: + break + await _send_shell_output( + websocket, + stream=stream_name, + text=line.decode(errors="replace"), + command_id=command_id, + ) + + stdout_task = asyncio.create_task(_stream_output(process.stdout, "stdout")) + stderr_task = asyncio.create_task(_stream_output(process.stderr, "stderr")) + + try: + exit_code = await asyncio.wait_for( + process.wait(), + timeout=_SHELL_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + process.kill() + await process.wait() + await _send_shell_output( + websocket, + stream="stderr", + text=( + f"Killed: command exceeded " + f"{_SHELL_TIMEOUT_SECONDS}s timeout." + ), + command_id=command_id, + ) + exit_code = 124 + + await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) + await _send_shell_output( + websocket, + exit_code=exit_code, + command_id=command_id, + ) + + +async def _send_shell_output( + websocket: WebSocket, + stream: str | None = None, + text: str | None = None, + exit_code: int | None = None, + command_id: str | None = None, +) -> None: + payload: dict[str, object] = {} + if stream is not None: + payload["stream"] = stream + if text is not None: + payload["text"] = text + if exit_code is not None: + payload["exit_code"] = exit_code + if command_id is not None: + payload["command_id"] = command_id + + try: + await websocket.send_text( + json.dumps({"type": "shell_output", "data": payload}) + ) + except Exception: + logger.debug("Failed sending shell output to websocket client", exc_info=True) diff --git a/src/cli/main.py b/src/cli/main.py index 62ba4e9..c281dcd 100644 --- a/src/cli/main.py +++ b/src/cli/main.py @@ -57,11 +57,6 @@ def cmd_meshcore_radio(args: argparse.Namespace) -> None: run_meshcore_radio(args) -def cmd_reset_password(_args: argparse.Namespace) -> None: - from src.cli.reset_password_command import run_reset_password - sys.exit(run_reset_password()) - - def cmd_version(_args: argparse.Namespace) -> None: print(f" Meshpoint v{VERSION}") @@ -93,11 +88,6 @@ def main() -> None: help="Serial port override (auto-detected if omitted)", ) - sub.add_parser( - "reset-password", - help="Reset the dashboard admin password (invalidates open sessions)", - ) - sub.add_parser("version", help="Print version information") args = parser.parse_args() @@ -110,7 +100,6 @@ def main() -> None: "restart": cmd_restart, "stop": cmd_stop, "meshcore-radio": cmd_meshcore_radio, - "reset-password": cmd_reset_password, "version": cmd_version, } diff --git a/src/cli/reset_password_command.py b/src/cli/reset_password_command.py deleted file mode 100644 index e82975e..0000000 --- a/src/cli/reset_password_command.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Interactive ``meshpoint reset-password`` recovery command. - -Last-resort recovery path for the local dashboard: a host-level -operator (SSH'd in as ``pi``/``meshpoint``) prompts for a fresh -admin password, the new bcrypt hash is written to ``local.yaml``, -the JWT secret is rotated, and ``session_version`` is bumped so any -session minted before the reset is invalidated immediately. - -The command depends only on ``WebAuthConfig`` + ``PasswordHasher`` -+ ``JwtSessionService.generate_secret`` -- it deliberately does not -import the FastAPI app or the running pipeline so it works against -a stopped service. -""" - -from __future__ import annotations - -import getpass -import sys -from typing import Callable - -from src.api.auth.jwt_session import JwtSessionService -from src.api.auth.password_hasher import PasswordHasher -from src.config import load_config, save_section_to_yaml - -_MIN_PASSWORD_LENGTH = 8 - - -PromptFn = Callable[[str], str] - - -class _CliWriter: - """Tiny wrapper around print so tests can capture output.""" - - def __init__(self, sink=None) -> None: - self._sink = sink or sys.stdout - - def writeln(self, text: str = "") -> None: - print(text, file=self._sink) - - -def run_reset_password( - *, - prompt_password: PromptFn = getpass.getpass, - confirm_password: PromptFn = getpass.getpass, - writer: _CliWriter | None = None, - config_loader: Callable = load_config, - persister: Callable[[str, dict], None] = save_section_to_yaml, -) -> int: - """Run the interactive reset-password flow. - - Returns a process-style exit code (``0`` on success, non-zero on - user cancel / mismatch). All I/O collaborators are injectable so - the test suite can drive it without spawning a TTY. - """ - out = writer or _CliWriter() - out.writeln() - out.writeln(" Meshpoint password reset") - out.writeln(" Sets a new admin password and invalidates any open sessions.") - out.writeln() - - try: - password = prompt_password(" New admin password: ") - confirm = confirm_password(" Confirm new password: ") - except (KeyboardInterrupt, EOFError): - out.writeln() - out.writeln(" Aborted.") - return 130 - - if password != confirm: - out.writeln() - out.writeln(" Passwords did not match. No changes written.") - return 1 - if len(password) < _MIN_PASSWORD_LENGTH: - out.writeln() - out.writeln( - f" Password must be at least {_MIN_PASSWORD_LENGTH} characters." - ) - return 1 - - config = config_loader() - new_hash = PasswordHasher().hash(password) - new_secret = JwtSessionService.generate_secret() - new_version = max(1, config.web_auth.session_version) + 1 - - persister( - "web_auth", - { - "admin_password_hash": new_hash, - "jwt_secret": new_secret, - "session_version": new_version, - }, - ) - - out.writeln() - out.writeln( - " Password reset complete. Open http:/// and sign in." - ) - return 0 diff --git a/src/config.py b/src/config.py index ec8a275..045ecc4 100644 --- a/src/config.py +++ b/src/config.py @@ -178,33 +178,6 @@ class TransmitConfig: nodeinfo: NodeInfoConfig = field(default_factory=NodeInfoConfig) -@dataclass -class WebAuthConfig: - """Local dashboard authentication settings. - - First-run state is ``admin_password_hash == ""``: the dashboard - forces the user through the ``/setup`` flow before any other page - or API call resolves. Once a hash is written, the dashboard - requires a valid session cookie (or ``Authorization: Bearer``) - on every protected endpoint. - - ``jwt_secret`` is auto-generated on first run when empty and - persisted to ``local.yaml``. Rotating it (via the - ``meshpoint reset-password`` CLI) invalidates every existing - session in one move. ``session_version`` is embedded in the JWT - claim for finer-grained invalidation without rotating the secret. - """ - - admin_password_hash: str = "" - viewer_password_hash: str = "" - jwt_secret: str = "" - jwt_expiry_minutes: int = 60 - allow_read_only: bool = False - lockout_attempts: int = 5 - lockout_cooldown_minutes: int = 5 - session_version: int = 1 - - @dataclass class AppConfig: radio: RadioConfig = field(default_factory=RadioConfig) @@ -218,7 +191,6 @@ class AppConfig: relay: RelayConfig = field(default_factory=RelayConfig) mqtt: MqttConfig = field(default_factory=MqttConfig) transmit: TransmitConfig = field(default_factory=TransmitConfig) - web_auth: WebAuthConfig = field(default_factory=WebAuthConfig) def _resolve_radio_frequency(radio: "RadioConfig") -> None: @@ -276,7 +248,6 @@ def _apply_yaml(cfg: AppConfig, path: Path) -> None: "relay": cfg.relay, "mqtt": cfg.mqtt, "transmit": cfg.transmit, - "web_auth": cfg.web_auth, } for section_name, section_instance in section_map.items(): diff --git a/src/decode/meshtastic_decoder.py b/src/decode/meshtastic_decoder.py index 54d50cd..3b420ef 100644 --- a/src/decode/meshtastic_decoder.py +++ b/src/decode/meshtastic_decoder.py @@ -97,14 +97,6 @@ def _parse_header(header_bytes: bytes) -> Optional[dict]: [13] channel hash [14] next_hop (relay) [15] relay_node (lowest byte of last relay node's ID; 0 = direct) - - Returns None if the header parses but fails a structural-validity - check (currently only ``hop_limit > hop_start``, which is - mathematically impossible for an honestly-originated Meshtastic - packet: hop_limit starts at hop_start and only ever decrements - through relays). Defense in depth against any future status-code - blind spot in the wrapper letting corrupted bytes reach the - decoder. """ try: dest_id, source_id, packet_id = struct.unpack_from( @@ -119,14 +111,6 @@ def _parse_header(header_bytes: bytes) -> Optional[dict]: via_mqtt = bool(flags & 0x10) hop_start = (flags >> 5) & 0x07 - if hop_limit > hop_start: - logger.debug( - "Dropping packet with impossible hops hl=%d > hs=%d " - "(corrupted header bytes; source=0x%08x dest=0x%08x)", - hop_limit, hop_start, source_id, dest_id, - ) - return None - return { "dest_id": dest_id, "source_id": source_id, diff --git a/src/hal/sx1302_wrapper.py b/src/hal/sx1302_wrapper.py index 7e50c9f..61986f5 100644 --- a/src/hal/sx1302_wrapper.py +++ b/src/hal/sx1302_wrapper.py @@ -98,8 +98,6 @@ def __init__( self._started = False self._debug_rx = os.getenv("MESHPOINT_DEBUG_RX") == "1" self._crc_bad_count = 0 - self._no_crc_count = 0 - self._unknown_status_count = 0 def load(self) -> None: if not self._lib_path or not os.path.exists(self._lib_path): @@ -208,32 +206,15 @@ def receive(self) -> list[ConcentratorPacket]: pkt.rssic, pkt.snr, pkt.size, self._crc_bad_count, ) continue - elif pkt.status == STAT_NO_CRC: - self._no_crc_count += 1 - logger.warning( - "RX NO_CRC if=%d sf%d bw=%g rssi=%.1f snr=%.1f size=%d " - "(total NO_CRC: %d)", - pkt.if_chain, pkt.datarate, - BW_MAP.get(pkt.bandwidth, pkt.bandwidth), - pkt.rssic, pkt.snr, pkt.size, self._no_crc_count, - ) - continue - elif pkt.status != STAT_CRC_OK: - self._unknown_status_count += 1 - logger.warning( - "RX unknown status=0x%02X if=%d sf%d bw=%g rssi=%.1f " - "snr=%.1f size=%d (total unknown: %d)", - pkt.status, pkt.if_chain, pkt.datarate, - BW_MAP.get(pkt.bandwidth, pkt.bandwidth), - pkt.rssic, pkt.snr, pkt.size, self._unknown_status_count, - ) - continue elif self._debug_rx: + status_name = _STATUS_NAMES.get( + pkt.status, f"0x{pkt.status:02X}" + ) logger.info( - "RX if=%d sf%d bw=%g status=CRC_OK rssi=%.1f snr=%.1f size=%d", + "RX if=%d sf%d bw=%g status=%s rssi=%.1f snr=%.1f size=%d", pkt.if_chain, pkt.datarate, BW_MAP.get(pkt.bandwidth, pkt.bandwidth), - pkt.rssic, pkt.snr, pkt.size, + status_name, pkt.rssic, pkt.snr, pkt.size, ) packets.append( @@ -261,28 +242,6 @@ def crc_bad_count(self) -> int: """ return self._crc_bad_count - @property - def no_crc_count(self) -> int: - """Total NO_CRC packets dropped since process start. - - NO_CRC indicates the chip received a packet but the LoRa header - CRC bit was off, or the CRC could not be validated. On a - Meshtastic-configured concentrator (CRC always enabled in the - outbound LoRa header by spec), NO_CRC at the noise floor is the - primary source of phantom node rows in the local SQLite. - """ - return self._no_crc_count - - @property - def unknown_status_count(self) -> int: - """Total packets dropped due to a chip status code that is neither - CRC_OK, CRC_BAD, nor NO_CRC. - - Catches any future HAL or chip-firmware quirk that introduces a new - status code rather than silently treating it as valid. - """ - return self._unknown_status_count - def set_syncword(self, syncword: int) -> None: """Configure custom sync word (requires patched HAL).""" if self._lib is None: diff --git a/src/relay/mqtt_formatter.py b/src/relay/mqtt_formatter.py index d1f4a62..3c4d4b6 100644 --- a/src/relay/mqtt_formatter.py +++ b/src/relay/mqtt_formatter.py @@ -66,8 +66,7 @@ class MeshtasticMqttFormatter: def __init__(self, topic_root: str, region: str, gateway_id: str, location_precision: str = "exact", channel_resolver: Optional[ChannelResolver] = None): - self._topic_root = topic_root - self._region = region + self._topic_prefix = _build_topic_prefix(topic_root, region) self._gateway_id = gateway_id self._location_precision = location_precision self._channel_resolver = channel_resolver or ChannelResolver() @@ -91,7 +90,7 @@ def format(self, packet: Packet) -> Optional[MqttMessage]: return None channel_name = self._resolve_channel(packet) - topic = f"{self._topic_root}/{self._region}/2/e/{channel_name}/{self._gateway_id}" + topic = f"{self._topic_prefix}/2/e/{channel_name}/{self._gateway_id}" mesh_pkt = MeshPacket() mesh_pkt.id = _parse_packet_id(packet.packet_id) @@ -128,7 +127,7 @@ def _format_encrypted(self, packet: Packet) -> Optional[MqttMessage]: return None channel_name = self._resolve_channel(packet) - topic = f"{self._topic_root}/{self._region}/2/e/{channel_name}/{self._gateway_id}" + topic = f"{self._topic_prefix}/2/e/{channel_name}/{self._gateway_id}" mesh_pkt = MeshPacket() mesh_pkt.id = _parse_packet_id(packet.packet_id) @@ -154,7 +153,7 @@ def _format_encrypted(self, packet: Packet) -> Optional[MqttMessage]: def format_json(self, packet: Packet) -> Optional[MqttMessage]: """Build a JSON representation on the /json/ topic for HA/Node-RED.""" channel_name = self._resolve_channel(packet) - topic = f"{self._topic_root}/{self._region}/2/json/{channel_name}/{self._gateway_id}" + topic = f"{self._topic_prefix}/2/json/{channel_name}/{self._gateway_id}" payload = self._build_json_payload(packet) return MqttMessage(topic=topic, payload=json.dumps(payload).encode()) @@ -201,14 +200,13 @@ class MeshCoreMqttFormatter: def __init__(self, topic_root: str, region: str, gateway_id: str, location_precision: str = "exact"): - self._topic_root = topic_root - self._region = region + self._topic_prefix = _build_topic_prefix(topic_root, region) self._gateway_id = gateway_id self._location_precision = location_precision def format(self, packet: Packet) -> Optional[MqttMessage]: channel_name = "MeshCore" - topic = f"{self._topic_root}/{self._region}/2/c/{channel_name}/{self._gateway_id}" + topic = f"{self._topic_prefix}/2/c/{channel_name}/{self._gateway_id}" lat, lon = LocationRounder.apply( (packet.decoded_payload or {}).get("latitude"), @@ -341,3 +339,29 @@ def _is_hex(value: str) -> bool: return True except (ValueError, TypeError): return False + + +def _build_topic_prefix(topic_root: str, region: str) -> str: + """Build MQTT prefix with backward-compatible root/region semantics. + + Examples: + - topic_root="msh", region="US" -> "msh/US" + - topic_root="msh/US", region="FL" -> "msh/US/FL" + - topic_root="msh/US", region="US" -> "msh/US" (no duplicate) + - topic_root="msh", region="US/FL" -> "msh/US/FL" + """ + root = (topic_root or "").strip().strip("/") + reg = (region or "").strip().strip("/") + + if not root and not reg: + return "msh/US" + if not reg: + return root + if not root: + return reg + + root_lower = root.lower() + reg_lower = reg.lower() + if root_lower == reg_lower or root_lower.endswith(f"/{reg_lower}"): + return root + return f"{root}/{reg}" diff --git a/src/relay/mqtt_publisher.py b/src/relay/mqtt_publisher.py index 6b8c8f1..9caa853 100644 --- a/src/relay/mqtt_publisher.py +++ b/src/relay/mqtt_publisher.py @@ -74,6 +74,7 @@ def __init__( location_precision=config.location_precision, ) self._ha_discovery: Optional[HomeAssistantDiscovery] = None + self._topic_prefix = self._resolve_topic_prefix() @property def gateway_id(self) -> str: @@ -118,6 +119,10 @@ def connect(self) -> bool: "MQTT connecting to %s:%d as %s", self._config.broker, self._config.port, self._gateway_id, ) + logger.info( + "MQTT topic prefix resolved: %s", + self._topic_prefix, + ) return True except Exception: logger.exception("MQTT connection failed") @@ -197,7 +202,8 @@ def _format_packet(self, packet: Packet) -> list[MqttMessage]: def _on_connect(self, client, userdata, flags, rc) -> None: if rc == 0: self._connected = True - logger.info("MQTT connected to %s as %s", self._config.broker, self._gateway_id) + logger.info("MQTT publisher started as %s", self._gateway_id) + logger.debug("MQTT connected to broker=%s", self._config.broker) if self._config.homeassistant_discovery and self._client: self._ha_discovery = HomeAssistantDiscovery(self._client, self._gateway_id) else: @@ -209,6 +215,21 @@ def _on_disconnect(self, client, userdata, rc) -> None: if rc != 0: logger.warning("MQTT unexpected disconnect (rc=%d), auto-reconnecting", rc) + def _resolve_topic_prefix(self) -> str: + root = (self._config.topic_root or "").strip().strip("/") + region = (self._config.region or "").strip().strip("/") + if not root and not region: + return "msh/US" + if not region: + return root + if not root: + return region + root_lower = root.lower() + region_lower = region.lower() + if root_lower == region_lower or root_lower.endswith(f"/{region_lower}"): + return root + return f"{root}/{region}" + class HomeAssistantDiscovery: """Publishes HA auto-discovery configs for mesh node sensors.""" diff --git a/src/version.py b/src/version.py index ceb4588..08574e9 100644 --- a/src/version.py +++ b/src/version.py @@ -1,3 +1,3 @@ """Single source of truth for Meshpoint software version.""" -__version__ = "0.7.3.1" +__version__ = "0.7.2" diff --git a/tests/test_auth_bootstrap.py b/tests/test_auth_bootstrap.py deleted file mode 100644 index ee1b6d3..0000000 --- a/tests/test_auth_bootstrap.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Regression coverage for AuthSubsystem assembly. - -The historical bug this guards: the original implementation persisted -the auto-generated jwt_secret to local.yaml at service start. On a -fresh-SD install that ran install.sh + systemd before the user ran -``meshpoint setup``, this created a stub local.yaml just containing -``web_auth.jwt_secret``, which then made the setup wizard print -"Existing config/local.yaml found" on what was actually a clean -install. The fix moves the disk write into AuthService.complete_setup -so a true fresh install only writes local.yaml when the user has -configured something. -""" - -from __future__ import annotations - -import unittest -from unittest.mock import patch - -from src.api.auth.auth_bootstrap import build_auth_subsystem -from src.config import AppConfig, WebAuthConfig - - -class _CountingPatcher: - """Records how many times save_section_to_yaml is called and with what.""" - - def __init__(self) -> None: - self.calls: list[tuple[str, dict]] = [] - - def __call__(self, section: str, values: dict) -> None: - self.calls.append((section, values)) - - -class TestAuthBootstrapFreshInstall(unittest.TestCase): - def test_jwt_secret_generated_in_memory_only_no_disk_write(self) -> None: - """A fresh install must not pollute local.yaml from auth boot.""" - cfg = AppConfig() - self.assertEqual(cfg.web_auth.jwt_secret, "") - spy = _CountingPatcher() - with patch("src.api.auth.auth_bootstrap.save_section_to_yaml", spy): - subsystem = build_auth_subsystem(cfg) - - self.assertNotEqual(cfg.web_auth.jwt_secret, "") - self.assertEqual( - spy.calls, - [], - "auth bootstrap must not write local.yaml on first run; " - "secret persists when /setup completes", - ) - self.assertEqual( - subsystem.jwt_service._secret, - cfg.web_auth.jwt_secret, - "JwtSessionService must use the same secret that was set " - "on the WebAuthConfig", - ) - - def test_existing_jwt_secret_is_left_alone(self) -> None: - """An upgrade with a populated local.yaml is a no-op.""" - cfg = AppConfig() - cfg.web_auth = WebAuthConfig( - jwt_secret="already-on-disk-from-prior-setup", - admin_password_hash="$2b$04$exists", - ) - spy = _CountingPatcher() - with patch("src.api.auth.auth_bootstrap.save_section_to_yaml", spy): - build_auth_subsystem(cfg) - - self.assertEqual( - cfg.web_auth.jwt_secret, "already-on-disk-from-prior-setup" - ) - self.assertEqual(spy.calls, []) - - -class TestCompleteSetupPersistsJwtSecret(unittest.TestCase): - def test_complete_setup_persists_jwt_secret_alongside_password_hash( - self, - ) -> None: - """The first /setup call is what creates local.yaml.""" - from src.api.auth.auth_service import AuthService, SetupSuccess - from src.api.auth.jwt_session import JwtSessionService - from src.api.auth.lockout_tracker import LockoutTracker - from src.api.auth.password_hasher import PasswordHasher - - cfg = WebAuthConfig(jwt_secret="bootstrap-generated-in-memory") - persisted: list[dict] = [] - service = AuthService( - web_auth=cfg, - hasher=PasswordHasher(rounds=4), - lockout=LockoutTracker(max_attempts=5, cooldown_minutes=5), - jwt_service=JwtSessionService( - secret="bootstrap-generated-in-memory", - expiry_minutes=60, - session_version=1, - ), - persist=lambda values: persisted.append(values), - ) - - result = service.complete_setup("hunter22-strong") - self.assertIsInstance(result, SetupSuccess) - self.assertEqual(len(persisted), 1) - self.assertIn("admin_password_hash", persisted[0]) - self.assertIn("jwt_secret", persisted[0]) - self.assertEqual( - persisted[0]["jwt_secret"], "bootstrap-generated-in-memory" - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_auth_dependencies.py b/tests/test_auth_dependencies.py deleted file mode 100644 index 80558b2..0000000 --- a/tests/test_auth_dependencies.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Integration tests for ``src.api.auth.dependencies``. - -Mounts a tiny FastAPI app whose only routes use the auth dependencies -so we exercise cookie + Authorization-header extraction, role gating, -and the optional dependency under a real request lifecycle. -""" - -from __future__ import annotations - -import unittest - -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient - -from src.api.auth import dependencies as auth_deps -from src.api.auth.dependencies import ( - SESSION_COOKIE_NAME, - optional_auth, - require_admin, - require_auth, -) -from src.api.auth.jwt_session import ( - ROLE_ADMIN, - ROLE_VIEWER, - JwtSessionService, -) - -_SECRET = "dependencies-test-secret-" + "x" * 16 - - -def _build_app() -> FastAPI: - app = FastAPI() - - @app.get("/protected") - def protected(claims=Depends(require_auth)): - return {"sub": claims.subject, "role": claims.role} - - @app.get("/admin-only") - def admin_only(claims=Depends(require_admin)): - return {"sub": claims.subject} - - @app.get("/optional") - def optional(claims=Depends(optional_auth)): - if claims is None: - return {"signed_in": False} - return {"signed_in": True, "role": claims.role} - - return app - - -class TestAuthDependencies(unittest.TestCase): - def setUp(self) -> None: - self.service = JwtSessionService( - secret=_SECRET, expiry_minutes=60, session_version=1 - ) - auth_deps.init_auth(self.service) - self.app = _build_app() - self.client = TestClient(self.app) - - def tearDown(self) -> None: - auth_deps.reset_auth() - - def _admin_token(self) -> str: - return self.service.issue("admin", ROLE_ADMIN) - - def _viewer_token(self) -> str: - return self.service.issue("viewer", ROLE_VIEWER) - - def test_protected_rejects_anonymous(self) -> None: - response = self.client.get("/protected") - self.assertEqual(response.status_code, 401) - - def test_protected_accepts_cookie(self) -> None: - self.client.cookies.set(SESSION_COOKIE_NAME, self._admin_token()) - response = self.client.get("/protected") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["role"], ROLE_ADMIN) - - def test_protected_accepts_bearer_header(self) -> None: - response = self.client.get( - "/protected", - headers={"Authorization": f"Bearer {self._viewer_token()}"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["role"], ROLE_VIEWER) - - def test_protected_rejects_malformed_cookie(self) -> None: - self.client.cookies.set(SESSION_COOKIE_NAME, "not-a-jwt") - response = self.client.get("/protected") - self.assertEqual(response.status_code, 401) - - def test_protected_rejects_when_jwt_service_uninitialized(self) -> None: - auth_deps.reset_auth() - self.client.cookies.set(SESSION_COOKIE_NAME, self._admin_token()) - response = self.client.get("/protected") - self.assertEqual(response.status_code, 401) - - def test_admin_only_rejects_viewer(self) -> None: - self.client.cookies.set(SESSION_COOKIE_NAME, self._viewer_token()) - response = self.client.get("/admin-only") - self.assertEqual(response.status_code, 403) - - def test_admin_only_accepts_admin(self) -> None: - self.client.cookies.set(SESSION_COOKIE_NAME, self._admin_token()) - response = self.client.get("/admin-only") - self.assertEqual(response.status_code, 200) - - def test_optional_returns_none_for_anonymous(self) -> None: - response = self.client.get("/optional") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"signed_in": False}) - - def test_optional_returns_claims_when_authed(self) -> None: - self.client.cookies.set(SESSION_COOKIE_NAME, self._admin_token()) - response = self.client.get("/optional") - self.assertEqual(response.json(), {"signed_in": True, "role": ROLE_ADMIN}) - - def test_optional_returns_none_for_bad_token(self) -> None: - self.client.cookies.set(SESSION_COOKIE_NAME, "garbage") - response = self.client.get("/optional") - self.assertEqual(response.json(), {"signed_in": False}) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_auth_page_serving.py b/tests/test_auth_page_serving.py deleted file mode 100644 index d58fb60..0000000 --- a/tests/test_auth_page_serving.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Integration tests for /setup and /login HTML serving. - -These pages are mounted on ``server.py`` *before* the static catch- -all so a visitor can hit ``/setup`` or ``/login`` directly. The -tests below pin three things: - -1. Both routes return 200 with a real HTML payload. -2. The radar shell, identity strip, and form are present. -3. The login page surfaces the SSH recovery hint per the auth plan. -""" - -from __future__ import annotations - -import unittest -from pathlib import Path - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from src.api.server import _serve_auth_page - -_FRONTEND = Path(__file__).resolve().parents[1] / "frontend" - - -def _build_app() -> FastAPI: - app = FastAPI() - - @app.get("/setup") - async def setup_page(): - return _serve_auth_page(_FRONTEND, "setup.html") - - @app.get("/login") - async def login_page(): - return _serve_auth_page(_FRONTEND, "login.html") - - return app - - -class TestAuthPageServing(unittest.TestCase): - def setUp(self) -> None: - self.client = TestClient(_build_app()) - - def test_setup_page_returns_html_with_radar_and_form(self) -> None: - response = self.client.get("/setup") - self.assertEqual(response.status_code, 200) - self.assertIn("text/html", response.headers["content-type"]) - body = response.text - self.assertIn("First-time setup", body) - self.assertIn('id="auth-form"', body) - self.assertIn('id="password-input"', body) - self.assertIn('id="confirm-input"', body) - self.assertIn('class="auth-radar', body) - self.assertIn('data-auth-mode="setup"', body) - - def test_login_page_returns_html_with_recovery_hint(self) -> None: - response = self.client.get("/login") - self.assertEqual(response.status_code, 200) - body = response.text - self.assertIn('id="username-input"', body) - self.assertIn('id="password-input"', body) - self.assertIn('class="auth-radar', body) - self.assertIn("ssh pi@", body) - self.assertIn("meshpoint reset-password", body) - self.assertIn('data-auth-mode="login"', body) - - def test_setup_page_does_not_leak_login_recovery_hint(self) -> None: - body = self.client.get("/setup").text - self.assertNotIn("meshpoint reset-password", body) - - def test_404_for_unknown_auth_page(self) -> None: - app = FastAPI() - - @app.get("/missing") - async def missing(): - return _serve_auth_page(_FRONTEND, "does-not-exist.html") - - response = TestClient(app).get("/missing") - self.assertEqual(response.status_code, 404) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_auth_routes.py b/tests/test_auth_routes.py deleted file mode 100644 index 01dc1fa..0000000 --- a/tests/test_auth_routes.py +++ /dev/null @@ -1,188 +0,0 @@ -"""End-to-end tests for the /api/auth/* router. - -Spins up a real FastAPI app, wires a real ``AuthService`` (with an -in-memory persistence stub), and walks the full surface: setup, -login (success/wrong/locked/setup-required), logout, cookie -attributes (HttpOnly + SameSite + Secure-when-HTTPS). -""" - -from __future__ import annotations - -import unittest - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from src.api.auth import dependencies as auth_deps -from src.api.auth.auth_service import AuthService -from src.api.auth.dependencies import SESSION_COOKIE_NAME -from src.api.auth.jwt_session import JwtSessionService -from src.api.auth.lockout_tracker import LockoutTracker -from src.api.auth.password_hasher import PasswordHasher -from src.api.routes import auth_routes -from src.config import WebAuthConfig - -_SECRET = "auth-routes-test-secret-" + "y" * 16 -_PASSWORD = "correct horse staple" - - -def _build_client( - *, - web_auth: WebAuthConfig | None = None, - base_url: str = "http://testserver", -) -> tuple[TestClient, WebAuthConfig, list[dict]]: - cfg = web_auth or WebAuthConfig() - persisted: list[dict] = [] - service = AuthService( - web_auth=cfg, - hasher=PasswordHasher(rounds=4), - lockout=LockoutTracker(max_attempts=3, cooldown_minutes=5), - jwt_service=JwtSessionService( - secret=_SECRET, expiry_minutes=60, session_version=1 - ), - persist=lambda values: persisted.append(values), - ) - auth_routes.init_routes(service) - auth_deps.init_auth(service._jwt) - app = FastAPI() - app.include_router(auth_routes.router) - return TestClient(app, base_url=base_url), cfg, persisted - - -class TestSetupEndpoint(unittest.TestCase): - def tearDown(self) -> None: - auth_routes.reset_routes() - auth_deps.reset_auth() - - def test_first_setup_returns_admin_role_and_sets_cookie(self) -> None: - client, cfg, persisted = _build_client() - response = client.post("/api/auth/setup", json={"password": _PASSWORD}) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"role": "admin"}) - self.assertIn(SESSION_COOKIE_NAME, response.cookies) - self.assertTrue(cfg.admin_password_hash.startswith("$2")) - self.assertEqual(len(persisted), 1) - set_cookie_header = response.headers.get("set-cookie", "") - lowered = set_cookie_header.lower() - self.assertIn("httponly", lowered) - self.assertIn("samesite=lax", lowered) - self.assertNotIn("secure", lowered) - self.assertIn("path=/", lowered) - - def test_second_setup_returns_409(self) -> None: - client, _, _ = _build_client() - client.post("/api/auth/setup", json={"password": _PASSWORD}) - response = client.post("/api/auth/setup", json={"password": "anotherpass"}) - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["detail"], "already_set") - - def test_setup_short_password_returns_400(self) -> None: - client, _, _ = _build_client() - response = client.post("/api/auth/setup", json={"password": "short"}) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["detail"], "password_too_short") - - def test_setup_cookie_marked_secure_on_https(self) -> None: - client, _, _ = _build_client(base_url="https://testserver") - response = client.post("/api/auth/setup", json={"password": _PASSWORD}) - set_cookie_header = response.headers.get("set-cookie", "").lower() - self.assertIn("secure", set_cookie_header) - - -class TestLoginEndpoint(unittest.TestCase): - def setUp(self) -> None: - self.client, self.cfg, _ = _build_client() - self.client.post("/api/auth/setup", json={"password": _PASSWORD}) - self.client.cookies.clear() - - def tearDown(self) -> None: - auth_routes.reset_routes() - auth_deps.reset_auth() - - def test_login_success_sets_cookie(self) -> None: - response = self.client.post( - "/api/auth/login", - json={"username": "admin", "password": _PASSWORD}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["role"], "admin") - self.assertIn(SESSION_COOKIE_NAME, response.cookies) - - def test_login_wrong_password_returns_401(self) -> None: - response = self.client.post( - "/api/auth/login", - json={"username": "admin", "password": "wrong"}, - ) - self.assertEqual(response.status_code, 401) - self.assertEqual(response.json()["detail"], "invalid_credentials") - self.assertNotIn(SESSION_COOKIE_NAME, response.cookies) - - def test_login_unknown_user_returns_401(self) -> None: - response = self.client.post( - "/api/auth/login", - json={"username": "root", "password": _PASSWORD}, - ) - self.assertEqual(response.status_code, 401) - - def test_login_locks_after_three_failures(self) -> None: - for _ in range(3): - self.client.post( - "/api/auth/login", - json={"username": "admin", "password": "wrong"}, - ) - locked = self.client.post( - "/api/auth/login", - json={"username": "admin", "password": _PASSWORD}, - ) - self.assertEqual(locked.status_code, 429) - self.assertEqual(locked.json()["detail"], "locked_out") - self.assertIn("retry-after", {h.lower() for h in locked.headers.keys()}) - - def test_login_before_setup_returns_409(self) -> None: - auth_routes.reset_routes() - client, _, _ = _build_client() - response = client.post( - "/api/auth/login", - json={"username": "admin", "password": _PASSWORD}, - ) - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["detail"], "setup_required") - - def test_login_validation_rejects_blank(self) -> None: - response = self.client.post( - "/api/auth/login", json={"username": "", "password": ""} - ) - self.assertEqual(response.status_code, 422) - - -class TestLogoutEndpoint(unittest.TestCase): - def tearDown(self) -> None: - auth_routes.reset_routes() - auth_deps.reset_auth() - - def test_logout_clears_cookie(self) -> None: - client, _, _ = _build_client() - client.post("/api/auth/setup", json={"password": _PASSWORD}) - response = client.post("/api/auth/logout") - self.assertEqual(response.status_code, 204) - set_cookie_header = response.headers.get("set-cookie", "") - self.assertIn(SESSION_COOKIE_NAME, set_cookie_header) - self.assertIn("Max-Age=0", set_cookie_header) - - -class TestServiceUninitialized(unittest.TestCase): - def tearDown(self) -> None: - auth_routes.reset_routes() - auth_deps.reset_auth() - - def test_calling_setup_without_init_returns_503(self) -> None: - auth_routes.reset_routes() - app = FastAPI() - app.include_router(auth_routes.router) - client = TestClient(app) - response = client.post("/api/auth/setup", json={"password": _PASSWORD}) - self.assertEqual(response.status_code, 503) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py deleted file mode 100644 index 19a4980..0000000 --- a/tests/test_auth_service.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Unit tests for ``src.api.auth.auth_service.AuthService``. - -Drives each branch (setup happy/already-set/policy-rejected, login -happy/locked/wrong-password/setup-required, viewer role) through the -real PasswordHasher, JwtSessionService, and LockoutTracker -- only -the persistence callback is stubbed so we never hit disk. -""" - -from __future__ import annotations - -import unittest - -from src.api.auth.auth_service import ( - AuthService, - LoginFailure, - LoginSuccess, - SetupRejected, - SetupSuccess, -) -from src.api.auth.jwt_session import ( - ROLE_ADMIN, - ROLE_VIEWER, - JwtSessionService, -) -from src.api.auth.lockout_tracker import LockoutTracker -from src.api.auth.password_hasher import PasswordHasher -from src.config import WebAuthConfig - -_SECRET = "auth-service-test-secret-" + "z" * 16 -_VALID_PASSWORD = "correct horse staple" -_OTHER_PASSWORD = "different password" - - -class _FakeClock: - def __init__(self) -> None: - self.now = 1000.0 - - def __call__(self) -> float: - return self.now - - def advance(self, seconds: float) -> None: - self.now += seconds - - -def _build_service( - *, web_auth: WebAuthConfig | None = None, clock: _FakeClock | None = None -) -> tuple[AuthService, list[dict], _FakeClock, WebAuthConfig]: - cfg = web_auth or WebAuthConfig() - persisted: list[dict] = [] - clk = clock or _FakeClock() - service = AuthService( - web_auth=cfg, - hasher=PasswordHasher(rounds=4), - lockout=LockoutTracker(max_attempts=3, cooldown_minutes=5, clock=clk), - jwt_service=JwtSessionService( - secret=_SECRET, expiry_minutes=60, session_version=cfg.session_version - ), - persist=lambda values: persisted.append(values), - ) - return service, persisted, clk, cfg - - -class TestSetupFlow(unittest.TestCase): - def test_first_run_setup_persists_hash_and_returns_token(self) -> None: - service, persisted, _, cfg = _build_service() - result = service.complete_setup(_VALID_PASSWORD) - self.assertIsInstance(result, SetupSuccess) - self.assertEqual(len(persisted), 1) - self.assertIn("admin_password_hash", persisted[0]) - self.assertTrue(cfg.admin_password_hash.startswith("$2")) - - def test_setup_rejects_when_already_set(self) -> None: - cfg = WebAuthConfig(admin_password_hash="$2b$04$alreadyset") - service, persisted, _, _ = _build_service(web_auth=cfg) - result = service.complete_setup(_VALID_PASSWORD) - self.assertIsInstance(result, SetupRejected) - assert isinstance(result, SetupRejected) - self.assertEqual(result.reason, "already_set") - self.assertEqual(persisted, []) - - def test_setup_rejects_short_password(self) -> None: - service, persisted, _, _ = _build_service() - result = service.complete_setup("short") - self.assertIsInstance(result, SetupRejected) - assert isinstance(result, SetupRejected) - self.assertEqual(result.reason, "password_too_short") - self.assertEqual(persisted, []) - - def test_setup_rejects_overlong_password(self) -> None: - service, _, _, _ = _build_service() - result = service.complete_setup("a" * 257) - self.assertIsInstance(result, SetupRejected) - - -class TestLoginFlow(unittest.TestCase): - def setUp(self) -> None: - self.service, _, self.clock, self.cfg = _build_service() - self.service.complete_setup(_VALID_PASSWORD) - - def test_admin_login_success_returns_token(self) -> None: - result = self.service.login("admin", _VALID_PASSWORD) - self.assertIsInstance(result, LoginSuccess) - assert isinstance(result, LoginSuccess) - self.assertEqual(result.role, ROLE_ADMIN) - self.assertTrue(result.token) - - def test_login_is_case_insensitive_on_username(self) -> None: - result = self.service.login("ADMIN", _VALID_PASSWORD) - self.assertIsInstance(result, LoginSuccess) - - def test_login_wrong_password_is_invalid_credentials(self) -> None: - result = self.service.login("admin", _OTHER_PASSWORD) - self.assertIsInstance(result, LoginFailure) - assert isinstance(result, LoginFailure) - self.assertEqual(result.reason, "invalid_credentials") - - def test_login_unknown_username_collapses_to_invalid_credentials(self) -> None: - result = self.service.login("root", _VALID_PASSWORD) - self.assertIsInstance(result, LoginFailure) - assert isinstance(result, LoginFailure) - self.assertEqual(result.reason, "invalid_credentials") - - def test_login_locks_after_threshold(self) -> None: - for _ in range(2): - self.service.login("admin", _OTHER_PASSWORD) - third = self.service.login("admin", _OTHER_PASSWORD) - self.assertIsInstance(third, LoginFailure) - assert isinstance(third, LoginFailure) - self.assertEqual(third.reason, "invalid_credentials") - self.assertIsNotNone(third.retry_after_seconds) - - locked = self.service.login("admin", _VALID_PASSWORD) - self.assertIsInstance(locked, LoginFailure) - assert isinstance(locked, LoginFailure) - self.assertEqual(locked.reason, "locked_out") - - def test_lock_clears_after_cooldown(self) -> None: - for _ in range(3): - self.service.login("admin", _OTHER_PASSWORD) - self.clock.advance(301) - result = self.service.login("admin", _VALID_PASSWORD) - self.assertIsInstance(result, LoginSuccess) - - def test_login_success_resets_failure_count(self) -> None: - self.service.login("admin", _OTHER_PASSWORD) - self.service.login("admin", _OTHER_PASSWORD) - self.service.login("admin", _VALID_PASSWORD) - for _ in range(2): - result = self.service.login("admin", _OTHER_PASSWORD) - self.assertIsInstance(result, LoginFailure) - assert isinstance(result, LoginFailure) - self.assertEqual(result.reason, "invalid_credentials") - self.assertIsNone(self.service.login("admin", _VALID_PASSWORD).__dict__.get("retry_after_seconds")) - - -class TestViewerRole(unittest.TestCase): - def test_viewer_login_success_when_hash_present(self) -> None: - cfg = WebAuthConfig() - service, _, _, _ = _build_service(web_auth=cfg) - service.complete_setup(_VALID_PASSWORD) - viewer_hash = PasswordHasher(rounds=4).hash("viewerpass1") - cfg.viewer_password_hash = viewer_hash - result = service.login("viewer", "viewerpass1") - self.assertIsInstance(result, LoginSuccess) - assert isinstance(result, LoginSuccess) - self.assertEqual(result.role, ROLE_VIEWER) - - def test_viewer_login_fails_when_no_viewer_hash(self) -> None: - service, _, _, _ = _build_service() - service.complete_setup(_VALID_PASSWORD) - result = service.login("viewer", "anything") - self.assertIsInstance(result, LoginFailure) - - -class TestLoginPreSetup(unittest.TestCase): - def test_login_before_setup_is_setup_required(self) -> None: - service, _, _, _ = _build_service() - result = service.login("admin", _VALID_PASSWORD) - self.assertIsInstance(result, LoginFailure) - assert isinstance(result, LoginFailure) - self.assertEqual(result.reason, "setup_required") - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_dashboard_root_route.py b/tests/test_dashboard_root_route.py deleted file mode 100644 index 59e0f54..0000000 --- a/tests/test_dashboard_root_route.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Regression tests for the v0.7.3.1 dashboard-root auth gate. - -v0.7.3 mounted ``StaticFiles(directory=..., html=True)`` on ``/``, -which served ``index.html`` to anyone without checking the session -cookie. A stale browser tab against an upgraded server could then -load the new SPA JS, fight an unauthenticated ``/ws`` upgrade, and -get stranded in the reconnect loop documented in -``test_websocket_auth_close_code.py``. - -The v0.7.3.1 fix adds an explicit ``@app.get("/")`` route registered -*before* the StaticFiles mount that: - -- 302s to ``/setup`` if the admin password has not been set yet -- 302s to ``/login`` if it has but the request has no valid cookie -- serves ``index.html`` for valid sessions - -These tests exercise the helper directly, the route in isolation, -and the full server-style precedence with a static-mount sibling so -a future refactor cannot accidentally let the static catch-all win. -""" - -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from fastapi import FastAPI, Request -from fastapi.responses import FileResponse, RedirectResponse -from fastapi.staticfiles import StaticFiles -from fastapi.testclient import TestClient - -from src.api.auth.dependencies import SESSION_COOKIE_NAME -from src.api.auth.jwt_session import ROLE_ADMIN, JwtSessionService -from src.api.server import _request_has_valid_session - -_SECRET = "dashboard-root-test-secret-" + "y" * 16 - - -def _service() -> JwtSessionService: - return JwtSessionService(secret=_SECRET, expiry_minutes=60, session_version=1) - - -class _StubAuthService: - def __init__(self, setup_complete: bool) -> None: - self._setup_complete = setup_complete - - def is_setup_complete(self) -> bool: - return self._setup_complete - - -def _build_test_app( - static_dir: Path, jwt_service, auth_service -) -> FastAPI: - """Mirror the wiring used by ``server.create_app`` for ``/``.""" - app = FastAPI() - - @app.get("/", include_in_schema=False) - async def serve_dashboard_root(request: Request): - if not _request_has_valid_session(request, jwt_service): - target = "/login" if auth_service.is_setup_complete() else "/setup" - return RedirectResponse(url=target, status_code=302) - return FileResponse( - str(static_dir / "index.html"), media_type="text/html" - ) - - @app.get("/login", include_in_schema=False) - async def serve_login(): - return FileResponse( - str(static_dir / "login.html"), media_type="text/html" - ) - - @app.get("/setup", include_in_schema=False) - async def serve_setup(): - return FileResponse( - str(static_dir / "setup.html"), media_type="text/html" - ) - - if static_dir.exists(): - app.mount("/", StaticFiles(directory=str(static_dir), html=True)) - - return app - - -class _StaticDir: - """Build a throwaway frontend dir with all the files the route may serve.""" - - def __init__(self) -> None: - self._tmp = tempfile.TemporaryDirectory() - self.path = Path(self._tmp.name) - (self.path / "index.html").write_text( - "dashboard SPA" - ) - (self.path / "login.html").write_text("login") - (self.path / "setup.html").write_text("setup") - css_dir = self.path / "css" - css_dir.mkdir() - (css_dir / "dashboard.css").write_text("/* dashboard */") - - def cleanup(self) -> None: - self._tmp.cleanup() - - -class TestDashboardRootRedirects(unittest.TestCase): - def setUp(self) -> None: - self.static = _StaticDir() - self.service = _service() - - def tearDown(self) -> None: - self.static.cleanup() - - def _client(self, setup_complete: bool) -> TestClient: - return TestClient( - _build_test_app( - self.static.path, - self.service, - _StubAuthService(setup_complete=setup_complete), - ), - follow_redirects=False, - ) - - def test_unauthed_with_setup_complete_redirects_to_login(self) -> None: - response = self._client(setup_complete=True).get("/") - self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["location"], "/login") - - def test_unauthed_without_setup_complete_redirects_to_setup(self) -> None: - response = self._client(setup_complete=False).get("/") - self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["location"], "/setup") - - def test_invalid_cookie_redirects_to_login(self) -> None: - client = self._client(setup_complete=True) - client.cookies.set(SESSION_COOKIE_NAME, "not-a-jwt") - response = client.get("/") - self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["location"], "/login") - - def test_valid_cookie_serves_dashboard_html(self) -> None: - token = self.service.issue("admin", ROLE_ADMIN) - client = self._client(setup_complete=True) - client.cookies.set(SESSION_COOKIE_NAME, token) - response = client.get("/") - self.assertEqual(response.status_code, 200) - self.assertIn("text/html", response.headers["content-type"]) - self.assertIn("dashboard SPA", response.text) - - def test_static_assets_still_served_through_mount(self) -> None: - """Static asset paths (e.g. /css/foo.css) must NOT be auth-gated. - - Only the bare ``/`` is redirected; CSS/JS/asset siblings keep - flowing through ``StaticFiles`` so the login page itself can - load its bundled stylesheet from an unauthenticated session. - """ - client = self._client(setup_complete=True) - response = client.get("/css/dashboard.css") - self.assertEqual(response.status_code, 200) - self.assertIn("dashboard", response.text) - - -class TestRequestHasValidSessionHelper(unittest.TestCase): - """Pin behaviour of the cookie-only helper used by the root gate.""" - - def test_returns_false_when_jwt_service_is_none(self) -> None: - request = _stub_request(cookies={SESSION_COOKIE_NAME: "anything"}) - self.assertFalse(_request_has_valid_session(request, None)) - - def test_returns_false_when_no_cookie(self) -> None: - self.assertFalse( - _request_has_valid_session(_stub_request(cookies={}), _service()) - ) - - def test_returns_false_when_cookie_invalid(self) -> None: - self.assertFalse( - _request_has_valid_session( - _stub_request(cookies={SESSION_COOKIE_NAME: "garbage"}), - _service(), - ) - ) - - def test_returns_true_when_cookie_valid(self) -> None: - service = _service() - token = service.issue("admin", ROLE_ADMIN) - self.assertTrue( - _request_has_valid_session( - _stub_request(cookies={SESSION_COOKIE_NAME: token}), - service, - ) - ) - - -def _stub_request(*, cookies: dict) -> Request: - """Build a Request with the cookie jar populated. - - Faster than spinning up a TestClient for each helper-level case. - """ - scope = { - "type": "http", - "method": "GET", - "path": "/", - "headers": [ - ( - b"cookie", - "; ".join(f"{k}={v}" for k, v in cookies.items()).encode(), - ) - ] - if cookies - else [], - "query_string": b"", - } - return Request(scope) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_identity_route.py b/tests/test_identity_route.py deleted file mode 100644 index a4421ce..0000000 --- a/tests/test_identity_route.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Tests for the public ``GET /api/identity`` endpoint. - -The endpoint is intentionally allowlisted so the auth pages can -render the identity strip pre-session. These tests pin two things: - -1. The contract -- only ``device_name``, ``firmware_version``, and - ``setup_required`` are returned. No node IDs, positions, or - hardware fingerprints leak. -2. The ``setup_required`` flag flips correctly once the admin hash - is set. -""" - -from __future__ import annotations - -import unittest - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from src.api.auth.auth_service import AuthService -from src.api.auth.jwt_session import JwtSessionService -from src.api.auth.lockout_tracker import LockoutTracker -from src.api.auth.password_hasher import PasswordHasher -from src.api.routes import identity_routes -from src.config import WebAuthConfig -from src.models.device_identity import DeviceIdentity - -_SECRET = "identity-test-secret-" + "k" * 16 - - -def _build_client(*, with_password: bool = False) -> tuple[TestClient, WebAuthConfig]: - cfg = WebAuthConfig() - if with_password: - cfg.admin_password_hash = PasswordHasher(rounds=4).hash("setup-password") - auth_service = AuthService( - web_auth=cfg, - hasher=PasswordHasher(rounds=4), - lockout=LockoutTracker(max_attempts=5, cooldown_minutes=5), - jwt_service=JwtSessionService( - secret=_SECRET, expiry_minutes=60, session_version=1 - ), - persist=lambda _values: None, - ) - identity = DeviceIdentity( - device_id="ignored-private-id", - device_name="MeshpointAlpha", - latitude=12.34, - longitude=56.78, - altitude=99.0, - hardware_description="RAK2287 + Raspberry Pi 4", - firmware_version="0.7.3-test", - ) - identity_routes.init_routes(identity, auth_service) - app = FastAPI() - app.include_router(identity_routes.router) - return TestClient(app), cfg - - -class TestIdentityEndpoint(unittest.TestCase): - def tearDown(self) -> None: - identity_routes.reset_routes() - - def test_setup_required_when_no_admin_hash(self) -> None: - client, _ = _build_client() - response = client.get("/api/identity") - self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["device_name"], "MeshpointAlpha") - self.assertEqual(body["firmware_version"], "0.7.3-test") - self.assertTrue(body["setup_required"]) - - def test_setup_not_required_after_password_set(self) -> None: - client, _ = _build_client(with_password=True) - response = client.get("/api/identity") - body = response.json() - self.assertFalse(body["setup_required"]) - - def test_response_does_not_leak_pii_fields(self) -> None: - client, _ = _build_client() - body = client.get("/api/identity").json() - forbidden_keys = { - "device_id", - "latitude", - "longitude", - "altitude", - "hardware_description", - "auth_token", - } - leaked = forbidden_keys & set(body.keys()) - self.assertFalse(leaked, f"PII fields leaked: {leaked}") - - def test_503_when_uninitialized(self) -> None: - identity_routes.reset_routes() - app = FastAPI() - app.include_router(identity_routes.router) - response = TestClient(app).get("/api/identity") - self.assertEqual(response.status_code, 503) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_jwt_session.py b/tests/test_jwt_session.py deleted file mode 100644 index 979bc08..0000000 --- a/tests/test_jwt_session.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Unit tests for ``src.api.auth.jwt_session``. - -Covers happy-path issue/verify, every failure mode that must collapse -to ``None`` (bad sig, expired, missing claim, role mismatch, -``session_version`` mismatch, ``alg: none``), and constructor -guards. -""" - -from __future__ import annotations - -import time -import unittest -from datetime import datetime, timedelta, timezone - -import jwt - -from src.api.auth.jwt_session import ( - ROLE_ADMIN, - ROLE_VIEWER, - JwtSessionService, - SessionClaims, -) - -_SECRET = "test-secret-do-not-use-in-prod-" + "a" * 16 - - -class TestJwtSessionService(unittest.TestCase): - def setUp(self) -> None: - self.service = JwtSessionService( - secret=_SECRET, expiry_minutes=60, session_version=1 - ) - - def test_issue_then_verify_admin(self) -> None: - token = self.service.issue("admin", ROLE_ADMIN) - claims = self.service.verify(token) - self.assertIsInstance(claims, SessionClaims) - assert claims is not None - self.assertEqual(claims.subject, "admin") - self.assertEqual(claims.role, ROLE_ADMIN) - self.assertEqual(claims.session_version, 1) - - def test_issue_then_verify_viewer(self) -> None: - token = self.service.issue("viewer", ROLE_VIEWER) - claims = self.service.verify(token) - assert claims is not None - self.assertEqual(claims.role, ROLE_VIEWER) - - def test_verify_rejects_unknown_role(self) -> None: - with self.assertRaises(ValueError): - self.service.issue("admin", "superuser") - - def test_verify_rejects_empty_subject(self) -> None: - with self.assertRaises(ValueError): - self.service.issue("", ROLE_ADMIN) - - def test_verify_rejects_bad_signature(self) -> None: - token = self.service.issue("admin", ROLE_ADMIN) - other = JwtSessionService( - secret=_SECRET[::-1], expiry_minutes=60, session_version=1 - ) - self.assertIsNone(other.verify(token)) - - def test_verify_rejects_expired_token(self) -> None: - past = datetime.now(timezone.utc) - timedelta(minutes=5) - payload = { - "sub": "admin", - "role": ROLE_ADMIN, - "sv": 1, - "iat": int(past.timestamp()) - 60, - "exp": int(past.timestamp()), - } - token = jwt.encode(payload, _SECRET, algorithm="HS256") - self.assertIsNone(self.service.verify(token)) - - def test_verify_rejects_session_version_mismatch(self) -> None: - token = self.service.issue("admin", ROLE_ADMIN) - bumped = JwtSessionService( - secret=_SECRET, expiry_minutes=60, session_version=2 - ) - self.assertIsNone(bumped.verify(token)) - - def test_verify_rejects_alg_none_token(self) -> None: - payload = { - "sub": "admin", - "role": ROLE_ADMIN, - "sv": 1, - "iat": int(time.time()), - "exp": int(time.time()) + 60, - } - unsigned = jwt.encode(payload, "", algorithm="none") - self.assertIsNone(self.service.verify(unsigned)) - - def test_verify_rejects_missing_required_claim(self) -> None: - payload = { - "sub": "admin", - "role": ROLE_ADMIN, - "iat": int(time.time()), - "exp": int(time.time()) + 60, - } - token = jwt.encode(payload, _SECRET, algorithm="HS256") - self.assertIsNone(self.service.verify(token)) - - def test_verify_rejects_unknown_role_in_payload(self) -> None: - payload = { - "sub": "mallory", - "role": "root", - "sv": 1, - "iat": int(time.time()), - "exp": int(time.time()) + 60, - } - token = jwt.encode(payload, _SECRET, algorithm="HS256") - self.assertIsNone(self.service.verify(token)) - - def test_verify_rejects_empty_token(self) -> None: - self.assertIsNone(self.service.verify("")) - - def test_generate_secret_is_long_and_random(self) -> None: - first = JwtSessionService.generate_secret() - second = JwtSessionService.generate_secret() - self.assertNotEqual(first, second) - self.assertGreaterEqual(len(first), 32) - - def test_constructor_rejects_bad_inputs(self) -> None: - with self.assertRaises(ValueError): - JwtSessionService(secret="", expiry_minutes=60, session_version=1) - with self.assertRaises(ValueError): - JwtSessionService(secret=_SECRET, expiry_minutes=0, session_version=1) - with self.assertRaises(ValueError): - JwtSessionService(secret=_SECRET, expiry_minutes=60, session_version=0) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_lockout_tracker.py b/tests/test_lockout_tracker.py deleted file mode 100644 index 6d2494c..0000000 --- a/tests/test_lockout_tracker.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Unit tests for ``src.api.auth.lockout_tracker``. - -Uses a hand-driven monotonic clock so the suite never sleeps. -""" - -from __future__ import annotations - -import unittest - -from src.api.auth.lockout_tracker import LockoutTracker - - -class _FakeClock: - def __init__(self) -> None: - self.now = 1000.0 - - def __call__(self) -> float: - return self.now - - def advance(self, seconds: float) -> None: - self.now += seconds - - -class TestLockoutTracker(unittest.TestCase): - def setUp(self) -> None: - self.clock = _FakeClock() - self.tracker = LockoutTracker( - max_attempts=3, cooldown_minutes=5, clock=self.clock - ) - - def test_starts_unlocked(self) -> None: - self.assertIsNone(self.tracker.remaining_seconds("admin")) - - def test_failures_under_threshold_do_not_lock(self) -> None: - self.assertIsNone(self.tracker.register_failure("admin")) - self.assertIsNone(self.tracker.register_failure("admin")) - self.assertIsNone(self.tracker.remaining_seconds("admin")) - - def test_failures_at_threshold_lock(self) -> None: - self.tracker.register_failure("admin") - self.tracker.register_failure("admin") - cooldown = self.tracker.register_failure("admin") - self.assertEqual(cooldown, 300) - remaining = self.tracker.remaining_seconds("admin") - self.assertIsNotNone(remaining) - assert remaining is not None - self.assertGreater(remaining, 0) - self.assertLessEqual(remaining, 301) - - def test_register_success_clears_failures(self) -> None: - self.tracker.register_failure("admin") - self.tracker.register_failure("admin") - self.tracker.register_success("admin") - self.assertIsNone(self.tracker.register_failure("admin")) - self.assertIsNone(self.tracker.remaining_seconds("admin")) - - def test_lock_clears_after_cooldown_elapses(self) -> None: - for _ in range(3): - self.tracker.register_failure("admin") - self.assertIsNotNone(self.tracker.remaining_seconds("admin")) - self.clock.advance(301) - self.assertIsNone(self.tracker.remaining_seconds("admin")) - - def test_keys_are_isolated(self) -> None: - for _ in range(3): - self.tracker.register_failure("admin") - self.assertIsNotNone(self.tracker.remaining_seconds("admin")) - self.assertIsNone(self.tracker.remaining_seconds("viewer")) - self.assertIsNone(self.tracker.register_failure("viewer")) - - def test_failure_during_lock_returns_remaining_cooldown(self) -> None: - for _ in range(3): - self.tracker.register_failure("admin") - self.clock.advance(120) - residual = self.tracker.register_failure("admin") - self.assertIsNotNone(residual) - assert residual is not None - self.assertGreater(residual, 0) - self.assertLessEqual(residual, 181) - - def test_empty_key_is_noop(self) -> None: - self.assertIsNone(self.tracker.register_failure("")) - self.assertIsNone(self.tracker.remaining_seconds("")) - self.tracker.register_success("") - - def test_constructor_validation(self) -> None: - with self.assertRaises(ValueError): - LockoutTracker(max_attempts=0, cooldown_minutes=5) - with self.assertRaises(ValueError): - LockoutTracker(max_attempts=5, cooldown_minutes=0) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_meshtastic_decoder_header_validity.py b/tests/test_meshtastic_decoder_header_validity.py deleted file mode 100644 index bcb07ee..0000000 --- a/tests/test_meshtastic_decoder_header_validity.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Lock the structural-validity check in ``MeshtasticDecoder._parse_header``. - -A Meshtastic packet originates with ``hop_limit == hop_start`` and decrements -``hop_limit`` at each relay while ``hop_start`` stays fixed. Therefore at any -point in flight, ``hop_limit <= hop_start`` is mathematically guaranteed. -A header where ``hop_limit > hop_start`` cannot have come from an honest -sender, which means the flags byte was corrupted in transit (or the -"packet" was decoded from RF noise that the chip flagged with a status -the wrapper failed to filter). - -The check exists as defense in depth alongside the wrapper's status-code -filter (CRC_BAD / NO_CRC / unknown all dropped at source). If a future HAL -revision introduces a new status code, or if the chip ever false-positives -a CRC at the noise floor, this check still catches the resulting corrupted -header before a phantom node row materializes in SQLite. - -History: shipped in v0.7.3 alongside the wrapper-level NO_CRC filter after -fleet diagnostics on nopemesh and kmax confirmed corrupted headers were -the root cause of phantom node accumulation (>72k phantoms on nopemesh's -local DB before the fix). -""" - -from __future__ import annotations - -import struct -import unittest - -from src.decode.crypto_service import CryptoService -from src.decode.meshtastic_decoder import MeshtasticDecoder - - -def _flags(hop_limit: int, hop_start: int, *, - want_ack: bool = False, via_mqtt: bool = False) -> int: - """Pack a Meshtastic header flags byte. - - Bits 0-2: hop_limit - Bit 3: want_ack - Bit 4: via_mqtt - Bits 5-7: hop_start - """ - return ( - (hop_limit & 0x07) - | (0x08 if want_ack else 0) - | (0x10 if via_mqtt else 0) - | ((hop_start & 0x07) << 5) - ) - - -def _build_header( - *, - flags: int, - dest_id: int = 0xFFFFFFFF, - source_id: int = 0xDEADBEEF, - packet_id: int = 0x12345678, - channel_hash: int = 0x08, - next_hop: int = 0x00, - relay_node: int = 0x00, -) -> bytes: - """Synthesize a 16-byte Meshtastic header for tests.""" - return ( - struct.pack(" hop_start`` returns None at the parser.""" - - def test_hl_greater_than_hs_returns_none(self): - """Classic corruption signature: hl=4 > hs=3 cannot happen - on an honestly-originated packet.""" - header = _build_header(flags=_flags(hop_limit=4, hop_start=3)) - - parsed = MeshtasticDecoder._parse_header(header) - - self.assertIsNone(parsed) - - def test_extreme_hl_greater_than_hs_returns_none(self): - """hl=7 (max) > hs=0 (also possible after corruption) returns None.""" - header = _build_header(flags=_flags(hop_limit=7, hop_start=0)) - - parsed = MeshtasticDecoder._parse_header(header) - - self.assertIsNone(parsed) - - def test_hl_equal_to_hs_is_accepted(self): - """A direct (zero-hop-consumed) packet has hl == hs. Real.""" - header = _build_header(flags=_flags(hop_limit=3, hop_start=3)) - - parsed = MeshtasticDecoder._parse_header(header) - - self.assertIsNotNone(parsed) - self.assertEqual(parsed["hop_limit"], 3) - self.assertEqual(parsed["hop_start"], 3) - - def test_hl_less_than_hs_is_accepted(self): - """A relayed packet has hl < hs. Real.""" - header = _build_header(flags=_flags(hop_limit=2, hop_start=4)) - - parsed = MeshtasticDecoder._parse_header(header) - - self.assertIsNotNone(parsed) - self.assertEqual(parsed["hop_limit"], 2) - self.assertEqual(parsed["hop_start"], 4) - - def test_zero_hop_packet_is_accepted(self): - """A node configured with max_hops=0 transmits with hl=0/hs=0.""" - header = _build_header(flags=_flags(hop_limit=0, hop_start=0)) - - parsed = MeshtasticDecoder._parse_header(header) - - self.assertIsNotNone(parsed) - self.assertEqual(parsed["hop_limit"], 0) - self.assertEqual(parsed["hop_start"], 0) - - -class TestDecodeRejectsImpossibleHops(unittest.TestCase): - """End-to-end: ``decode()`` returns None for hl > hs packets, - so no Packet object reaches the storage layer.""" - - def setUp(self): - self._decoder = MeshtasticDecoder(CryptoService()) - - def test_decode_returns_none_for_impossible_hops(self): - """A full ``decode()`` call must reject corrupted hop arithmetic - before constructing a Packet object. This is the structural - guarantee that prevents phantom node rows.""" - raw = _build_header(flags=_flags(hop_limit=5, hop_start=2)) - - packet = self._decoder.decode(raw) - - self.assertIsNone(packet) - - def test_decode_succeeds_for_valid_hops(self): - """Sanity check: plausible hop arithmetic still decodes normally.""" - raw = _build_header(flags=_flags(hop_limit=2, hop_start=5)) - - packet = self._decoder.decode(raw) - - self.assertIsNotNone(packet) - self.assertEqual(packet.hop_limit, 2) - self.assertEqual(packet.hop_start, 5) - - -class TestImpossibleHopsAcrossFlagsCombinations(unittest.TestCase): - """The hl > hs check must trigger regardless of want_ack / via_mqtt.""" - - def test_with_want_ack_set(self): - header = _build_header( - flags=_flags(hop_limit=4, hop_start=3, want_ack=True) - ) - self.assertIsNone(MeshtasticDecoder._parse_header(header)) - - def test_with_via_mqtt_set(self): - header = _build_header( - flags=_flags(hop_limit=6, hop_start=1, via_mqtt=True) - ) - self.assertIsNone(MeshtasticDecoder._parse_header(header)) - - def test_with_both_flag_bits_set(self): - header = _build_header( - flags=_flags(hop_limit=7, hop_start=2, - want_ack=True, via_mqtt=True) - ) - self.assertIsNone(MeshtasticDecoder._parse_header(header)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_messages_routes.py b/tests/test_messages_routes.py new file mode 100644 index 0000000..541c25b --- /dev/null +++ b/tests/test_messages_routes.py @@ -0,0 +1,105 @@ +"""Edge-case tests for message API route helpers.""" + +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +try: + from fastapi import HTTPException + from src.api.routes import messages + _HAS_FASTAPI = True +except ModuleNotFoundError: # pragma: no cover - environment dependent + HTTPException = Exception + messages = None + _HAS_FASTAPI = False + + +class _StubMessageRepo: + async def get_conversations(self, include_overheard=False): + return [] + + +class _StubNodeRepo: + def __init__(self, nodes): + self._nodes = nodes + + async def get_all(self): + return self._nodes + + +@unittest.skipUnless(_HAS_FASTAPI, "fastapi is not installed in this environment") +class TestMessageRoutes(unittest.IsolatedAsyncioTestCase): + """Keep route behavior stable for common UI edge cases.""" + + async def test_send_message_rejects_whitespace_only_text(self): + messages.init_routes( + tx_service=SimpleNamespace(), + message_repo=_StubMessageRepo(), + node_repo=None, + meshcore_tx=None, + config=None, + ) + with self.assertRaises(HTTPException) as ctx: + await messages.send_message(messages.SendRequest(text=" ")) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("cannot be empty", str(ctx.exception.detail)) + + async def test_send_message_requires_transmit_service(self): + messages.init_routes( + tx_service=None, + message_repo=_StubMessageRepo(), + node_repo=None, + meshcore_tx=None, + config=None, + ) + with self.assertRaises(HTTPException) as ctx: + await messages.send_message(messages.SendRequest(text="hello")) + self.assertEqual(ctx.exception.status_code, 503) + self.assertIn("Transmit service not available", str(ctx.exception.detail)) + + async def test_get_contacts_filters_synthetic_node_ids(self): + node_repo = _StubNodeRepo([ + {"node_id": "rf_log", "long_name": "RF Log"}, + {"node_id": "mc:channel", "long_name": "MeshCore Channel"}, + {"node_id": "mc:abc123", "long_name": "MeshCore Synthetic"}, + {"node_id": "abcd1234", "long_name": "Field Node"}, + ]) + messages.init_routes( + tx_service=None, + message_repo=_StubMessageRepo(), + node_repo=node_repo, + meshcore_tx=None, + config=None, + ) + + contacts = await messages.get_contacts() + self.assertEqual(len(contacts), 1) + self.assertEqual(contacts[0]["node_id"], "abcd1234") + self.assertEqual(contacts[0]["name"], "Field Node") + + async def test_get_channels_falls_back_to_preset_when_primary_blank(self): + cfg = SimpleNamespace( + meshtastic=SimpleNamespace( + primary_channel_name="", + channel_keys={}, + ), + radio=SimpleNamespace( + spreading_factor=11, + bandwidth_khz=250.0, + ), + ) + messages.init_routes( + tx_service=None, + message_repo=_StubMessageRepo(), + node_repo=None, + meshcore_tx=None, + config=cfg, + ) + + channels = await messages.get_channels() + self.assertEqual(channels[0]["name"], "LongFast") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mqtt_publisher.py b/tests/test_mqtt_publisher.py new file mode 100644 index 0000000..484bc23 --- /dev/null +++ b/tests/test_mqtt_publisher.py @@ -0,0 +1,33 @@ +"""Tests for MQTT publisher logging and gateway ID behavior.""" + +from __future__ import annotations + +import unittest + +from src.config import MqttConfig +from src.relay.mqtt_publisher import MqttPublisher, _generate_gateway_id + + +class TestGatewayId(unittest.TestCase): + def test_gateway_id_is_deterministic_and_case_insensitive(self): + lower = _generate_gateway_id("meshpoint-alpha") + upper = _generate_gateway_id("MESHPOINT-ALPHA") + self.assertEqual(lower, upper) + self.assertTrue(lower.startswith("!")) + self.assertEqual(len(lower), 9) + + +class TestMqttPublisherLogging(unittest.TestCase): + def test_on_connect_logs_start_message(self): + pub = MqttPublisher(MqttConfig(enabled=True), device_name="meshpoint-alpha") + with self.assertLogs("src.relay.mqtt_publisher", level="INFO") as logs: + pub._on_connect(client=None, userdata=None, flags=None, rc=0) + self.assertTrue(pub.connected) + self.assertTrue( + any("MQTT publisher started as !" in msg for msg in logs.output), + logs.output, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mqtt_topic_paths.py b/tests/test_mqtt_topic_paths.py new file mode 100644 index 0000000..97669b4 --- /dev/null +++ b/tests/test_mqtt_topic_paths.py @@ -0,0 +1,28 @@ +"""Tests for MQTT topic prefix composition.""" + +from __future__ import annotations + +import unittest + +from src.relay.mqtt_formatter import _build_topic_prefix + + +class TestMqttTopicPrefix(unittest.TestCase): + def test_default_root_and_region(self): + self.assertEqual(_build_topic_prefix("msh", "US"), "msh/US") + + def test_hierarchical_region_path(self): + self.assertEqual(_build_topic_prefix("msh", "US/FL"), "msh/US/FL") + + def test_topic_root_can_include_region_segments(self): + self.assertEqual(_build_topic_prefix("msh/US", "FL"), "msh/US/FL") + + def test_prevents_duplicate_region_suffix(self): + self.assertEqual(_build_topic_prefix("msh/US", "US"), "msh/US") + + def test_strips_slashes(self): + self.assertEqual(_build_topic_prefix("/msh/US/", "/FL/"), "msh/US/FL") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_password_hasher.py b/tests/test_password_hasher.py deleted file mode 100644 index 7edffff..0000000 --- a/tests/test_password_hasher.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Unit tests for ``src.api.auth.password_hasher``. - -Uses bcrypt rounds=4 throughout to keep the suite fast (each -hash/verify is < 5 ms vs ~250 ms at the production cost factor). -""" - -from __future__ import annotations - -import unittest - -from src.api.auth.password_hasher import PasswordHasher - - -class TestPasswordHasher(unittest.TestCase): - def setUp(self) -> None: - self.hasher = PasswordHasher(rounds=4) - - def test_hash_then_verify_roundtrip(self) -> None: - digest = self.hasher.hash("correct horse battery staple") - self.assertTrue(digest.startswith("$2")) - self.assertTrue(self.hasher.verify("correct horse battery staple", digest)) - - def test_verify_rejects_wrong_password(self) -> None: - digest = self.hasher.hash("hunter2") - self.assertFalse(self.hasher.verify("hunter3", digest)) - - def test_verify_returns_false_for_empty_stored_hash(self) -> None: - self.assertFalse(self.hasher.verify("anything", "")) - - def test_verify_returns_false_for_empty_candidate(self) -> None: - digest = self.hasher.hash("hunter2") - self.assertFalse(self.hasher.verify("", digest)) - - def test_verify_returns_false_for_malformed_hash(self) -> None: - self.assertFalse(self.hasher.verify("hunter2", "not-a-bcrypt-hash")) - - def test_hash_rejects_empty_password(self) -> None: - with self.assertRaises(ValueError): - self.hasher.hash("") - - def test_hash_rejects_non_string_password(self) -> None: - with self.assertRaises(TypeError): - self.hasher.hash(b"bytes-not-allowed") # type: ignore[arg-type] - - def test_two_hashes_of_same_password_differ(self) -> None: - first = self.hasher.hash("repeat") - second = self.hasher.hash("repeat") - self.assertNotEqual(first, second) - self.assertTrue(self.hasher.verify("repeat", first)) - self.assertTrue(self.hasher.verify("repeat", second)) - - def test_unicode_password_supported(self) -> None: - password = "p\u00e1ssw\u00f8rd-\U0001f680" - digest = self.hasher.hash(password) - self.assertTrue(self.hasher.verify(password, digest)) - self.assertFalse(self.hasher.verify(password + "x", digest)) - - def test_rounds_out_of_range_rejected(self) -> None: - with self.assertRaises(ValueError): - PasswordHasher(rounds=2) - with self.assertRaises(ValueError): - PasswordHasher(rounds=20) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_protected_router_wiring.py b/tests/test_protected_router_wiring.py deleted file mode 100644 index b36db55..0000000 --- a/tests/test_protected_router_wiring.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Integration tests for the include-time auth contract. - -Mirrors the wiring that ``src.api.server.create_app`` uses: - -- public routers mounted with no auth dependency -- protected routers mounted with ``dependencies=[Depends(require_auth)]`` -- WS handshake gated by ``authenticate_websocket`` - -Confirms that a router mounted with ``Depends(require_auth)`` rejects -anonymous requests with 401 across every method on it, accepts a -valid session cookie, and that the WS guard rejects un-authed -upgrades with the negotiated 4401 close code. -""" - -from __future__ import annotations - -import unittest - -from fastapi import APIRouter, Depends, FastAPI, WebSocket, WebSocketDisconnect -from fastapi.testclient import TestClient - -from src.api.auth import dependencies as auth_deps -from src.api.auth.dependencies import SESSION_COOKIE_NAME, require_auth -from src.api.auth.jwt_session import ROLE_ADMIN, JwtSessionService -from src.api.auth.ws_guard import WS_AUTH_CLOSE_CODE, authenticate_websocket - -_SECRET = "wiring-test-secret-" + "q" * 16 - - -def _build_protected_router() -> APIRouter: - router = APIRouter(prefix="/api/sample", tags=["sample"]) - - @router.get("/ping") - def ping(): - return {"pong": True} - - @router.post("/echo") - def echo(): - return {"echoed": True} - - return router - - -def _build_app(jwt_service: JwtSessionService) -> FastAPI: - app = FastAPI() - app.include_router( - _build_protected_router(), - dependencies=[Depends(require_auth)], - ) - - @app.websocket("/ws") - async def websocket_endpoint(websocket: WebSocket): - claims = authenticate_websocket(websocket, jwt_service) - if claims is None: - await websocket.close(code=WS_AUTH_CLOSE_CODE) - return - await websocket.accept() - try: - while True: - await websocket.receive_text() - except WebSocketDisconnect: - return - - return app - - -class TestProtectedRouterWiring(unittest.TestCase): - def setUp(self) -> None: - self.service = JwtSessionService( - secret=_SECRET, expiry_minutes=60, session_version=1 - ) - auth_deps.init_auth(self.service) - self.client = TestClient(_build_app(self.service)) - - def tearDown(self) -> None: - auth_deps.reset_auth() - - def test_get_on_protected_router_without_auth_is_401(self) -> None: - response = self.client.get("/api/sample/ping") - self.assertEqual(response.status_code, 401) - - def test_post_on_protected_router_without_auth_is_401(self) -> None: - response = self.client.post("/api/sample/echo") - self.assertEqual(response.status_code, 401) - - def test_protected_router_accepts_valid_cookie(self) -> None: - token = self.service.issue("admin", ROLE_ADMIN) - self.client.cookies.set(SESSION_COOKIE_NAME, token) - response = self.client.get("/api/sample/ping") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"pong": True}) - - def test_protected_router_accepts_bearer_header(self) -> None: - token = self.service.issue("admin", ROLE_ADMIN) - response = self.client.get( - "/api/sample/ping", - headers={"Authorization": f"Bearer {token}"}, - ) - self.assertEqual(response.status_code, 200) - - -class TestWebsocketAuthGate(unittest.TestCase): - def setUp(self) -> None: - self.service = JwtSessionService( - secret=_SECRET, expiry_minutes=60, session_version=1 - ) - auth_deps.init_auth(self.service) - self.client = TestClient(_build_app(self.service)) - - def tearDown(self) -> None: - auth_deps.reset_auth() - - def test_ws_rejects_anonymous_with_4401(self) -> None: - with self.assertRaises(WebSocketDisconnect) as ctx: - with self.client.websocket_connect("/ws"): - pass - self.assertEqual(ctx.exception.code, WS_AUTH_CLOSE_CODE) - - def test_ws_accepts_valid_cookie(self) -> None: - token = self.service.issue("admin", ROLE_ADMIN) - self.client.cookies.set(SESSION_COOKIE_NAME, token) - with self.client.websocket_connect("/ws") as ws: - ws.close() - - def test_ws_accepts_query_token_fallback(self) -> None: - token = self.service.issue("admin", ROLE_ADMIN) - with self.client.websocket_connect(f"/ws?token={token}") as ws: - ws.close() - - def test_ws_rejects_invalid_token(self) -> None: - with self.assertRaises(WebSocketDisconnect) as ctx: - with self.client.websocket_connect("/ws?token=not-a-jwt"): - pass - self.assertEqual(ctx.exception.code, WS_AUTH_CLOSE_CODE) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_relay_node_header.py b/tests/test_relay_node_header.py index 0590ed4..0c7d9ce 100644 --- a/tests/test_relay_node_header.py +++ b/tests/test_relay_node_header.py @@ -24,7 +24,7 @@ def _build_header( dest_id: int = 0xFFFFFFFF, source_id: int = 0xDEADBEEF, packet_id: int = 0x12345678, - flags: int = 0x63, + flags: int = 0x03, channel_hash: int = 0x08, next_hop: int = 0x00, relay_node: int = 0x00, @@ -33,13 +33,6 @@ def _build_header( Mirrors the on-air byte order documented in ``MeshtasticDecoder._parse_header``. - - Default ``flags = 0x63`` encodes ``hop_limit=3, hop_start=3`` - (a fresh direct packet with 3 hops of headroom). Earlier the default - was ``0x03`` (hop_limit=3, hop_start=0) which is structurally - impossible for an honestly-originated packet; the v0.7.3 header - validity check rejects that combination so the default had to move - to a sane value. """ return ( struct.pack(" None: - self._web_auth = web_auth - - def __call__(self) -> AppConfig: - cfg = AppConfig() - cfg.web_auth = self._web_auth - return cfg - - -class _CapturingPersister: - def __init__(self) -> None: - self.calls: list[tuple[str, dict]] = [] - - def __call__(self, section: str, values: dict) -> None: - self.calls.append((section, values)) - - -def _writer() -> _CliWriter: - return _CliWriter(io.StringIO()) - - -def _stub_prompt(value: str): - def _ask(_label: str) -> str: - return value - return _ask - - -class TestResetPasswordCommand(unittest.TestCase): - def setUp(self) -> None: - self.web_auth = WebAuthConfig( - admin_password_hash="$2b$04$old", - jwt_secret="old-secret-do-not-reuse", - session_version=1, - ) - self.persister = _CapturingPersister() - - def _run(self, password: str, confirm: str) -> int: - return run_reset_password( - prompt_password=_stub_prompt(password), - confirm_password=_stub_prompt(confirm), - writer=_writer(), - config_loader=_FakeConfigLoader(self.web_auth), - persister=self.persister, - ) - - def test_success_writes_new_hash_and_rotates_secret(self) -> None: - exit_code = self._run("brand-new-pass-1", "brand-new-pass-1") - self.assertEqual(exit_code, 0) - self.assertEqual(len(self.persister.calls), 1) - section, values = self.persister.calls[0] - self.assertEqual(section, "web_auth") - self.assertIn("admin_password_hash", values) - self.assertIn("jwt_secret", values) - self.assertIn("session_version", values) - self.assertNotEqual(values["jwt_secret"], "old-secret-do-not-reuse") - self.assertGreaterEqual(values["session_version"], 2) - - def test_new_hash_is_verifiable(self) -> None: - self._run("verifiable-pass", "verifiable-pass") - new_hash = self.persister.calls[0][1]["admin_password_hash"] - self.assertTrue(PasswordHasher(rounds=4).verify("verifiable-pass", new_hash)) - - def test_mismatched_passwords_abort_without_persist(self) -> None: - exit_code = self._run("password-one", "password-two") - self.assertEqual(exit_code, 1) - self.assertEqual(self.persister.calls, []) - - def test_short_password_aborts_without_persist(self) -> None: - exit_code = self._run("short", "short") - self.assertEqual(exit_code, 1) - self.assertEqual(self.persister.calls, []) - - def test_session_version_bumps_from_default(self) -> None: - self.web_auth.session_version = 7 - self._run("rotate-pass-1", "rotate-pass-1") - values = self.persister.calls[0][1] - self.assertEqual(values["session_version"], 8) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_sx1302_wrapper.py b/tests/test_sx1302_wrapper.py index 29ffe60..135a75a 100644 --- a/tests/test_sx1302_wrapper.py +++ b/tests/test_sx1302_wrapper.py @@ -1,19 +1,12 @@ """Tests for ``SX1302Wrapper.receive`` packet filtering. -Locks in the contract that the wrapper only emits packets the chip -flagged as ``STAT_CRC_OK``. Anything else (CRC_BAD, NO_CRC, or any -unknown status code a future HAL revision might introduce) is dropped -at source with a counted WARNING log line. Without this filter, -RF-corrupted bytes flow into the Meshtastic decoder where they create -three classes of noise: +Locks in the contract that CRC_BAD packets are dropped at the source +rather than being passed downstream as if they were valid packets. +Without this filter, RF-corrupted bytes flow into the Meshtastic +decoder where they create three classes of noise: - phantom node IDs (corrupted source field) - false ENCRYPTED packets (corrupted channel hash byte) - garbled-but-readable text (corrupted payload mid-packet) - -History: v0.7.2 added the CRC_BAD drop. v0.7.3 extended the gate to -NO_CRC and unknown statuses after fleet diagnostics on -high-traffic Meshpoints (nopemesh, kmax) showed NO_CRC packets at the -noise floor were the dominant remaining phantom-creation path. """ from __future__ import annotations @@ -65,8 +58,8 @@ def _build_wrapper() -> SX1302Wrapper: return wrapper -class TestReceiveFiltersCorruptedPackets(unittest.TestCase): - """Only CRC_OK packets reach the decoder; everything else is dropped.""" +class TestReceiveFiltersCrcBad(unittest.TestCase): + """CRC_BAD packets are filtered before reaching the decoder.""" def test_crc_bad_packet_is_dropped(self): wrapper = _build_wrapper() @@ -81,8 +74,6 @@ def populate(_max, pkt_array): self.assertEqual(result, []) self.assertEqual(wrapper.crc_bad_count, 1) - self.assertEqual(wrapper.no_crc_count, 0) - self.assertEqual(wrapper.unknown_status_count, 0) def test_crc_ok_packet_passes_through(self): wrapper = _build_wrapper() @@ -99,56 +90,36 @@ def populate(_max, pkt_array): self.assertTrue(result[0].crc_ok) self.assertEqual(len(result[0].payload), 16) self.assertEqual(wrapper.crc_bad_count, 0) - self.assertEqual(wrapper.no_crc_count, 0) - self.assertEqual(wrapper.unknown_status_count, 0) - - def test_no_crc_packet_is_dropped(self): - """STAT_NO_CRC packets carry corrupted header bytes that produce - phantom node rows downstream. v0.7.3 drops them at source.""" - wrapper = _build_wrapper() - - def populate(_max, pkt_array): - _set_packet(pkt_array[0], status=STAT_NO_CRC, size=16) - return 1 - wrapper._lib.lgw_receive.side_effect = populate - - result = wrapper.receive() - - self.assertEqual(result, []) - self.assertEqual(wrapper.no_crc_count, 1) - self.assertEqual(wrapper.crc_bad_count, 0) - self.assertEqual(wrapper.unknown_status_count, 0) + def test_no_crc_packet_still_passes_through(self): + """STAT_NO_CRC was passed through pre-fix; that behavior is unchanged. - def test_unknown_status_packet_is_dropped(self): - """Anything that isn't CRC_OK / CRC_BAD / NO_CRC is treated as - suspect and dropped, so a future HAL revision that introduces a - new status code can't silently re-open the leak path.""" + Locking it in so a future tightening of the filter is a deliberate + decision, not an accident. + """ wrapper = _build_wrapper() def populate(_max, pkt_array): - _set_packet(pkt_array[0], status=0x42, size=24) + _set_packet(pkt_array[0], status=STAT_NO_CRC, size=16) return 1 wrapper._lib.lgw_receive.side_effect = populate result = wrapper.receive() - self.assertEqual(result, []) - self.assertEqual(wrapper.unknown_status_count, 1) + self.assertEqual(len(result), 1) + self.assertFalse(result[0].crc_ok) self.assertEqual(wrapper.crc_bad_count, 0) - self.assertEqual(wrapper.no_crc_count, 0) - def test_mixed_batch_returns_only_crc_ok_packets(self): + def test_mixed_batch_returns_only_good_packets(self): wrapper = _build_wrapper() def populate(_max, pkt_array): _set_packet(pkt_array[0], status=STAT_CRC_OK, size=16, rssi=-40.0) _set_packet(pkt_array[1], status=STAT_CRC_BAD, size=32, rssi=-75.0) _set_packet(pkt_array[2], status=STAT_CRC_OK, size=24, rssi=-55.0) - _set_packet(pkt_array[3], status=STAT_NO_CRC, size=20, rssi=-110.0) - _set_packet(pkt_array[4], status=0x07, size=18, rssi=-95.0) - return 5 + _set_packet(pkt_array[3], status=STAT_CRC_BAD, size=8, rssi=-80.0) + return 4 wrapper._lib.lgw_receive.side_effect = populate @@ -157,9 +128,7 @@ def populate(_max, pkt_array): self.assertEqual(len(result), 2) rssi_values = sorted(p.rssi for p in result) self.assertEqual(rssi_values, [-55.0, -40.0]) - self.assertEqual(wrapper.crc_bad_count, 1) - self.assertEqual(wrapper.no_crc_count, 1) - self.assertEqual(wrapper.unknown_status_count, 1) + self.assertEqual(wrapper.crc_bad_count, 2) def test_crc_bad_counter_persists_across_calls(self): wrapper = _build_wrapper() @@ -176,20 +145,6 @@ def populate_one_bad(_max, pkt_array): self.assertEqual(wrapper.crc_bad_count, 3) - def test_no_crc_counter_persists_across_calls(self): - wrapper = _build_wrapper() - - def populate_one_no_crc(_max, pkt_array): - _set_packet(pkt_array[0], status=STAT_NO_CRC, size=20) - return 1 - - wrapper._lib.lgw_receive.side_effect = populate_one_no_crc - - wrapper.receive() - wrapper.receive() - - self.assertEqual(wrapper.no_crc_count, 2) - def test_size_zero_packet_is_skipped_before_status_check(self): """size==0 is a separate skip path that still works after the fix.""" wrapper = _build_wrapper() diff --git a/tests/test_websocket_auth_close_code.py b/tests/test_websocket_auth_close_code.py deleted file mode 100644 index fadce04..0000000 --- a/tests/test_websocket_auth_close_code.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Regression tests for the v0.7.3.1 WS auth-close-code bug. - -In v0.7.3, ``server.py`` called ``await websocket.close(code=4401)`` -*before* ``await websocket.accept()``. Starlette translates that into -an HTTP 403 on the WebSocket upgrade, which browsers report to JS as -close code ``1006`` (Abnormal Closure) instead of the negotiated -``4401``. The dashboard's WS client only redirects to ``/login`` on -``4401``; everything else falls through to the reconnect loop, so -unauthenticated users were stranded indefinitely on "Reconnecting..." -after upgrading. - -The fix moved the auth-rejection logic into ``_gate_ws_or_close`` -which always ``accept()``s before ``close()``. These tests pin the -call order at the production-endpoint level so a future refactor -cannot silently re-introduce the bug. ``starlette.testclient`` masks -the difference between pre- and post-accept close (both surface as -``WebSocketDisconnect`` with the right code), which is precisely why -the v0.7.3 CI suite failed to catch this. -""" - -from __future__ import annotations - -import unittest -from typing import Optional -from unittest.mock import AsyncMock - -from src.api.auth.jwt_session import ROLE_ADMIN, JwtSessionService -from src.api.auth.ws_guard import WS_AUTH_CLOSE_CODE -from src.api.server import _gate_ws_or_close - -_SECRET = "ws-close-code-test-secret-" + "x" * 16 - - -def _service() -> JwtSessionService: - return JwtSessionService(secret=_SECRET, expiry_minutes=60, session_version=1) - - -class _FakeWebSocket: - """Minimal WebSocket stand-in that records call order.""" - - def __init__(self, cookie_token: Optional[str] = None) -> None: - self.cookies = {"meshpoint_session": cookie_token} if cookie_token else {} - self.query_params = {} - self.calls: list[str] = [] - self.accept = AsyncMock(side_effect=self._record_accept) - self.close = AsyncMock(side_effect=self._record_close) - - async def _record_accept(self, *_args, **_kwargs) -> None: - self.calls.append("accept") - - async def _record_close(self, *_args, **kwargs) -> None: - self.calls.append(f"close:{kwargs.get('code')}") - - -class TestGateWsOrCloseRejectionPath(unittest.IsolatedAsyncioTestCase): - async def test_no_token_calls_accept_before_close(self) -> None: - ws = _FakeWebSocket() - ok = await _gate_ws_or_close(ws, _service()) - - self.assertFalse(ok) - self.assertEqual(ws.calls, ["accept", f"close:{WS_AUTH_CLOSE_CODE}"]) - ws.accept.assert_awaited_once() - ws.close.assert_awaited_once_with(code=WS_AUTH_CLOSE_CODE) - - async def test_invalid_token_calls_accept_before_close(self) -> None: - ws = _FakeWebSocket(cookie_token="not-a-jwt") - ok = await _gate_ws_or_close(ws, _service()) - - self.assertFalse(ok) - self.assertEqual(ws.calls, ["accept", f"close:{WS_AUTH_CLOSE_CODE}"]) - - async def test_close_uses_negotiated_4401_code(self) -> None: - ws = _FakeWebSocket() - await _gate_ws_or_close(ws, _service()) - ws.close.assert_awaited_once_with(code=4401) - - async def test_jwt_service_none_treated_as_unauth(self) -> None: - ws = _FakeWebSocket() - ok = await _gate_ws_or_close(ws, None) - self.assertFalse(ok) - self.assertEqual(ws.calls, ["accept", f"close:{WS_AUTH_CLOSE_CODE}"]) - - -class TestGateWsOrCloseAuthorizedPath(unittest.IsolatedAsyncioTestCase): - async def test_valid_cookie_returns_true_without_calling_close(self) -> None: - service = _service() - token = service.issue("admin", ROLE_ADMIN) - ws = _FakeWebSocket(cookie_token=token) - - ok = await _gate_ws_or_close(ws, service) - - self.assertTrue(ok) - self.assertEqual(ws.calls, []) - ws.accept.assert_not_awaited() - ws.close.assert_not_awaited() - - -class TestGateWsCallOrderIsExplicit(unittest.IsolatedAsyncioTestCase): - """Defense against the specific regression: accept must precede close.""" - - async def test_accept_index_strictly_lower_than_close_index(self) -> None: - ws = _FakeWebSocket() - await _gate_ws_or_close(ws, _service()) - - accept_idx = ws.calls.index("accept") - close_idx = ws.calls.index(f"close:{WS_AUTH_CLOSE_CODE}") - self.assertLess( - accept_idx, - close_idx, - "accept() must be awaited before close() so the custom " - "close code reaches the browser; pre-accept close causes " - "Starlette to send HTTP 403 and the close code is lost.", - ) - - -if __name__ == "__main__": # pragma: no cover - unittest.main()