diff options
32 files changed, 1522 insertions, 837 deletions
@@ -752,7 +752,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Group delete (hub) | `meshbay_hub.api.groups` | `DELETE /v1/groups/{group_id}` — admin only | | JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations | | Operator operations | `meshbay_node.ops` | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these; they take the daemon `state`, raise `OpError`, and know nothing about HTTP. Two implementations of one operation with two authorization checks is C1/C6 one size down | -| Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000). Each operation endpoint is one `_op(...)` line — logic there is a rule the MNP path does not have | +| Node local control API | `meshbay_node.ui.app` | JSON only, loopback + per-run token (localhost:18000): status, groups/roots, roster, denylist, node settings, peers, audit. Clients: the `meshbay-node` CLI and the desktop client's Node page. Each operation endpoint is one `_op(...)` line. The server-rendered dashboard, the `ui` CLI verb and the never-wired chat/config endpoints were removed 2026-09-01 (`docs/refactor-node-ui.md`) | | Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) | | Video flow control (browser) | `static/video-player.js` | `pump()` — the only place credit is granted. Read-ahead bounded by `BUFFER_AHEAD_S` of film, `STREAM_WINDOW` segments in flight, driven by a clock and by playback, never by arriving data | | Player under test | `tests/harness/mse_harness.mjs` | Runs the real `pump`/`flushQueue`/`evictBehind` against a fake SourceBuffer with a ceiling. Do not write a second model of them | diff --git a/devel-phases-next.md b/devel-phases-next.md index 338beb2..00cd3e3 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -929,7 +929,7 @@ a browser.** | # | Component | Description | |---|---|---| | 14.1 | `meshbay-node status` | ✅ DONE — hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is stopped | -| 14.1b | `meshbay-node ui` | ✅ DONE — prints the admin UI URL and the `ssh -L` line. Does not open a browser | +| 14.1b | ~~`meshbay-node ui`~~ | **Removed 2026-09-01** (`docs/refactor-node-ui.md` phase 5). The server-rendered admin page it opened is gone; the CLI and the desktop client's Node page use the loopback control API directly | | 14.1c | `meshbay-node gek-init` | ✅ DONE — initialises a group key via the daemon's loopback API. Was previously only possible by clicking a button in a browser on the node's own machine | | 14.2 | `meshbay-node group list` | ✅ **DONE 2026-08-18** — groups with roots, key state, file and peer counts | | 14.3 | `meshbay-node group create` | Create group on hub, add to config, generate GEK | diff --git a/docs/PACKAGING-GUIDE.md b/docs/PACKAGING-GUIDE.md index bb3a3bd..0f9997d 100644 --- a/docs/PACKAGING-GUIDE.md +++ b/docs/PACKAGING-GUIDE.md @@ -157,18 +157,9 @@ sudo firewall-cmd --reload sudo ufw allow "MeshBay Cast" ``` -### Node admin UI - -Opens TCP 18000. - -```bash -# Fedora (firewalld) -sudo firewall-cmd --permanent --add-service=meshbay-node -sudo firewall-cmd --reload - -# Ubuntu (ufw) -sudo ufw allow "MeshBay Node" -``` +The node's own administration surface is a loopback API (127.0.0.1 only, +per-run token) reached by the CLI and the desktop client's Node page. It is +never network-exposed and ships no firewall profile. --- diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 2d59f1f..f9ab79b 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -36,7 +36,7 @@ Les dépendances déclarées dans les `pyproject.toml` sont installées automati | `cryptography` | meshbay-common | crypto (Ed25519, ChaCha20, Argon2id) | | `PyJWT` | meshbay-common | JWT EdDSA | | `blake3`, `msgpack`, `zstandard` | meshbay-common | hashing, sérialisation, compression | -| `fastapi`, `uvicorn` | meshbay-node | HTTP API + UI locale | +| `fastapi`, `uvicorn` | meshbay-node | API de contrôle loopback | | `httpx` | meshbay-node | client hub | | `watchdog` | meshbay-node | surveillance répertoire | | `aioquic` | meshbay-node | transport QUIC (MNP v2) | @@ -107,7 +107,7 @@ Fichiers disponibles : 1 fichier(s) indexé(s) ✓ Node actif — MNP sur le port 19001 - Admin UI : meshbay-node ui (boucle locale, jeton requis) + Contrôle : meshbay-node status (API loopback, jeton requis) CTRL+C pour arrêter. ``` @@ -122,7 +122,8 @@ meshbay-node status > `/file/{id}` ont été supprimés en 0.2.0 (findings C1 et C6) : ils servaient l'index > et les fichiers en dehors du handshake qui décide de ce qu'un pair a le droit de > voir. Le port 19001 est le listener MNP, pas un serveur web. La seule surface HTTP -> est l'UI d'administration, sur la boucle locale et protégée par un jeton. +> est l'API de contrôle JSON sur la boucle locale, protégée par un jeton — utilisée +> par le CLI et la page Node du client desktop. **Ajouter vos propres fichiers :** ```bash diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md index ad36a1a..ee4d257 100644 --- a/docs/invite-pairing-v1.md +++ b/docs/invite-pairing-v1.md @@ -456,7 +456,7 @@ key material at all, and that the retired message reaches no handler. |---|---| | `member list` / `invite` / `revoke` / `unpin`, all over SSH, no browser | `daemon.main` | | Roster endpoints behind the per-run session token (11.5.3) | `ui/app.py` | -| Roster section in the local admin UI, every value escaped (H2) | `ui/app.py._render_roster` | +| Roster view in the desktop client's Node page (Preact escapes by default; H2) | `static/node-page.js` | | `_daemon_api` / `_resolve_group` — one loopback call path for every command | `daemon.py` | | Codes written to `data_dir/invite-code` and `data_dir/pair-code` | `roster.write_code_file` | @@ -494,7 +494,7 @@ longer window costs little — single use, one account, never seen by the hub, a must cover `join_request`) - `daemon.py` — `_resolve_admin_pk` → roster lookup with the legacy config fallback; new CLI commands; `status` output -- `ui/app.py` — roster and invites in the local admin UI, escaped as per 11.5.16 +- `ui/app.py` — roster and invites over the token-gated loopback API (JSON) ### Common - `protocol.py` — four message constants diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md index bee2f77..2f2d5c7 100644 --- a/docs/meshbay-draft-v6.md +++ b/docs/meshbay-draft-v6.md @@ -19,6 +19,11 @@ > browser and node, configurable on the node side (Node page, CLI, `node.toml`); ICE > interface filtering exposed on the Node page. See §1 row 15 and §2.12. > +> **Amended 2026-09-01**: §2.11/§2.12 corrected to match what was built — the Node page +> reaches the daemon over its token-gated loopback HTTP API, not MNP. The MNP `node_*` +> message types remain, unused, for a possible future browser-only path. See +> `docs/refactor-node-ui.md`. +> > The v5 convention is carried forward and is not negotiable: **a claim in this document > must name the adversary it holds against.** A property that holds against a passive hub > and not an active one is written that way. @@ -370,8 +375,8 @@ pinning the checked IP — deferred. ### 2.11 Node page — `[node]` settings exposed to the operator -New. The Node page (D5) connects over MNP and shows groups, roots and the roster; -it does not show the daemon's own tuning. Five `[node]` settings from `node.toml` +New. The Node page (D5) shows groups, roots and the roster; it does not show the +daemon's own tuning. Five `[node]` settings from `node.toml` are worth surfacing there — they affect what the node does, not only how it starts, and their current value is invisible until something goes wrong (an invitation that expired silently, a viewer told "server busy" with no knob to turn). @@ -392,10 +397,19 @@ read as today; if `roster.db` holds an override for a key, it takes precedence. line replacement (the same technique `ops.py` uses for roots), not a round-trip through a writer — comments and formatting survive. -**Wire shape.** `node_status_ack` gains a `settings` object carrying the five values above -(effective, i.e. roster override if present, config default otherwise). `node_settings_set` -writes changes and returns `node_settings_set_ack` with the updated keys. Both are -operator-only. +**Transport — the loopback API, not MNP.** The Node page talks to the daemon over its +token-gated `127.0.0.1` HTTP API (`GET`/`PUT /api/node-settings`), the same API the CLI +uses. `GET` returns the five effective values (roster override if present, config default +otherwise); `PUT` writes changes and returns the updated keys. Both require the per-run +session token, which the desktop client's main process reads from the daemon's data +directory and the renderer never sees. + +An earlier draft of this section specified an MNP path (`node_status_ack` carrying a +`settings` object, `node_settings_set` / `node_settings_set_ack`). Those message types +exist in `protocol.py` and are handled in `webrtc_server.py`, but the Node page does not +use them: the loopback API needs no signaling round-trip, no GEK and no joined group, and +works while the node is still starting. The MNP handlers are reserved for a possible +future browser-side (no desktop client) path. See `docs/refactor-node-ui.md`. ### 2.12 WebRTC transport — STUN fallbacks and ICE interface filtering @@ -423,7 +437,8 @@ other used. (`meshbay-node stun list|add|remove|reset`), and `node.toml` (`stun_servers` under `[node]`). Persistence follows the §2.11 pattern: `roster.db` for immediate effect, `node.toml` so the value survives a DB wipe, roster override wins on startup. The live -transport's STUN list is hot-swapped on save — no restart. +transport's STUN list is hot-swapped on save — no restart. The Node page reaches these +settings over the loopback API described in §2.11, not MNP. **Browser-side: hardcoded, not configurable.** The browser's `iceServers` array is set in `transport.js`, shipped by the hub. No mechanism exists for a node to push STUN diff --git a/docs/refactor-node-ui.md b/docs/refactor-node-ui.md new file mode 100644 index 0000000..7bab5d2 --- /dev/null +++ b/docs/refactor-node-ui.md @@ -0,0 +1,403 @@ +# Refactor: fold the node's local admin surface into the desktop Node page + +> Status: **complete** (2026-09-01) — all six phases landed; see §11 for what +> each did. This stays as the decision record. +> Scope: the node's *own* administration surface. Group-level settings +> (member-upload, apps, scan-settings) are not touched — they live in the group +> UI and stay there. +> +> **Net result:** `ui/app.py` is a JSON-only, ruff-clean loopback control API +> (1074 → 453 lines); the server-rendered dashboard, the `/audit` HTML page, +> the `meshbay-node ui` verb and three never-wired endpoints are gone; the +> desktop client's Node page (`static/node-page.js`) gained an Overview tab, +> node-wide Roster, Peers, a paginated Audit log with CSV export, index-cache +> Maintenance, and an Unlink action, organised into six tabs; the pointless +> `18000/tcp` firewall profiles were deleted. One capability is intentionally +> gone: browser-based admin on a headless server — the CLI covers every +> operation there. + +--- + +## 1. The distinction that matters + +"The local admin UI" is three things bundled in `packages/meshbay-node/src/meshbay_node/ui/`: + +| Component | Consumers today | Redundant with `node-page.js`? | +|---|---|---| +| **Loopback JSON API** `/api/*` (~35 routes), `127.0.0.1:{ui_port}`, per-run token | the **entire CLI** (`_daemon_api` — every verb is an HTTP call), `node-page.js` (via `platform.node.call`), `platform.node.detect()`, the create-group wizard's index-progress poll | **No.** This is the node's control plane. | +| **Server-rendered HTML** — `GET /` (dashboard), `GET /audit` (`_DASHBOARD` / `_AUDIT_HTML` templates + `_render_*` helpers) | a human with a browser (`meshbay-node ui` prints a tokened URL; `ssh -L` from a remote host) | **Yes, entirely.** | +| uvicorn server + middleware (token gate, CSP headers) | — | Stays — the API needs it. | + +**This refactor removes the HTML layer only.** Removing the module would break +the CLI, node detection, and the Node page itself. + +--- + +## 2. Decision + +1. Enrich `packages/meshbay-hub/src/meshbay_hub/static/node-page.js` so it covers + everything the HTML dashboard showed (peers, audit, index-cache maintenance, + node-wide roster, node identity/QUIC/hub display). +2. Add a **Unlink node** action (endpoint already exists, nothing surfaces it). +3. Fix the audit view to show real usernames. +4. Organise `node-page.js` into tabs. +5. Delete `GET /` and `GET /audit` from `ui/app.py`, plus the templates, the + `render` helpers, the `meshbay-node ui` CLI verb, and every doc/packaging + reference to a browser admin UI. +6. Keep the whole `/api/*` surface and the uvicorn server. + +**Accepted cost:** on a headless server (the meshbay.org model) the only admin +path becomes the CLI. The CLI already covers every operation, so this is +acceptable — but it is a real capability reduction and is recorded here as a +conscious choice, not an oversight. + +--- + +## 3. Inventory — nothing is lost + +### 3a. HTML dashboard content → destination + +| Dashboard element | Backing endpoint | Destination | +|---|---|---| +| Node status badge, `needs` hints | `GET /api/status` | Node page header (already partly there) | +| Stats: groups / files / peers / user | `GET /api/status` | Overview tab | +| **Connected Peers** table | `GET /api/peers` | **Peers tab (new wiring)** | +| Roster (node-wide) | `GET /api/roster?group_id=` | **Roster tab — extend the current per-group view to node-wide** | +| Groups list | `GET /api/groups` | Groups tab (already there) | +| Node Configuration: Hub URL, QUIC port, Node ID | `GET /api/status` (`endpoint_hint`, `quic_port`, `hub_url`) | Overview tab (new fields) | +| Node Configuration: 5 `[node]` settings | `GET /api/node-settings` | Settings tab (already there) | +| **Maintenance**: index-cache count + Prune | `GET /api/index-cache`, `POST /api/index-cache/prune` | **Settings/Maintenance tab (new wiring)** | +| `/audit` page | `GET /api/audit` | **Audit tab (new wiring)** | + +### 3b. `/api/*` endpoints — consumer and disposition + +| Endpoint | Consumed by | Disposition | +|---|---|---| +| `GET /api/status` | Electron `node:detect`, CLI `status`, node-page | keep | +| `DELETE /api/unlink` | *nothing* | keep + **surface in node-page** | +| `GET /api/groups` | CLI, node-page | keep | +| `POST /api/groups/attach` · `POST /api/groups/detach` | CLI, node-page (detach) | keep | +| `DELETE /api/groups/{g}/files/{f}` | CLI `file rm`, group Files UI | keep | +| `GET /api/groups/{g}/files` | CLI `file list`, HTML dashboard | keep (drop dashboard caller) | +| `GET /api/denylist` · `POST /api/denylist/clear` | CLI, node-page | keep | +| `GET /api/index-cache` · `POST /api/index-cache/prune` | **HTML dashboard only** | keep + **wire into node-page** | +| `POST /api/groups/{g}/video/rematch` | CLI, group Videos UI | keep | +| `GET /api/peers` | **HTML dashboard only** | keep + **wire into node-page** | +| `GET /api/audit` | **HTML `/audit` only** | keep + **wire into node-page** + username fix (§5) | +| `GET /api/config` | *nothing* | **triage — likely dead (§6b)** | +| `POST /api/operator/pair` | CLI, node-page | keep | +| `GET /api/roster` | CLI, node-page | keep (node-page uses node-wide mode too) | +| `POST /api/groups/{g}/invites` · `GET /api/resolve` | CLI | keep | +| `POST /api/members/{u}/revoke` | CLI | keep + optionally surface in node-page | +| `POST /api/members/{u}/unpin` | CLI, node-page | keep | +| `POST /api/groups/{g}/gek` | CLI `gek init/rotate`, node-page (rotate) | keep | +| `POST /api/groups/{g}/roots` · `DELETE .../roots/{n}` | node-page | keep | +| `GET /api/groups/{g}/index-status` | create-group wizard | keep | +| `PUT /api/groups/{g}/member-upload` · `.../apps` · `.../scan-settings` | group Settings UI | keep (out of scope) | +| `POST /api/reload` | CLI, node-page | keep | +| `GET /api/node-settings` · `PUT /api/node-settings` | CLI `stun`, node-page | keep | +| `GET /api/chat/history` · `WS /ws/chat` | *nothing* | **triage — likely dead (§6b)** | +| `GET /` · `GET /audit` | browser only | **DELETE** | + +### 3c. CLI verbs + +`init reset status ui gek-init gek operator member group file video denylist stun +reload restart-daemon calibrate-argon2`. + +Only **`ui`** goes away. `status` output drops its `admin UI meshbay-node ui` +line. Nothing else changes — the CLI keeps talking to the same `/api/*`. + +--- + +## 4. Gap fills in `node-page.js` + +New sections/tabs, each a thin `nodeCall`: + +- **Peers** — `GET /api/peers` → table (user, IP, group, state). Poll ~5 s while visible. +- **Audit** — `GET /api/audit` with the event/user/limit filters the HTML page had. + Render every field as **text** (htm/preact escapes by default — never + `dangerouslySetInnerHTML` on `detail`/`username`; `detail` carries + member-chosen filenames, H2). +- **Maintenance** — `GET /api/index-cache` (count) + `POST /api/index-cache/prune`. +- **Roster (node-wide)** — call `GET /api/roster` with no `group_id`. Show every + identity and its devices; keep the existing `unpin`, add `revoke_member` per + member. (`revoke_device` for a single device is a later item — the endpoint + does not exist yet.) +- **Overview** — Node ID (`endpoint_hint`), QUIC port, hub URL, version, from + `GET /api/status`. + +Tabs: **Overview · Groups · Roster · Peers · Audit · Settings**. Pure UI +refactor of the current 749-line single scroll; no protocol change. Split into +per-tab files if it grows (same move as `docs/apps.md` did for the group UI) — +optional. + +--- + +## 5. Audit usernames + +**Symptom:** rows show `8f7e206e` (that is `user_id[:8]`, the fallback in the +HTML template's `e.username || e.user_id.slice(0,8)`). + +**Cause:** the `username` column exists and `_audit()` already passes +`username=self._username`, but it is blank for (a) legacy rows written before the +column was populated and (b) events where the handshake payload carried no +username (pre-handshake `auth_failed`, some token shapes). + +**Fix — resolve at read time, no migration:** in the audit endpoint (or a new +`ops.get_audit`), build `{user_id: username}` from the roster +(`roster.list_members()` + identities already expose `username` node-wide, same +source `_render_roster` uses) and fill blanks: +`row.username or roster_name.get(row.user_id) or row.user_id[:8]`. + +**Security:** this is **not** the stored-XSS concern. That concern is rendering +attacker-influenced strings (`username` from the hub, `detail` filenames) as +**markup**. Showing the real name is fine *as text*. The rule for the Preact +Audit tab: interpolate, never inject HTML. + +--- + +## 6. Cleanup + +### 6a. Dead code / config once the HTML is gone + +- `ui/app.py`: `@app.get("/")`, `@app.get("/audit")`, `_DASHBOARD_*`/`_AUDIT_HTML` + templates, `_render_dashboard`, `_render_node_settings`, `_render_roster` (HTML + variant), `_render_audit_page`, and any `escape`-only helpers left unused. +- `daemon.py`: the `ui` CLI verb + its `argparse` choice + help text + `quiet` + list entry; the `_ui_url`/`ui` handler block; `log.info("Admin UI ready — open + it with: meshbay-node ui")`; the `admin UI meshbay-node ui` line in + `_print_status`. +- `packaging/firewall/ufw/meshbay` and + `packaging/firewall/firewalld/meshbay-node.xml`: drop the `18000/tcp` service. + The UI binds `127.0.0.1` only (`daemon.py` `host="127.0.0.1"`), so these rules + never did anything useful and become a footgun if the bind address ever + changes. Remove regardless of the rest of this refactor. +- Docs/man: `docs/QUICKSTART.md:110`, `QE/deploy/README.md:99`, + `man/meshbay-node.1` (the `meshbay-node ui` entry), `packaging/deb/.../control` + and `packaging/rpm/meshbay-node.spec` ("a local admin UI on localhost:18000"). + +**Keep:** the uvicorn server, the token middleware, the CSP middleware (harmless +and good hygiene on JSON responses), the `ui-token` file, `ui_port` in +`node.toml`. Renaming `ui_*` → `localapi_*` is churn for no user value — deferred, +noted only so the naming mismatch is a known thing. + +### 6b. Orphan endpoints — ✅ removed (phase 6) + +`GET /api/config`, `GET /api/chat/history`, `WS /ws/chat` (plus +`broadcast_chat_to_ui` / `app.broadcast_chat` / `_chat_subscribers`) were a +never-wired local operator chat view plus a superseded config dump. Confirmed +dead against git history and removed. See phase 6 above. + +--- + +## 7. Security review + +The loopback API's model is unchanged and adequate: bind `127.0.0.1`; per-run +token (`os.urandom(18)`, `ui-token` mode 0600, required as `?t=` or +`X-MeshBay-Token`); CSP `default-src 'none'; connect-src 'self'`. Threats +modelled: DNS-rebinding from the operator's browser, and any local process. The +token closes both. + +- **Folding functions into `node-page.js` adds no surface** — every call goes + through the same token-gated API; the Electron main process holds the token, + the renderer never sees it (same model as `hub:fetch`). +- **Do not shell out to the CLI from the Node page.** The CLI is itself an HTTP + client of this API; spawning it would be a strictly worse `nodeCall` (process + spawn, arg-injection, PATH assumptions, unstructured errors). If a function is + missing from the API, add the endpoint. `main.js` shelling out to + `systemctl --user` is a legitimate exception — OS service control has no API + equivalent; node *operations* stay on the API. +- **Audit tab:** render as text (§5). +- **Unlink copy:** unlink clears only the hub's stored linking key + (`DELETE /v1/users/me/node_key` → `user.pk_node_ed25519 = None`). It does **not** + touch the roster, GEKs, pins, or on-disk group data — the node keeps serving + anyone who reaches it directly over MNP. The UI must say "the hub stops + brokering connections to this node", not imply a wipe. `reset` stays the + destroy-everything path. After unlink, `POST /v1/nodes/auth` fails and the hub + WebSocket drops — expected; show it as "offline from hub", not an error. + +--- + +## 8. Node-key linking — confirmed automatic (native) + +`create-group-page.js` `linkNodeKey(pk)` is called from `detectNode()` after +`platform.node.detect()` returns the node's `pk_node_ed25519`; it +`PUT /v1/users/me/node_key`. So in the desktop wizard the key links +automatically on node detection. The manual paste box in `profile-page.js` is the +fallback for the browser-only case (no bridge to read the node's key) and for +re-linking. No change needed; the **unlink** counterpart is the only missing half +(§2.2, §7). + +--- + +## 9. Draft correction + +`docs/meshbay-draft-v6.md` §2.11 and §2.12 describe the Node page as connecting +**over MNP** (`node_status_ack`, `node_settings_set`). The code went +loopback-HTTP instead (`node-page.js` header comment says so explicitly), which is +better for a native client — no signaling round-trip, no GEK/session, works +before any group is joined. The MNP handlers +(`webrtc_server.py:_do_node_status`, `_do_node_settings_set`) are dormant. + +Update §2.11/§2.12 to state: the Node page uses the node's loopback API; the MNP +`node_*` message types are reserved for a possible future browser-side (no +Electron) path and are not the mechanism today. + +--- + +## 10. Tests + +- `test_cli_dispatch.py` — drop the `["ui", ...]` row; assert `ui` is no longer a + parser choice. +- New `ops`/endpoint test for audit username backfill from the roster. +- Node-page: extend whatever source-reading test pins its structure (per the SPA + testing posture) to cover the new tabs' `nodeCall` paths. +- `test_packaging_units.py` / packaging tests — assert the firewall service files + no longer open 18000. +- Grep guard (or manual): no `meshbay-node ui` string left in `man/`, `docs/`, + `packaging/`. + +--- + +## 11. Phases + +1. **Draft + firewall** — ✅ done 2026-09-01. Corrected draft-v6 §2.11/§2.12 + (loopback API, not MNP) with a 2026-09-01 amendment line. Deleted + `packaging/firewall/firewalld/meshbay-node.xml`; stripped the `[MeshBay Node]` + stanza from `packaging/firewall/ufw/meshbay` (the `[MeshBay Cast]` profile + stays). Follow-on edits so the build still passes: + `packaging/build/build-node.sh` (drop the firewalld copy), + `packaging/rpm/meshbay-node.spec` `%files` (drop the `.xml` line), + `docs/PACKAGING-GUIDE.md` (drop the "Node admin UI / TCP 18000" section). No + behaviour change. The deb/spec "local admin UI on localhost:18000" description + lines are left for phase 5. +2. **node-page.js gap fills** — ✅ done 2026-09-01. Added five sections to the + existing single-scroll page (tabs come in phase 4): **Overview** (version, + node id, QUIC port, hub — from `GET /api/status`, fetched in + `fetchStatus`/`refresh`), **Node roster** (`GET /api/roster` with no + `group_id`, node-wide, with `unpin`), **Connected peers** (`GET /api/peers`), + **Audit log** (`GET /api/audit` with event + limit filters, every field + rendered as text), **Maintenance** (`GET /api/index-cache` + + `POST /api/index-cache/prune`). Backend: `GET /api/audit` now backfills a + blank `username` from `roster.list_identities()` at read time (no migration). + ~31 `node.*` i18n keys added to all ten locale catalogues (English + placeholders, matching the existing node-page convention). `test_locales.py` + green; `node --check` green; node ops/audit/status/roster tests green. + Per-member `revoke` in the node-wide view is deferred — revoke is + group-scoped and the node-wide list has no group context. +3. **Unlink** — ✅ done 2026-09-01. Added a danger-styled "Unlink from hub" + section at the bottom of `node-page.js` (`unlinkNode` → `DELETE /api/unlink`, + confirm + consequence copy per §7, hidden while `status === + 'waiting_for_node_key'`). Backend endpoint was already present and unchanged. + 5 `node.unlink_*` i18n keys added to all ten locales; `.node-group-danger` + added to `style.css`. `node --check` + `test_locales.py` green. Note: the + endpoint 503s when the node has no hub session — the catch surfaces the + bridge message, which is acceptable for that edge case. +4. **Tabs** — ✅ done 2026-09-01. `node-page.js` now has a `tab` state and an + `.admin-tabs` bar (reusing the existing admin-page tab styles) with six tabs: + **Overview** (node info + Maintenance), **Groups** (per-group cards), + **Roster** (node-wide roster), **Peers**, **Audit**, **Settings** (denylist + + node settings + STUN + ICE + Unlink). Header, service panel, action message + and the operator-pair banner stay above the bar, visible on every tab. Each + section is gated `${tab === '<x>' && …}`; the groups IIFE is + `${tab === 'groups' && (() => {…})()}`. 6 `node.tab_*` i18n keys added to all + ten locales. Verified with a per-tab render probe (htm parses and the + component executes cleanly for all six tabs) plus `node --check` and + `test_locales.py`. +5. **Remove HTML** — ✅ done 2026-09-01. + - `ui/app.py`: deleted `GET /`, `GET /audit`, and everything below the + `# ── HTML UI ──` marker (`_render_page` + inline dashboard template, + `_render_roster`, `_render_node_settings`, `_render_audit_page`, + `_AUDIT_HTML`, `_fmt_size`). ~500 lines gone. Dropped now-unused imports + (`base64`, `time`, `html.escape`, `HTMLResponse`). Module docstring rewritten + — it is a JSON control API, not a web UI. The uvicorn server, the token + middleware and the CSP middleware stay. + - `daemon.py`: removed the `ui` argparse choice + its help text + `quiet` + entry + the `if args.command == "ui":` handler. Startup log line is now + `Control API on 127.0.0.1:<port>`; `_print_status` drops the `admin UI` + line; the "waiting_for_node_key" hint now points at `meshbay-node status` + and the desktop client. `_daemon_api` docstring reworded. (Net effect on + ruff: one F541 fewer; the file's pre-existing lint debt is untouched.) + - Tests: `test_cli_dispatch.py` drops the `["ui"]` case and asserts `ui` is + no longer a parser choice. The two `_render_page` H2 tests + (`test_admin_ui_escapes_filenames`, `_roster_usernames`) are replaced by + `test_node_control_api_serves_no_html`, which pins the routes at 404 and + the render helpers as absent. Full node suite: 749 passed, same 2 + pre-existing failures (`test_packaging_units` bad split logic; + `test_webrtc_transport` aiortc flake). + - Docs/packaging: `docs/QUICKSTART.md`, `man/meshbay-node.1` (the `ui` verb + entry deleted, "admin UI" → "control API" throughout), + `packaging/deb/.../control`, `packaging/rpm/meshbay-node.spec`, + `devel-phases-next.md` (14.1b struck through), `docs/invite-pairing-v1.md` + (`_render_roster` pointer → `node-page.js`), `CLAUDE.md` key-modules row, + `config.py` example-config comment. `QE/deploy/README.md` also touched but + it is gitignored. +6. **Orphan-endpoint triage** — ✅ done 2026-09-01. All three confirmed dead and + removed from `ui/app.py`: + - `GET /api/config` — no consumer (the CLI, SPA and Electron never call it); + its payload is a strict subset of `/api/status` + `/api/groups` + + `/api/node-settings`. `test_security_regressions.py` used it only as a + sample path for the token-gate check → switched to `/api/groups`. + - `GET /api/chat/history` — reads `state["chat_store"]`, a **global** store + that the daemon deliberately never sets (per-group stores since finding + H1), so it always returned `{"messages": []}`; the message shape was stale + too. The real chat-history path is the MNP `chat_history` handler in + `webrtc_server.py` (unchanged, `test_transport_contracts.py` still green). + - `WS /ws/chat` + `_chat_subscribers` + `broadcast_chat_to_ui` + + `app.broadcast_chat` — added in the Phase-7 "Node v2" commit and never + wired: `git grep broadcast_chat` finds only the definition and the + assignment, nothing invokes it, so the socket's subscribers received + nothing. + - Fallout: dropped now-unused imports (`json`, `WebSocket`, + `WebSocketDisconnect`) and ran `ruff --fix` for the file's remaining + pre-existing import debt (`Path`, `DEFAULT_CONFIG_PATH`, `generate_gek`, + `wrap_gek_aes`, `ROLE_MEMBER`, `ROLE_OPERATOR`, the function-local + `Roster`). **`ui/app.py` now passes `ruff check` with no errors** (1074 → + 453 lines across phases 5–6). + - Tightened the `_security_headers` CSP to `default-src 'none'; frame-ancestors + 'none'; base-uri 'none'` — the JSON-only API has no HTML, inline script or + stylesheet to allow, so the old `'unsafe-inline'` / `connect-src 'self'` + grants were dead weight. No test asserted the string. Middleware docstrings + refreshed ("admin UI" → "control API"). + +4b. **Node page UI polish** — ✅ done 2026-09-01 (operator feedback on phase 4): + - Overview/Maintenance, Roster, Peers and Audit tabs **auto-load on open** — + the per-tab "Load" buttons are gone (`useEffect` on `[tab, status]`, audit + also on filter/page). Per-group roster and denylist keep their buttons. + - **Names, not ids**: `ui/app.py` gained `_display_names(state)` → + `(user_id→username from roster, group_id→name from node.toml)`. `/api/peers` + resolves both (`username`, new `group_name`); `/api/audit` adds + `group_name` and keeps the username backfill. The SPA renders the name and + falls back to a shortened id. + - **Audit pagination**: `get_entries` gained `offset`; `/api/audit` takes + `offset`, clamps `limit` to 1000, and fetches `limit+1` to report + `has_more` without a count. The tab has Previous/Next + "Page N", newest + first, page size 50/100/200/500. `test_audit.py::test_pagination_newest_first`. + - **Audit overflow**: table wrapped in `.node-table-scroll` (`overflow-x:auto`); + `.node-table-audit` keeps time/event/user/ip/group on one line (IP never + clipped) and wraps only the detail column. + - **CSV export**: `Export CSV` button → `exportAuditCsv` walks the whole log + for the current filter in 1000-row pages (de-duped by id so an event + written mid-export cannot duplicate a row; 1M-row hard stop) and + `saveCsv()` writes every matching entry — native Save As on the desktop + (`platform.nativeSave`), blob download link otherwise. RFC-4180 quoting. + - Settings tab section order: node settings → STUN → ICE → **denylist** → + **Unlink** (denylist sits directly above the danger zone). + - 6 new i18n keys (`node.loading`, `node.audit_export/_exporting/_prev/_next/_page`), + 4 dead `*_load` keys removed, across all ten locales. Per-tab render probe + + `node --check` + `test_locales.py` + node test subset (193) green. + +Phases 1–4 lose nothing and can land independently. Phase 5 is the one that +removes a capability (browser admin on a headless box) and should be its own +reviewed commit. + +--- + +## 12. Out of scope + +- Group-level settings UI (member-upload, apps, scan-settings) — stays in the + group UI. +- Renaming `ui_port` / `ui-token` / `create_ui_app`. +- `revoke_device` (single-device revocation) — needs a new endpoint; track + separately. +- Any MNP browser-side node admin path. diff --git a/man/meshbay-node.1 b/man/meshbay-node.1 index 2ce1b58..1e1173b 100644 --- a/man/meshbay-node.1 +++ b/man/meshbay-node.1 @@ -15,14 +15,14 @@ meshbay\-node \- MeshBay Node daemon, local file host, streaming server, and gro .B meshbay\-node is the local daemon of the MeshBay decentralized platform. It hosts shared directories, indexes files, serves video streams to peers, -manages group membership, and provides a local admin web UI. +manages group membership, and provides a local control API. .PP When invoked without a command, the daemon starts in the foreground. It loads its configuration from .IR ~/.config/meshbay/node.toml , unlocks (or creates) its Ed25519 keystore, connects to the hub, starts the directory indexer, the QUIC chunk server, -the WebRTC transport, and the local admin UI. +the WebRTC transport, and the local control API. .PP The daemon runs until interrupted by .B SIGINT @@ -44,12 +44,6 @@ configured groups and their roots, operator pairing status, and pending invitations. Works whether the daemon is running or stopped. . .TP -.B ui -Print the URL of the local admin web UI, including the session token. -The UI listens on loopback only; use SSH port forwarding to access it -from another machine. -. -.TP .B calibrate\-argon2 Benchmark Argon2id with several memory cost values and suggest the one closest to a 500\ ms target on the current hardware. @@ -261,7 +255,7 @@ Override: . .TP .B ui_port -Local admin web UI port (bound to 127.0.0.1 only). Default: +Local control API port (loopback only, token\-gated). Default: .BR 18000 . . .TP @@ -478,13 +472,14 @@ Encrypted Ed25519 + X25519 keystore, protected by Argon2id. .TP .I ~/.local/share/meshbay/ Data directory: chat databases, index caches, bundle store, roster, -denylist, and the UI session token. +denylist, and the control API session token. . .TP .I ~/.local/share/meshbay/ui\-token -Per\-run token for the local admin UI. Written by the daemon at startup -and read by -.BR "meshbay\-node ui" . +Per\-run token for the local control API. Written by the daemon at startup +and read by the +.B meshbay\-node +CLI and the desktop client's Node page. . .SH SYSTEMD INTEGRATION A user service unit is provided at @@ -516,10 +511,9 @@ and supports . .SH SECURITY .IP \(bu 2 -The admin UI binds to +The control API binds to .B 127.0.0.1 -only and is protected by a per\-run token. Use SSH port forwarding for -remote access. +only and is protected by a per\-run token. .IP \(bu 2 Operator authority is established locally via .BR "meshbay\-node operator pair" , diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 3253372..6c03726 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -847,4 +847,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index aae9031..88dca15 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -826,4 +826,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 4f80fe9..cf0d6d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -843,4 +843,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 9b9c5b2..138c2cc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -858,4 +858,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 785c510..21a7fca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -857,4 +857,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 22792ce..e74750f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -841,4 +841,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 01663c3..9f15053 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -859,4 +859,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 2bc29ee..069d642 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -885,4 +885,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 2f2aa43..a1a1352 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -844,4 +844,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 141cb6f..b0c88ff 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -829,4 +829,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 82fc56e..8df4c9d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -89,6 +89,30 @@ async function nodeCall(method, path, body) { return platform.node.call(method, path, body); } +// Hand a generated file to the user: native Save As on the desktop, a blob +// download link everywhere else. Small text only (CSV export) — not the +// large-file download path in file-utils.js. +async function saveCsv(filename, text) { + const bytes = new TextEncoder().encode(text); + try { + const target = await platform.nativeSave(filename, { auto: false }); + if (target && target.writable) { + if (target.open) await target.open(); + await target.writable.write(bytes); + await target.writable.close(); + return; + } + } catch { /* fall through to the blob path */ } + const url = URL.createObjectURL(new Blob([bytes], { type: 'text/csv' })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + export function NodePage({ groups }) { const [status, setStatus] = useState('idle'); const [error, setError] = useState(''); @@ -111,6 +135,18 @@ export function NodePage({ groups }) { const [operatorPaired, setOperatorPaired] = useState(false); const [pairBusy, setPairBusy] = useState(false); const [pairStatus, setPairStatus] = useState(''); + const [nodeInfo, setNodeInfo] = useState(null); + const [peers, setPeers] = useState(null); + const [audit, setAudit] = useState(null); + const [auditEvent, setAuditEvent] = useState(''); + const [auditPageSize, setAuditPageSize] = useState(100); + const [auditPage, setAuditPage] = useState(0); + const [auditHasMore, setAuditHasMore] = useState(false); + const [auditExporting, setAuditExporting] = useState(false); + const [cacheCount, setCacheCount] = useState(null); + const [nodeRoster, setNodeRoster] = useState(null); + const [unlinkBusy, setUnlinkBusy] = useState(false); + const [tab, setTab] = useState('overview'); const fetchStatus = useCallback(async () => { setStatus('connecting'); @@ -126,6 +162,7 @@ export function NodePage({ groups }) { setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); setNodeSettings(result.settings || null); + try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} setStatus('connected'); } catch (err) { setError(platform.bridgeMessage(err)); @@ -147,6 +184,7 @@ export function NodePage({ groups }) { setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); setNodeSettings(result.settings || null); + try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} } catch {} }, []); @@ -408,6 +446,163 @@ export function NodePage({ groups }) { } }, [refresh]); + const loadPeers = useCallback(async () => { + setBusy(true); + try { + const r = await nodeCall('GET', '/api/peers'); + setPeers(r.peers || []); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const loadAudit = useCallback(async () => { + setBusy(true); + try { + const offset = auditPage * auditPageSize; + let path = `/api/audit?limit=${auditPageSize}&offset=${offset}`; + if (auditEvent) path += `&event=${encodeURIComponent(auditEvent)}`; + const r = await nodeCall('GET', path); + setAudit(r.entries || []); + setAuditHasMore(!!r.has_more); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [auditEvent, auditPage, auditPageSize]); + + const exportAuditCsv = useCallback(async () => { + setAuditExporting(true); + setActionMsg(''); + try { + // Every entry that matches the current event filter, not just one page. + // Walk the log newest-first in blocks; de-dupe by id so an event written + // mid-export (which shifts rows to a higher offset) cannot duplicate one. + const PAGE = 1000; + const MAX_PAGES = 1000; // 1M-row stop, so a bug cannot spin forever + const evq = auditEvent ? `&event=${encodeURIComponent(auditEvent)}` : ''; + const seen = new Set(); + const rows = []; + for (let page = 0; page < MAX_PAGES; page++) { + const r = await nodeCall( + 'GET', `/api/audit?limit=${PAGE}&offset=${page * PAGE}${evq}`); + const batch = r.entries || []; + for (const e of batch) { + if (!seen.has(e.id)) { seen.add(e.id); rows.push(e); } + } + if (!r.has_more || batch.length === 0) break; + } + + const cols = ['timestamp', 'event', 'user', 'user_id', 'ip', + 'group', 'group_id', 'detail']; + const esc = (v) => { + const s = v == null ? '' : String(v); + return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; + }; + const lines = [cols.join(',')]; + for (const e of rows) { + lines.push([ + new Date(e.timestamp * 1000).toISOString(), + e.event, e.username || '', e.user_id || '', e.ip || '', + e.group_name || '', e.group_id || '', e.detail || '', + ].map(esc).join(',')); + } + const csv = lines.join('\r\n') + '\r\n'; + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + await saveCsv(`node-audit-${stamp}.csv`, csv); + setActionMsg(t('node.audit_exported', { n: rows.length })); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setAuditExporting(false); + } + }, [auditEvent]); + + const loadCache = useCallback(async () => { + setBusy(true); + try { + const r = await nodeCall('GET', '/api/index-cache'); + setCacheCount(r.count ?? 0); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const pruneCache = useCallback(async () => { + setBusy(true); + setActionMsg(''); + try { + const r = await nodeCall('POST', '/api/index-cache/prune'); + setCacheCount(r.kept ?? null); + setActionMsg(t('node.maintenance_pruned', { n: r.removed ?? 0 })); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const loadNodeRoster = useCallback(async () => { + setBusy(true); + try { + const r = await nodeCall('GET', '/api/roster'); + setNodeRoster(r); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const unpinNodeMember = useCallback(async (userId) => { + if (!confirm(t('node.unpin_confirm', { name: userId }))) return; + setBusy(true); + setActionMsg(''); + try { + await nodeCall('POST', `/api/members/${userId}/unpin`); + setActionMsg(t('node.unpin_done')); + await loadNodeRoster(); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [loadNodeRoster]); + + const unlinkNode = useCallback(async () => { + if (!confirm(t('node.unlink_confirm'))) return; + setUnlinkBusy(true); + setActionMsg(''); + try { + await nodeCall('DELETE', '/api/unlink'); + setActionMsg(t('node.unlink_done')); + await refresh(); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setUnlinkBusy(false); + } + }, [refresh]); + + // Each read-only tab loads itself on open — no Load button. + useEffect(() => { + if (status !== 'connected') return; + if (tab === 'overview') loadCache(); + else if (tab === 'roster') loadNodeRoster(); + else if (tab === 'peers') loadPeers(); + }, [tab, status, loadCache, loadNodeRoster, loadPeers]); + + // Audit reloads on tab open and whenever the filter or page changes + // (loadAudit's identity tracks auditEvent / auditPage / auditPageSize). + useEffect(() => { + if (status === 'connected' && tab === 'audit') loadAudit(); + }, [tab, status, loadAudit]); + if (status === 'idle' || status === 'connecting') { return html`<div class="page-content"> <h2>${t('node.title')}</h2> @@ -447,7 +642,29 @@ export function NodePage({ groups }) { : pairStatus ? html`<p class="error-msg">${pairStatus}</p>` : null} </div> `} - ${(() => { + <div class="admin-tabs"> + ${['overview', 'groups', 'roster', 'peers', 'audit', 'settings'].map(k => html` + <button key=${k} class="admin-tab ${tab === k ? 'active' : ''}" + onClick=${() => setTab(k)}>${t('node.tab_' + k)}</button> + `)} + </div> + + ${tab === 'overview' && nodeInfo && html` + <div class="node-group"> + <span class="settings-heading">${t('node.overview')}</span> + <table class="node-table"> + <tbody> + <tr><td>${t('node.overview_version')}</td><td>${nodeInfo.version || '—'}</td></tr> + <tr><td>${t('node.overview_node_id')}</td> + <td><code>${nodeInfo.endpoint_hint || nodeInfo.pk_node_ed25519 || '—'}</code></td></tr> + <tr><td>${t('node.overview_quic_port')}</td><td>${nodeInfo.quic_port || '—'}</td></tr> + <tr><td>${t('node.overview_hub')}</td><td>${nodeInfo.hub_url || '—'}</td></tr> + </tbody> + </table> + </div> + `} + + ${tab === 'groups' && (() => { const hubIds = new Set((groups || []).map(g => g.id)); return nodeGroups.map(g => { const stale = !hubIds.has(g.id); @@ -561,53 +778,169 @@ export function NodePage({ groups }) { }); })()} + ${tab === 'roster' && html` + <div class="node-group"> + <span class="settings-heading">${t('node.node_roster')}</span> + <p class="node-hint">${t('node.node_roster_hint')}</p> + ${!nodeRoster ? html` + <p class="node-hint">${t('node.loading')}</p> + ` : (nodeRoster.members || []).length === 0 ? html` + <p class="node-hint">${t('node.roster_empty')}</p> + ` : html` + <div class="node-table-scroll"> + <table class="node-table"> + <thead><tr> + <th>${t('node.roster_user')}</th> + <th>${t('node.roster_role')}</th> + <th>${t('node.roster_status')}</th> + <th>${t('node.roster_scope')}</th> + <th>${t('node.roster_via')}</th> + <th></th> + </tr></thead> + <tbody> + ${(nodeRoster.members || []).map(m => html` + <tr key=${m.user_id + ':' + (m.group_id || '')}> + <td>${m.username || m.user_id.slice(0, 12)}</td> + <td>${m.role}</td> + <td>${m.status}</td> + <td>${m.group_id ? m.group_id.slice(0, 8) : t('node.roster_scope_node')}</td> + <td>${m.pinned_via || ''}</td> + <td>${m.pk_ed25519 && html` + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => unpinNodeMember(m.user_id)}> + ${t('node.unpin')}</button> + `}</td> + </tr> + `)} + </tbody> + </table> + </div> + `} + </div> + `} + + ${tab === 'peers' && html` + <div class="node-group"> + <span class="settings-heading">${t('node.peers_title')}</span> + ${!peers ? html` + <p class="node-hint">${t('node.loading')}</p> + ` : peers.length === 0 ? html` + <p class="node-hint">${t('node.peers_empty')}</p> + ` : html` + <div class="node-table-scroll"> + <table class="node-table"> + <thead><tr> + <th>${t('node.peers_user')}</th> + <th>${t('node.peers_ip')}</th> + <th>${t('node.peers_group')}</th> + <th>${t('node.peers_state')}</th> + </tr></thead> + <tbody> + ${peers.map(p => html` + <tr key=${p.peer_id}> + <td>${p.username || (p.user_id || '').slice(0, 12) || '—'}</td> + <td>${p.remote_ip || '—'}</td> + <td>${p.group_name || (p.group_id ? p.group_id.slice(0, 8) : '—')}</td> + <td>${p.state || '—'}</td> + </tr> + `)} + </tbody> + </table> + </div> + `} + </div> + `} + + ${tab === 'audit' && html` <div class="node-group"> <div class="node-section-header"> - <span class="settings-heading">${t('node.denylist')}</span> - <button class="btn btn-small btn-secondary" disabled=${busy} - onClick=${loadDenylist}> - ${t('node.denylist_load')}</button> + <span class="settings-heading">${t('node.audit_title')}</span> + <button class="btn btn-small btn-secondary" + disabled=${auditExporting || busy} + onClick=${exportAuditCsv}> + ${auditExporting ? t('node.audit_exporting') : t('node.audit_export')}</button> </div> - ${showDenylist && denylist && html` - <div class="node-denylist"> - ${denylist.count === 0 ? html` - <p class="node-hint">${t('node.denylist_empty')}</p> - ` : html` - <p class="node-hint">${t('node.denylist_count', - { n: denylist.count })}</p> - ${(denylist.users || []).map(u => html` - <div class="node-deny-entry" key=${'u:' + u}> - <span>user: ${u}</span> - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => clearDenylist(u)}> - ${t('node.denylist_remove')}</button> - </div> - `)} - ${(denylist.groups || []).map(g => html` - <div class="node-deny-entry" key=${'g:' + g}> - <span>group: ${g}</span> - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => clearDenylist(g)}> - ${t('node.denylist_remove')}</button> - </div> - `)} - ${(denylist.jtis || []).map(j => html` - <div class="node-deny-entry" key=${'j:' + j}> - <span>token: ${j.slice(0, 16)}</span> - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => clearDenylist(j)}> - ${t('node.denylist_remove')}</button> - </div> - `)} - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => clearDenylist('')}> - ${t('node.denylist_clear_all')}</button> - `} + <div class="stun-add-row"> + <select value=${auditEvent} + onChange=${e => { setAuditEvent(e.target.value); setAuditPage(0); }}> + <option value="">${t('node.audit_all_events')}</option> + <option value="handshake">handshake</option> + <option value="file_download">file_download</option> + <option value="file_upload">file_upload</option> + <option value="file_delete">file_delete</option> + <option value="stream_video">stream_video</option> + <option value="chat_message">chat_message</option> + <option value="disconnect">disconnect</option> + <option value="auth_failed">auth_failed</option> + </select> + <select value=${String(auditPageSize)} + onChange=${e => { setAuditPageSize(parseInt(e.target.value) || 100); setAuditPage(0); }}> + <option value="50">50</option> + <option value="100">100</option> + <option value="200">200</option> + <option value="500">500</option> + </select> + </div> + ${!audit ? html` + <p class="node-hint">${t('node.loading')}</p> + ` : audit.length === 0 ? html` + <p class="node-hint">${t('node.audit_empty')}</p> + ` : html` + <div class="node-table-scroll"> + <table class="node-table node-table-audit"> + <thead><tr> + <th>${t('node.audit_time')}</th> + <th>${t('node.audit_event')}</th> + <th>${t('node.audit_user')}</th> + <th>${t('node.audit_ip')}</th> + <th>${t('node.audit_group')}</th> + <th>${t('node.audit_detail')}</th> + </tr></thead> + <tbody> + ${audit.map(e => html` + <tr key=${e.id}> + <td>${new Date(e.timestamp * 1000).toLocaleString()}</td> + <td>${e.event}</td> + <td>${e.username || (e.user_id || '').slice(0, 8)}</td> + <td>${e.ip || '—'}</td> + <td>${e.group_name || (e.group_id ? e.group_id.slice(0, 8) : '—')}</td> + <td>${e.detail || ''}</td> + </tr> + `)} + </tbody> + </table> + </div> + `} + ${audit && html` + <div class="node-audit-pager"> + <button class="btn btn-small btn-secondary" + disabled=${busy || auditPage === 0} + onClick=${() => setAuditPage(p => Math.max(0, p - 1))}> + ${t('node.audit_prev')}</button> + <span class="node-hint">${t('node.audit_page', { n: auditPage + 1 })}</span> + <button class="btn btn-small btn-secondary" + disabled=${busy || !auditHasMore} + onClick=${() => setAuditPage(p => p + 1)}> + ${t('node.audit_next')}</button> </div> `} </div> + `} + + ${tab === 'overview' && html` + <div class="node-group"> + <span class="settings-heading">${t('node.maintenance')}</span> + ${cacheCount === null ? html` + <p class="node-hint">${t('node.loading')}</p> + ` : html` + <p class="node-hint">${t('node.maintenance_cache', { n: cacheCount })}</p> + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${pruneCache}>${t('node.maintenance_prune')}</button> + `} + </div> + `} - ${editSettings && html` + ${tab === 'settings' && editSettings && html` <div class="node-group"> <span class="settings-heading">${t('node.settings')}</span> <p class="node-hint">${t('node.settings_edit_hint')}</p> @@ -659,7 +992,7 @@ export function NodePage({ groups }) { </div> `} - ${editStun && html` + ${tab === 'settings' && editStun && html` <div class="node-group"> <span class="settings-heading">${t('node.stun_servers')}</span> <p class="node-hint">${t('node.stun_hint')}</p> @@ -704,7 +1037,7 @@ export function NodePage({ groups }) { </div> `} - ${editIce && html` + ${tab === 'settings' && editIce && html` <div class="node-group"> <span class="settings-heading">${t('node.ice_interfaces')}</span> <p class="node-hint">${editIce.length === 0 @@ -744,6 +1077,63 @@ export function NodePage({ groups }) { </div> </div> `} + + ${tab === 'settings' && html` + <div class="node-group"> + <div class="node-section-header"> + <span class="settings-heading">${t('node.denylist')}</span> + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${loadDenylist}> + ${t('node.denylist_load')}</button> + </div> + ${showDenylist && denylist && html` + <div class="node-denylist"> + ${denylist.count === 0 ? html` + <p class="node-hint">${t('node.denylist_empty')}</p> + ` : html` + <p class="node-hint">${t('node.denylist_count', + { n: denylist.count })}</p> + ${(denylist.users || []).map(u => html` + <div class="node-deny-entry" key=${'u:' + u}> + <span>user: ${u}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(u)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + ${(denylist.groups || []).map(g => html` + <div class="node-deny-entry" key=${'g:' + g}> + <span>group: ${g}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(g)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + ${(denylist.jtis || []).map(j => html` + <div class="node-deny-entry" key=${'j:' + j}> + <span>token: ${j.slice(0, 16)}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(j)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist('')}> + ${t('node.denylist_clear_all')}</button> + `} + </div> + `} + </div> + `} + + ${tab === 'settings' && nodeInfo && nodeInfo.status !== 'waiting_for_node_key' && html` + <div class="node-group node-group-danger"> + <span class="settings-heading">${t('node.unlink_title')}</span> + <p class="node-hint">${t('node.unlink_hint')}</p> + <button class="btn btn-small btn-danger" disabled=${unlinkBusy || busy} + onClick=${unlinkNode}>${t('node.unlink_button')}</button> + </div> + `} </div> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index e217cc8..fe01cd6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2480,6 +2480,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } opacity: 0.65; border-color: var(--warn, #d97706); } +.node-group-danger { + border-color: var(--error, #dc2626); +} .node-group-header { display: flex; align-items: baseline; @@ -2616,6 +2619,28 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } padding: 4px 8px; border-bottom: 1px solid var(--border); } +.node-table-scroll { + overflow-x: auto; + margin-top: 4px; +} +/* Audit rows: keep the fixed-shape columns on one line (the IP must never be + clipped); only the free-text detail column wraps. */ +.node-table-audit td, +.node-table-audit th { white-space: nowrap; } +.node-table-audit td:last-child, +.node-table-audit th:last-child { + white-space: normal; + word-break: break-word; + overflow-wrap: anywhere; + min-width: 16ch; + max-width: 48ch; +} +.node-audit-pager { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} .node-deny-entry { display: flex; diff --git a/packages/meshbay-node/src/meshbay_node/audit.py b/packages/meshbay-node/src/meshbay_node/audit.py index 6346f16..b382107 100644 --- a/packages/meshbay-node/src/meshbay_node/audit.py +++ b/packages/meshbay-node/src/meshbay_node/audit.py @@ -104,6 +104,7 @@ class AuditStore: limit: int = 200, user_id: str | None = None, event: str | None = None, + offset: int = 0, ) -> list[AuditEntry]: conditions = ["timestamp > ?"] params: list = [since] @@ -114,11 +115,12 @@ class AuditStore: conditions.append("event = ?") params.append(event) params.append(limit) + params.append(max(0, offset)) where = " AND ".join(conditions) cursor = await self._db.execute( f"SELECT id, timestamp, user_id, username, ip, event, group_id, detail " - f"FROM audit_log WHERE {where} ORDER BY timestamp DESC LIMIT ?", + f"FROM audit_log WHERE {where} ORDER BY timestamp DESC LIMIT ? OFFSET ?", params, ) rows = await cursor.fetchall() diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 0355615..b1ebd54 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -37,7 +37,7 @@ username = "myusername" [node] quic_port = 19010 # QUIC (MNP) — LAN, port-forwarded, hub-less direct access -ui_port = 18000 # local admin UI (127.0.0.1 only) +ui_port = 18000 # local control API — JSON, 127.0.0.1 only, token-gated # One-time codes. An invitation waits for someone to read their messages; an # operator pairing code is typed during the SSH session that printed it. diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 2130440..056371a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -12,14 +12,13 @@ Startup sequence: 8. Start QUIC chunk server (LAN / port-forwarded / hub-less direct access) 9. (Phase 11.5: the unauthenticated HTTP file API and the TCP+TLS server were removed) 10. Start hub WebSocket (signaling, revocations, WebRTC offers) - 11. Start local web UI on node.ui_port (localhost only) + 11. Start local control API on node.ui_port (loopback only, token-gated) 12. Run until SIGINT/SIGTERM Usage: meshbay-node # interactive password prompt meshbay-node --config /path # custom config meshbay-node status # node state + public key (works while stopped) - meshbay-node ui # print the local admin UI URL meshbay-node gek-init # initialise the group key (no browser needed) meshbay-node init # write example config + create keystore meshbay-node --calibrate-argon2 # benchmark Argon2id, suggest parameters @@ -201,19 +200,21 @@ class NodeDaemon: ) log.info("Keys loaded: %s", keys.pk_ed25519_b64[:16]) - # 2. Start admin UI early (so operator can copy node key before hub login) + # 2. Start the local control API early (so the operator can read the + # node key before hub login). It is JSON-only, loopback-only, and both + # the CLI and the desktop client's Node page are its clients. self._state["pk_node_ed25519"] = keys.pk_ed25519_b64 self._state["config"] = self._config # Where it came from, so `group add` appends to the file this process # actually read rather than guessing at the default. self._state["config_path"] = str(self._config_path) - # Per-run token for the local admin UI (11.5.3). Not a password: it keeps + # Per-run token for the control API (11.5.3). Not a password: it keeps # other local processes and rebound browser pages out of an API that can # re-initialise group keys. ui_token = base64.urlsafe_b64encode(os.urandom(18)).decode().rstrip("=") self._state["ui_token"] = ui_token - # Persisted so `meshbay-node ui` can open the browser. Nobody should ever - # have to copy a token out of a log or a terminal — that is not a workflow. + # Persisted so the CLI and the desktop client can read it — nobody + # should ever copy a token out of a log or a terminal. self._config.data_dir.mkdir(parents=True, exist_ok=True) self._ui_token_file = self._config.data_dir / "ui-token" self._ui_token_file.write_text(ui_token) @@ -228,7 +229,7 @@ class NodeDaemon: ) ui_server = uvicorn.Server(ui_cfg) self._tasks.append(asyncio.create_task(ui_server.serve())) - log.info("Admin UI ready — open it with: meshbay-node ui") + log.info("Control API on 127.0.0.1:%d", self._config.node.ui_port) # 3. Hub connection (Ed25519 auth — retries until node key is linked) hub_cfg = HubConfig( @@ -415,7 +416,7 @@ class NodeDaemon: } if not groups_ctx: - log.warning("No groups configured yet — admin UI and hub " + log.warning("No groups configured yet — the control API and hub " "connection stay up; attach a group to go live") # 5. Chat stores (one SQLite DB per group) @@ -608,7 +609,7 @@ class NodeDaemon: # authentication, for private groups too — finding C1. Every client path now # goes through the MNP handshake (JWT + group claim + GEK proof). - # 10. Update admin UI state (UI already running from step 2) + # 10. Update control API state (already running from step 2) self._state["groups_ctx"] = groups_ctx self._state["audit_store"] = self._audit_store self._state["bundle_store"] = self._bundle_store @@ -908,17 +909,19 @@ class NodeDaemon: except _httpx.HTTPStatusError as e: body = e.response.text if hasattr(e.response, 'text') else '' # Any 401 here needs a human at a browser, and the operator needs - # this daemon alive to read its public key out of the local admin - # UI. Exiting would take that UI down and strand them — which is - # exactly what happened when a node was started before its owner - # had registered. + # this daemon alive to read its public key (via `meshbay-node + # status` or the desktop client, both of which query the control + # API). Exiting would strand them — which is exactly what + # happened when a node was started before its owner had + # registered. if e.response.status_code == 401: if "No node key" in body: self._state["status"] = "waiting_for_node_key" log.warning( - "Node key not linked. Open the admin UI, copy this " - "node's key, and paste it in Settings > Link Node on " - "%s. Retrying in 5s...", + "Node key not linked. Get it from `meshbay-node " + "status` and paste it in Settings > Link Node on %s " + "(the desktop client links it automatically). " + "Retrying in 5s...", self._config.hub.url, ) else: @@ -1500,8 +1503,8 @@ def _daemon_api(cfg: Config, path: str, method: str = "GET", The daemon owns the roster, the hub session and the live group contexts, so the CLI asks it to act rather than opening its databases behind its back. It - also means every operator action goes through the same authorization as the - admin UI (the per-run session token, 11.5.3). + also means every operator action goes through the control API's per-run + session token (11.5.3), the same gate the desktop client's Node page passes. """ import json as _json import urllib.error @@ -1611,13 +1614,13 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", - choices=["init", "reset", "status", "ui", "gek-init", + choices=["init", "reset", "status", "gek-init", "gek", "operator", "member", "group", "file", "video", "denylist", "stun", "reload", "restart-daemon", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " - "| ui: print the admin UI URL | operator pair: pair a " + "| operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " "| group list|add|remove | gek init|rotate | file list|rm " "| video rematch: re-resolve TMDB matches for a group's " @@ -1655,7 +1658,7 @@ def main() -> None: args = parser.parse_args() # Query commands print a report; library logging would interleave with it. - quiet = args.command in ("status", "ui", "gek-init", "gek", "operator", + quiet = args.command in ("status", "gek-init", "gek", "operator", "member", "group", "file", "video", "denylist", "stun", "reload", "restart-daemon", "reset") logging.basicConfig( @@ -1843,7 +1846,6 @@ def main() -> None: print(f"groups {live.get('group_count', 0)}" f" files {live.get('total_files', 0)}" f" peers {live.get('webrtc_peers', 0)}") - print(f"admin UI meshbay-node ui") needs = live.get("needs", []) if needs: @@ -2331,19 +2333,6 @@ def main() -> None: print(f"also written to {path}") return - if args.command == "ui": - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) - token_file = cfg.data_dir / "ui-token" - if not token_file.exists(): - print("Node does not appear to be running — start it with: meshbay-node") - sys.exit(1) - print(f"http://127.0.0.1:{cfg.node.ui_port}" - f"/?t={token_file.read_text().strip()}") - print() - print("The UI listens on loopback only. From another machine:") - print(f" ssh -L {cfg.node.ui_port}:127.0.0.1:{cfg.node.ui_port} <this-host>") - return - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) if not cfg.hub.username: print("Error: hub.username not set in config. Run: meshbay-node init") diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 671597f..6fdc78f 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -1,34 +1,25 @@ """ -MeshBay Node — local administration web UI (localhost:18000). +MeshBay Node — local control API (loopback, default port 18000). -FastAPI app providing: - - Dashboard: node status, connected peers, group overview - - Groups: file listing, shared directory info - - Peers: connected WebRTC/QUIC clients - - Audit log: IP + action log for legal compliance - - API endpoints for all data (JSON) +A JSON-only FastAPI app: node status, groups and roots, roster and denylist, +node settings, connected peers, and the audit log. It is the single control +plane for the node — the `meshbay-node` CLI and the desktop client's Node page +are both clients of it. (Chat is served to browsers over MNP/WebRTC, not here.) -Served only on 127.0.0.1 — not exposed to the network. -Gated by a per-run session token (11.5.3) — printed at daemon startup. +Served only on 127.0.0.1 — never network-exposed — and every request is gated +by a per-run session token (11.5.3) written to `<data_dir>/ui-token`. There is +no server-rendered UI: the Node page ships in the desktop client (see +`docs/refactor-node-ui.md`). """ import asyncio -import base64 -import json import logging -import time -from html import escape -from pathlib import Path -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import JSONResponse -from meshbay_node import __version__ -from meshbay_node import ops -from meshbay_node.config import DEFAULT_CONFIG_PATH +from meshbay_node import __version__, ops from meshbay_node.indexer.indexer import DirectoryIndexer -from meshbay_common.crypto import generate_gek, wrap_gek_aes -from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR log = logging.getLogger(__name__) @@ -50,6 +41,31 @@ def _op(coro): return run() +async def _display_names(state: dict) -> tuple[dict[str, str], dict[str, str]]: + """(user_id -> username, group_id -> name) for rendering ids a human reads. + + Usernames come from the roster (the node's own record); group names from + node.toml. Both are best-effort — a missing entry just leaves the caller + with the raw id to shorten. + """ + users: dict[str, str] = {} + roster = state.get("roster") + if roster: + try: + for ident in await roster.list_identities(): + if ident.get("username"): + users[ident["user_id"]] = ident["username"] + except Exception: + pass + groups: dict[str, str] = {} + config = state.get("config") + if config: + for g in config.groups: + if getattr(g, "id", None): + groups[g.id] = g.name + return users, groups + + def create_ui_app(state: dict) -> FastAPI: app = FastAPI( @@ -62,14 +78,14 @@ def create_ui_app(state: dict) -> FastAPI: @app.middleware("http") async def _require_session_token(request, call_next): """ - Gate the admin UI behind a per-run token (11.5.3). + Gate the control API behind a per-run token (11.5.3). "localhost only" is weaker than it sounds: any process on the machine can reach it, and a page in the operator's browser can reach it too via DNS rebinding. Since this API can re-initialise a group's GEK and read the audit log, an unauthenticated loopback service is a privilege boundary - waiting to be crossed. The token is printed at startup and accepted as - ?t= or the X-MeshBay-Token header. + waiting to be crossed. The token is written to `<data_dir>/ui-token` at + startup and accepted as ?t= or the X-MeshBay-Token header. """ from fastapi.responses import PlainTextResponse @@ -84,25 +100,17 @@ def create_ui_app(state: dict) -> FastAPI: @app.middleware("http") async def _security_headers(request, call_next): """ - Defence in depth behind the escaping fixes for H2. This UI is unauthenticated - on loopback, so script execution here equals full control of the node admin API. - - Note what this does and does not do: the page relies on inline <script>, so - script-src must allow 'unsafe-inline' and CSP therefore does NOT prevent an - injected script from running. Escaping is the actual fix. What CSP buys is - containment — connect-src/img-src/form-action 'self'|'none' stop an injected - script from exfiltrating the audit log or config to an external host. + Belt-and-braces for a loopback API that returns only JSON. Since the + server no longer renders any HTML (the dashboard was removed + 2026-09-01), the response has nothing an injected script could live in + — but a DNS-rebound page or a content-sniffing client that manages to + treat a body as a document still gets `default-src 'none'`, which + forbids every fetch, script, style and frame. `nosniff` stops the + sniffing in the first place. """ response = await call_next(request) response.headers["Content-Security-Policy"] = ( - "default-src 'none'; " - "style-src 'unsafe-inline'; " - "script-src 'unsafe-inline'; " - "connect-src 'self'; " - "img-src 'self' data:; " - "form-action 'none'; " - "frame-ancestors 'none'; " - "base-uri 'none'" + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" ) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" @@ -126,7 +134,6 @@ def create_ui_app(state: dict) -> FastAPI: if status == "running": roster = state.get("roster") if roster: - from meshbay_node.roster import Roster members = await roster.list_members() operators = [m for m in members if m["role"] == "operator" and m["status"] == "active"] @@ -239,14 +246,18 @@ def create_ui_app(state: dict) -> FastAPI: webrtc = state.get("webrtc") if not webrtc: return {"peers": []} + users, groups = await _display_names(state) peers = [] for pid, session in list(webrtc._sessions.items()): from meshbay_node.transport.webrtc_server import _get_remote_ip + uid = session._user_id or "" + gid = session._group_id or "" peers.append({ "peer_id": pid, - "user_id": session._user_id or "", - "username": session._username or "", - "group_id": session._group_id or "", + "user_id": uid, + "username": session._username or users.get(uid, ""), + "group_id": gid, + "group_name": groups.get(gid, ""), "remote_ip": session._remote_ip or _get_remote_ip(session._pc), "state": session._pc.connectionState, }) @@ -256,60 +267,44 @@ def create_ui_app(state: dict) -> FastAPI: async def api_audit( since: float = 0, limit: int = 200, + offset: int = 0, user_id: str | None = Query(default=None), event: str | None = Query(default=None), ): audit = state.get("audit_store") if not audit: - return {"entries": []} - entries = await audit.get_entries( - since=since, limit=limit, user_id=user_id, event=event) + return {"entries": [], "offset": 0, "limit": limit, "has_more": False} + limit = max(1, min(limit, 1000)) + offset = max(0, offset) + # Fetch one extra row to know whether a next page exists without a count. + rows = await audit.get_entries( + since=since, limit=limit + 1, offset=offset, + user_id=user_id, event=event) + has_more = len(rows) > limit + entries = rows[:limit] + + # Legacy rows and pre-handshake events store user_id only; group_id is + # never a name. Resolve both for display — no migration, the roster and + # node.toml are the node's own records. + names, groups = await _display_names(state) + return { + "offset": offset, + "limit": limit, + "has_more": has_more, "entries": [ { "id": e.id, "timestamp": e.timestamp, "user_id": e.user_id, - "username": e.username, + "username": e.username or names.get(e.user_id, ""), "ip": e.ip, "event": e.event, "group_id": e.group_id, + "group_name": groups.get(e.group_id, ""), "detail": e.detail, } for e in entries - ] - } - - @app.get("/api/config") - async def api_config(): - config = state.get("config") - if not config: - return {} - return { - "hub_url": config.hub.url, - "username": config.hub.username, - "quic_port": config.node.quic_port, - "ui_port": config.node.ui_port, - "data_dir": str(config.data_dir), - "settings": { - "invite_ttl_hours": config.node.invite_ttl_hours, - "pair_ttl_hours": config.node.pair_ttl_hours, - "device_request_ttl_minutes": config.node.device_request_ttl_minutes, - "max_concurrent_streams": config.node.max_concurrent_streams, - "transcode_incompatible_video": config.node.transcode_incompatible_video, - }, - "groups": [ - { - "id": g.id, - "name": g.name, - "roots": [ - {"path": r.path, "name": r.name, "kind": r.kind, - "upload": r.upload} - for r in g.roots - ], - "visibility": g.visibility, - } - for g in config.groups ], } @@ -455,574 +450,4 @@ def create_ui_app(state: dict) -> FastAPI: async def update_node_settings(payload: dict): return await _op(lambda: ops.set_node_settings(state, payload)) - # ── Chat endpoints ─────────────────────────────────────────────────────── - - _chat_subscribers: list[WebSocket] = [] - - @app.get("/api/chat/history") - async def chat_history(since: float = 0, limit: int = 100): - chat_store = state.get("chat_store") - if not chat_store: - return {"messages": []} - msgs = await chat_store.get_messages(since=since, limit=limit) - return { - "messages": [ - { - "id": m.id, - "sender_id": m.sender_id, - "iteration": m.iteration, - "timestamp": m.timestamp, - "thread_id": m.thread_id, - } - for m in msgs - ] - } - - @app.websocket("/ws/chat") - async def chat_websocket(ws: WebSocket): - await ws.accept() - _chat_subscribers.append(ws) - try: - while True: - await ws.receive_text() - except WebSocketDisconnect: - pass - finally: - _chat_subscribers.remove(ws) - - async def broadcast_chat_to_ui(msg: dict) -> None: - payload = json.dumps(msg) - dead = [] - for ws in _chat_subscribers: - try: - await ws.send_text(payload) - except Exception: - dead.append(ws) - for ws in dead: - _chat_subscribers.remove(ws) - - app.broadcast_chat = broadcast_chat_to_ui - - # ── HTML UI ────────────────────────────────────────────────────────────── - - @app.get("/", response_class=HTMLResponse) - async def root(): - # Roster reads are async and the page renderer is not, so gather here. - roster = state.get("roster") - roster_view = None - if roster: - identities = {i["user_id"]: i for i in await roster.list_identities()} - roster_view = { - "identities": identities, - "members": await roster.list_members(), - "invites": await roster.list_invites(), - } - index_cache = state.get("index_cache") - cache_count = await index_cache.count() if index_cache else None - return _render_page(state, roster_view, cache_count) - - @app.get("/audit", response_class=HTMLResponse) - async def audit_page(): - return _render_audit_page(state.get("ui_token", "")) - return app - - -def _fmt_size(n: int) -> str: - if n < 1024: - return f"{n} B" - if n < 1024 * 1024: - return f"{n / 1024:.1f} KB" - if n < 1024 * 1024 * 1024: - return f"{n / (1024 * 1024):.1f} MB" - return f"{n / (1024 * 1024 * 1024):.2f} GB" - - -def _render_node_settings(config) -> str: - if not config: - return "" - nd = config.node - transcode = "on" if nd.transcode_incompatible_video else "off" - return f""" - <table style="margin-top:10px"> - <thead><tr><th>Setting</th><th>Value</th></tr></thead> - <tbody> - <tr><td>Invitation TTL</td><td>{nd.invite_ttl_hours} hours</td></tr> - <tr><td>Pairing code TTL</td><td>{nd.pair_ttl_hours} hours</td></tr> - <tr><td>Device request TTL</td><td>{nd.device_request_ttl_minutes} minutes</td></tr> - <tr><td>Max concurrent streams</td><td>{nd.max_concurrent_streams}</td></tr> - <tr><td>Transcode incompatible video</td><td>{transcode}</td></tr> - </tbody> - </table>""" - - -def _render_roster(roster_view: dict | None) -> str: - """ - Who this node recognises, and which keys are theirs. - - Every value here is escaped: usernames come from the hub and pass through the - roster, so they are attacker-influenced text on the operator's own admin page - (the H2 rule applies to them exactly as it does to filenames). - """ - if roster_view is None: - return '<p class="muted">Roster unavailable</p>' - - identities = roster_view["identities"] - rows = "" - for m in roster_view["members"]: - ident = identities.get(m["user_id"], {}) - scope = escape(m["group_id"][:8]) if m["group_id"] else "node-wide" - status_color = "#22c55e" if m["status"] == "active" else "#ef4444" - rows += ( - f"<tr><td>{escape(str(ident.get('username') or m['user_id']))}</td>" - f"<td>{escape(str(m['role']))}</td>" - f"<td><span class='badge' style='background:{status_color}'>" - f"{escape(str(m['status']))}</span></td>" - f"<td>{scope}</td>" - f"<td><code>{escape(str(ident.get('pk_ed25519', ''))[:16])}…</code></td>" - f"<td>{escape(str(ident.get('pinned_at', '?')))} " - f"({escape(str(ident.get('pinned_via', '?')))})</td></tr>" - ) - if not rows: - rows = ('<tr><td colspan="6" class="muted">Nobody admitted yet — ' - 'run <code>meshbay-node member invite <username></code></td></tr>') - - invite_rows = "" - for i in roster_view["invites"]: - invite_rows += ( - f"<tr><td><code>{escape(str(i['user_id'])[:16])}</code></td>" - f"<td>{escape(str(i['group_id'][:8] or 'node-wide'))}</td>" - f"<td>{escape(str(i['role']))}</td>" - f"<td>{escape(str(i['expires_at']))}</td></tr>" - ) - invites_html = "" - if invite_rows: - invites_html = f""" - <details style="margin-top:10px"><summary>Pending invitations</summary> - <table> - <thead><tr><th>Account</th><th>Group</th><th>Role</th><th>Expires</th></tr></thead> - <tbody>{invite_rows}</tbody> - </table> - </details>""" - - return f""" - <table> - <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Scope</th> - <th>Identity key</th><th>Pinned</th></tr></thead> - <tbody>{rows}</tbody> - </table> - {invites_html} - <p class="muted" style="margin-top:8px"> - Codes are issued from the CLI: <code>meshbay-node operator pair</code>, - <code>meshbay-node member invite <username></code>. They never pass - through the hub. - </p>""" - - -def _render_page(state: dict, roster_view: dict | None = None, - index_cache_count: int | None = None) -> str: - token_js = json.dumps(state.get("ui_token", "")) - status = state.get("status", "starting") - indexes = state.get("indexes", {}) - groups_ctx = state.get("groups_ctx", {}) - config = state.get("config") - webrtc = state.get("webrtc") - total_files = sum(idx.count for idx in indexes.values()) - peer_count = webrtc.active_peers if webrtc else 0 - status_color = { - "running": "#22c55e", "error": "#ef4444", - "waiting_for_node_key": "#f97316", - }.get(status, "#f59e0b") - - # Groups section - groups_html = "" - for gid, ctx in groups_ctx.items(): - cfg = None - if config: - cfg = next((g for g in config.groups if g.id == gid), None) - idx = ctx.get("index") - name = cfg.name if cfg else gid[:8] - roots = ctx.get("roots") - # An unavailable root is shown as such rather than hidden: its files are - # still listed and still in the index, and hiding the root would make a - # frozen library look deleted — the exact confusion this is meant to - # prevent. - shared = ", ".join( - f"{r.name} → {r.path}" + ("" if r.available else " [UNAVAILABLE]") - for r in roots - ) if roots else "" - vis = cfg.visibility if cfg else "private" - fcount = idx.count if idx else 0 - total_size = sum(e.size for e in idx.entries) if idx else 0 - - # Everything interpolated below is attacker-controlled: filenames come from - # uploads by any group member. Rendering them raw was a stored XSS into the - # unauthenticated localhost admin UI, i.e. full control of the node admin API - # from the operator's browser (finding H2). - file_rows = "" - if idx: - for e in sorted(idx.entries, key=lambda x: x.name): - file_rows += ( - f"<tr><td>{escape(e.name)}</td><td>{escape(e.type)}</td>" - f"<td>{_fmt_size(e.size)}</td><td>{escape(e.path or '/')}</td></tr>" - ) - - has_gek = bool(ctx.get("gek")) - gek_badge = ( - '<span class="badge" style="background:#22c55e">GEK active</span>' - if has_gek - else '<span class="badge" style="background:#ef4444">No GEK</span>' - ) - gek_label = "Re-wrap GEK for all members" if has_gek else "Initialize GEK" - gek_color = "#3b82f6" if has_gek else "#22c55e" - gek_action = f""" - <div style="margin:10px 0"> - <button onclick="initGEK('{gid}')" - id="gek-btn-{gid[:8]}" - style="padding:8px 16px;background:{gek_color};color:#fff;border:none; - border-radius:6px;cursor:pointer;font-size:0.85em"> - {gek_label} - </button> - <span id="gek-status-{gid[:8]}" class="muted" style="margin-left:8px"></span> - </div>""" - - groups_html += f""" - <div class="card"> - <h3>{escape(str(name))} - <span class="badge" style="background:#6366f1">{escape(str(vis))}</span> - {gek_badge} - </h3> - <p><b>Directory:</b> <code>{escape(str(shared))}</code></p> - <p><b>Files:</b> {fcount} — <b>Total:</b> {_fmt_size(total_size)}</p> - {gek_action} - <p class="muted">ID: {escape(gid)}</p> - <details><summary>File list</summary> - <table> - <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead> - <tbody>{file_rows}</tbody> - </table> - </details> - </div>""" - - # Peers section - peers_html = "" - if webrtc: - for pid, session in list(webrtc._sessions.items()): - from meshbay_node.transport.webrtc_server import _get_remote_ip - ip = session._remote_ip or _get_remote_ip(session._pc) - peers_html += ( - f"<tr><td>{escape(session._username or session._user_id or '—')}</td>" - f"<td>{escape(ip or '—')}</td>" - f"<td>{escape(session._group_id[:8] if session._group_id else '—')}</td>" - f"<td>{escape(session._pc.connectionState)}</td></tr>" - ) - if not peers_html: - peers_html = '<tr><td colspan="4" class="muted">No connected peers</td></tr>' - - return f"""<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<title>MeshBay Node Admin</title> -<meta name="viewport" content="width=device-width,initial-scale=1"> -<style> - :root {{ - --bg: #0f172a; --surface: #1e293b; --border: #334155; - --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6; - --green: #22c55e; --red: #ef4444; --yellow: #f59e0b; - }} - * {{ box-sizing: border-box; margin: 0; padding: 0; }} - body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: var(--bg); color: var(--text); }} - .container {{ max-width: 1000px; margin: 0 auto; padding: 20px; }} - h1 {{ font-size: 1.5em; margin-bottom: 20px; }} - h2 {{ font-size: 1.2em; margin: 24px 0 12px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }} - h3 {{ font-size: 1em; margin-bottom: 8px; }} - .badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; - color: #fff; font-size: 0.8em; font-weight: 600; vertical-align: middle; }} - .stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 12px; margin-bottom: 20px; }} - .stat {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; - padding: 16px; text-align: center; }} - .stat .value {{ font-size: 1.8em; font-weight: 700; color: var(--accent); }} - .stat .label {{ font-size: 0.8em; color: var(--muted); margin-top: 4px; }} - .card {{ background: var(--surface); border: 1px solid var(--border); - border-radius: 8px; padding: 16px; margin-bottom: 12px; }} - table {{ width: 100%; border-collapse: collapse; font-size: 0.85em; margin-top: 8px; }} - th, td {{ padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }} - th {{ color: var(--muted); font-weight: 600; font-size: 0.75em; text-transform: uppercase; }} - code {{ background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }} - .muted {{ color: var(--muted); font-size: 0.85em; }} - details summary {{ cursor: pointer; color: var(--accent); font-size: 0.85em; margin-top: 8px; }} - a {{ color: var(--accent); text-decoration: none; }} - a:hover {{ text-decoration: underline; }} - nav {{ display: flex; gap: 16px; margin-bottom: 20px; }} - nav a {{ padding: 6px 12px; border-radius: 6px; background: var(--surface); - border: 1px solid var(--border); }} - nav a:hover {{ background: var(--border); text-decoration: none; }} - .footer {{ margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--border); - font-size: 0.8em; color: var(--muted); }} -</style> -</head> -<body> -<div class="container"> - <h1>MeshBay Node <span class="badge" style="background:{status_color}">{status}</span></h1> - - <nav> - <a href="/">Dashboard</a> - <a href="/audit">Audit Log</a> - <a href="/api/status">API</a> - </nav> - - <div class="stats"> - <div class="stat"><div class="value">{len(groups_ctx)}</div><div class="label">Groups</div></div> - <div class="stat"><div class="value">{total_files}</div><div class="label">Files</div></div> - <div class="stat"><div class="value">{peer_count}</div><div class="label">Connected Peers</div></div> - <div class="stat"> - <div class="value" style="font-size:1em;word-break:break-all">{state.get("username", "—")}</div> - <div class="label">User</div> - </div> - </div> - - <h2>Connected Peers</h2> - <table> - <thead><tr><th>User</th><th>IP</th><th>Group</th><th>State</th></tr></thead> - <tbody>{peers_html}</tbody> - </table> - - <h2>Roster</h2> - {_render_roster(roster_view)} - - <h2>Groups</h2> - {groups_html or '<p class="muted">No groups configured</p>'} - - <h2>Node Configuration</h2> - <div class="card"> - <p><b>Hub:</b> {state.get("hub_url", "—")}</p> - <p><b>QUIC port:</b> {state.get("quic_port", "—")}</p> - <p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p> - {_render_node_settings(config)} - </div> - - <h2>Maintenance</h2> - <div class="card"> - <p><b>Index cache:</b> <span id="cacheCount">{ - index_cache_count if index_cache_count is not None else "—" - }</span> path(s) remembered (size/mtime → hash, shared by every group)</p> - <p class="muted">Removes rows whose path no longer belongs to any group's - root, or whose file is genuinely gone from a root that is currently - reachable. Never touches a root that is temporarily unavailable - (unplugged drive) — that one still needs its full cache back the - moment it returns.</p> - <div style="margin:10px 0"> - <button onclick="pruneIndexCache()" id="prune-cache-btn" - style="padding:8px 16px;background:#3b82f6;color:#fff;border:none; - border-radius:6px;cursor:pointer;font-size:0.85em"> - Prune stale entries - </button> - <span id="prune-cache-status" class="muted" style="margin-left:8px"></span> - </div> - </div> - - <h2>Link Node to Hub Account</h2> - <div class="card"> - <p>To connect to your group from a browser, link this node to your hub account. - Copy the key below and paste it in <b>Settings > Link Node</b> on the hub.</p> - <div style="margin:12px 0;display:flex;align-items:center;gap:8px"> - <code id="nodeKey" style="flex:1;padding:8px;word-break:break-all;background:var(--border); - border-radius:4px;font-size:0.9em;user-select:all">{state.get("pk_node_ed25519", "—")}</code> - <button onclick="navigator.clipboard.writeText(document.getElementById('nodeKey').textContent).then(()=>{{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',2000)}})" - style="padding:8px 16px;background:var(--accent);color:#fff;border:none;border-radius:6px; - cursor:pointer;font-size:0.85em;white-space:nowrap">Copy</button> - </div> - <p class="muted">This is the node's Ed25519 public key. It's safe to share — it identifies - this node but cannot be used to impersonate it.</p> - </div> - - <div class="footer"> - MeshBay Node v{__version__} — localhost only — - <a href="/api/status">status</a> · - <a href="/api/groups">groups</a> · - <a href="/api/peers">peers</a> · - <a href="/api/audit">audit</a> · - <a href="/api/config">config</a> - — auto-refresh 10s - </div> -</div> -<script> -const TOKEN = {token_js}; -async function initGEK(groupId) {{ - const btn = document.getElementById('gek-btn-' + groupId.slice(0,8)); - const status = document.getElementById('gek-status-' + groupId.slice(0,8)); - if (btn) btn.disabled = true; - if (status) status.textContent = 'Initializing...'; - try {{ - const resp = await fetch('/api/groups/' + groupId + '/gek?t=' + TOKEN, {{ method: 'POST' }}); - const data = await resp.json(); - if (resp.ok) {{ - if (status) status.textContent = 'GEK initialized — ' - + data.authorized_members + ' authorized member(s) get it on connect'; - if (status) status.style.color = '#22c55e'; - setTimeout(() => location.reload(), 2000); - }} else {{ - if (status) status.textContent = data.error || 'Failed'; - if (status) status.style.color = '#ef4444'; - if (btn) btn.disabled = false; - }} - }} catch (e) {{ - if (status) status.textContent = 'Error: ' + e.message; - if (status) status.style.color = '#ef4444'; - if (btn) btn.disabled = false; - }} -}} -async function pruneIndexCache() {{ - const btn = document.getElementById('prune-cache-btn'); - const status = document.getElementById('prune-cache-status'); - if (btn) btn.disabled = true; - if (status) {{ status.textContent = 'Pruning...'; status.style.color = ''; }} - try {{ - const resp = await fetch('/api/index-cache/prune?t=' + TOKEN, {{ method: 'POST' }}); - const data = await resp.json(); - if (resp.ok) {{ - if (status) status.textContent = 'Removed ' + data.removed + ', kept ' + data.kept; - if (status) status.style.color = '#22c55e'; - const count = document.getElementById('cacheCount'); - if (count) count.textContent = data.kept; - }} else {{ - if (status) status.textContent = data.error || 'Failed'; - if (status) status.style.color = '#ef4444'; - }} - }} catch (e) {{ - if (status) status.textContent = 'Error: ' + e.message; - if (status) status.style.color = '#ef4444'; - }} finally {{ - if (btn) btn.disabled = false; - }} -}} -setTimeout(()=>location.reload(), 10000); -</script> -</body> -</html>""" - - -def _render_audit_page(token: str = "") -> str: - return _AUDIT_HTML.replace("__TOKEN__", json.dumps(token)) - - -_AUDIT_HTML = """<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<title>MeshBay Node — Audit Log</title> -<meta name="viewport" content="width=device-width,initial-scale=1"> -<style> - :root { - --bg: #0f172a; --surface: #1e293b; --border: #334155; - --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6; - } - * { box-sizing: border-box; margin: 0; padding: 0; } - body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: var(--bg); color: var(--text); } - .container { max-width: 1100px; margin: 0 auto; padding: 20px; } - h1 { font-size: 1.5em; margin-bottom: 16px; } - nav { display: flex; gap: 16px; margin-bottom: 20px; } - nav a { padding: 6px 12px; border-radius: 6px; background: var(--surface); - border: 1px solid var(--border); color: var(--accent); text-decoration: none; } - nav a:hover { background: var(--border); } - .filters { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } - .filters select, .filters input { - background: var(--surface); color: var(--text); border: 1px solid var(--border); - padding: 6px 10px; border-radius: 6px; font-size: 0.85em; - } - table { width: 100%; border-collapse: collapse; font-size: 0.82em; } - th, td { padding: 5px 8px; text-align: left; border-bottom: 1px solid var(--border); } - th { color: var(--muted); font-weight: 600; font-size: 0.75em; text-transform: uppercase; - position: sticky; top: 0; background: var(--bg); } - .muted { color: var(--muted); } - #count { margin-bottom: 10px; font-size: 0.85em; color: var(--muted); } -</style> -</head> -<body> -<div class="container"> - <h1>Audit Log</h1> - <nav><a id="navHome" href="/">Dashboard</a><a id="navAudit" href="/audit">Audit Log</a></nav> - - <div class="filters"> - <select id="eventFilter"> - <option value="">All events</option> - <option value="handshake">handshake</option> - <option value="file_download">file_download</option> - <option value="file_upload">file_upload</option> - <option value="file_delete">file_delete</option> - <option value="stream_video">stream_video</option> - <option value="chat_message">chat_message</option> - <option value="disconnect">disconnect</option> - <option value="auth_failed">auth_failed</option> - </select> - <input id="userFilter" placeholder="Filter by user..." /> - <select id="limitSelect"> - <option value="100">100 entries</option> - <option value="500">500 entries</option> - <option value="1000">1000 entries</option> - </select> - </div> - - <div id="count"></div> - <table> - <thead><tr><th>Time</th><th>Event</th><th>User</th><th>IP</th><th>Group</th><th>Detail</th></tr></thead> - <tbody id="tbody"></tbody> - </table> -</div> -<script> -const TOKEN = __TOKEN__; -async function load() { - const ev = document.getElementById('eventFilter').value; - const limit = document.getElementById('limitSelect').value; - let url = '/api/audit?limit=' + limit + (TOKEN ? '&t=' + TOKEN : ''); - if (ev) url += '&event=' + ev; - const r = await fetch(url); - const data = await r.json(); - const tbody = document.getElementById('tbody'); - document.getElementById('count').textContent = data.entries.length + ' entries'; - // textContent, not innerHTML: e.detail carries filenames chosen by group members - // (finding H2). Building this row with string concatenation was a stored XSS. - tbody.replaceChildren(...data.entries.map(e => { - const tr = document.createElement('tr'); - const cells = [ - new Date(e.timestamp * 1000).toLocaleString(), - e.event, - e.username || (e.user_id || '').slice(0, 8), - e.ip || '—', - e.group_id ? e.group_id.slice(0, 8) : '—', - e.detail || '', - ]; - for (const value of cells) { - const td = document.createElement('td'); - td.textContent = value; - tr.appendChild(td); - } - return tr; - })); -} -for (const [id, href] of [['navHome','/'],['navAudit','/audit']]) { - const el = document.getElementById(id); - if (el && TOKEN) el.href = href + '?t=' + TOKEN; -} -document.getElementById('eventFilter').onchange = load; -document.getElementById('limitSelect').onchange = load; -let debounceTimer; -document.getElementById('userFilter').oninput = function() { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - const val = this.value; - const rows = document.querySelectorAll('#tbody tr'); - rows.forEach(r => { - r.style.display = r.textContent.toLowerCase().includes(val.toLowerCase()) ? '' : 'none'; - }); - }, 200); -}; -load(); -setInterval(load, 15000); -</script> -</body> -</html>""" diff --git a/packages/meshbay-node/tests/test_audit.py b/packages/meshbay-node/tests/test_audit.py index b2ed15e..8755be2 100644 --- a/packages/meshbay-node/tests/test_audit.py +++ b/packages/meshbay-node/tests/test_audit.py @@ -53,6 +53,27 @@ async def test_filter_by_user(audit): @pytest.mark.asyncio +async def test_pagination_newest_first(audit): + for i in range(5): + await audit.log_event(user_id="u1", event="connect", detail=f"e{i}") + # distinct timestamps so ORDER BY is deterministic + await audit._db.execute( + "UPDATE audit_log SET timestamp = ? WHERE detail = ?", + (1000 + i, f"e{i}")) + await audit._db.commit() + + page1 = await audit.get_entries(limit=2, offset=0) + page2 = await audit.get_entries(limit=2, offset=2) + page3 = await audit.get_entries(limit=2, offset=4) + + assert [e.detail for e in page1] == ["e4", "e3"] # newest first + assert [e.detail for e in page2] == ["e2", "e1"] + assert [e.detail for e in page3] == ["e0"] # last, partial page + # offset past the end is empty, not an error + assert await audit.get_entries(limit=2, offset=99) == [] + + +@pytest.mark.asyncio async def test_entry_count(audit): assert await audit.entry_count() == 0 await audit.log_event(user_id="u1", event="connect") diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index d912b43..b676546 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -24,7 +24,6 @@ from meshbay_node import daemon as daemon_mod # would otherwise stop for a confirmation nobody can type in a test. VERBS = [ ["status"], - ["ui"], ["group", "list"], ["group", "add"], # missing --dir: usage, then exit ["gek", "init"], @@ -135,6 +134,11 @@ def test_the_verb_list_here_matches_the_parser(): f"CLI verbs with no dispatch test: {sorted(untested)} — add them to " f"VERBS above") + # The server-rendered admin UI (and its `ui` verb) were removed in + # docs/refactor-node-ui.md phase 5. The control API stays; the browser + # page does not. + assert "ui" not in declared, "the `ui` verb came back" + @pytest.mark.parametrize("argv,verb", [ (["reload"], "reload"), diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 725b800..10182d3 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -650,56 +650,34 @@ def test_node_admin_ui_requires_token(): assert client.get("/api/status").status_code == 403 assert client.get("/api/status?t=wrong").status_code == 403 - assert client.get("/api/config?t=wrong").status_code == 403 + assert client.get("/api/groups?t=wrong").status_code == 403 assert client.get("/api/status?t=secret-token").status_code == 200 assert client.get( "/api/status", headers={"X-MeshBay-Token": "secret-token"} ).status_code == 200 -def test_admin_ui_escapes_filenames(tmp_path): +def test_node_control_api_serves_no_html(): """ - H2: filenames are chosen by any group member and were rendered into the - localhost admin UI unescaped, giving script execution against an - unauthenticated admin API. - """ - from meshbay_node.ui.app import _render_page - - payload = '<img src=x onerror="fetch(1)">' - index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) - index.add_entry(IndexEntry( - id="0" * 64, name=payload, path="", size=1, type="video", added_at=0, - )) - - html = _render_page({ - "status": "running", - "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(tmp_path)}}, - "indexes": {"g" * 32: index}, - }) - - assert payload not in html, "filename rendered unescaped — stored XSS (H2)" - assert "<img" in html, "filename should appear escaped" + H2 was stored XSS in the server-rendered admin dashboard: a member-chosen + filename, or a hub-supplied username, landed in an HTML page on the + operator's machine unescaped. That dashboard is gone + (docs/refactor-node-ui.md phase 5) — the control API is JSON only, so there + is no server-side template to inject into. The Node page that replaced it + ships in the desktop client and escapes by default (Preact). - -def test_admin_ui_escapes_roster_usernames(tmp_path): - """ - H2 again, for the roster: usernames originate at the hub and land on the - operator's own admin page, which can re-key groups and read the audit log. + This locks the removal in: the HTML routes stay 404, and the render helpers + stay deleted so nothing reintroduces a template by importing one. """ - from meshbay_node.ui.app import _render_page + import meshbay_node.ui.app as ui_app + from fastapi.testclient import TestClient - payload = '<img src=x onerror="fetch(1)">' - html = _render_page( - {"status": "running", "groups_ctx": {}, "indexes": {}}, - { - "identities": {"u1": {"user_id": "u1", "username": payload, - "pk_ed25519": "AAA", "pinned_at": "now", - "pinned_via": "code"}}, - "members": [{"group_id": "", "user_id": "u1", "role": "operator", - "status": "active"}], - "invites": [], - }, - ) + app = ui_app.create_ui_app({"status": "running", "groups_ctx": {}, + "indexes": {}, "ui_token": "t"}) + client = TestClient(app) + for path in ("/", "/audit", "/dashboard"): + assert client.get(f"{path}?t=t").status_code == 404, path - assert payload not in html, "username rendered unescaped — stored XSS (H2)" - assert "<img" in html + for gone in ("_render_page", "_render_audit_page", "_render_roster", + "_AUDIT_HTML"): + assert not hasattr(ui_app, gone), f"{gone} came back — HTML surface" diff --git a/packaging/build/build-node.sh b/packaging/build/build-node.sh index d8a5af4..9ad4968 100755 --- a/packaging/build/build-node.sh +++ b/packaging/build/build-node.sh @@ -75,13 +75,10 @@ cp "$REPO/packaging/systemd/meshbay-node.service" \ cp "$REPO/packaging/systemd/meshbay-node-user.service" \ "$ROOT/usr/lib/systemd/user/meshbay-node.service" -# --- Firewall profiles ---------------------------------------------------- -# firewalld (Fedora) -mkdir -p "$ROOT/usr/lib/firewalld/services" -cp "$REPO/packaging/firewall/firewalld/meshbay-node.xml" \ - "$ROOT/usr/lib/firewalld/services/" - -# UFW (Ubuntu/Debian) +# --- Firewall profile --------------------------------------------------------- +# UFW (Ubuntu/Debian) — carries the "MeshBay Cast" LAN-casting profile only. +# The node's own admin surface is a loopback API (127.0.0.1, token-gated) and +# is never firewall-exposed, so it ships no profile. mkdir -p "$ROOT/etc/ufw/applications.d" cp "$REPO/packaging/firewall/ufw/meshbay" \ "$ROOT/etc/ufw/applications.d/" diff --git a/packaging/deb/meshbay-node/DEBIAN/control b/packaging/deb/meshbay-node/DEBIAN/control index 0c9d73d..76b2005 100644 --- a/packaging/deb/meshbay-node/DEBIAN/control +++ b/packaging/deb/meshbay-node/DEBIAN/control @@ -10,7 +10,8 @@ Recommends: ffmpeg Description: MeshBay Node — local file host, streaming server, and group daemon MeshBay Node indexes local directories and serves encrypted files to authenticated group members over WebRTC. Includes video streaming (fMP4 - remux via ffmpeg), group chat, and a local admin UI on localhost:18000. + remux via ffmpeg), group chat, and a token-gated loopback control API + on 127.0.0.1:18000 for the CLI and the desktop client. . Installs node code into the shared venv at /opt/meshbay-common/venv/. Ships a default TMDB API token for the Videos app. diff --git a/packaging/firewall/firewalld/meshbay-node.xml b/packaging/firewall/firewalld/meshbay-node.xml deleted file mode 100644 index 3443b54..0000000 --- a/packaging/firewall/firewalld/meshbay-node.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<service> - <short>MeshBay Node</short> - <description>MeshBay Node local administration interface (localhost only by default).</description> - <port protocol="tcp" port="18000"/> -</service> diff --git a/packaging/firewall/ufw/meshbay b/packaging/firewall/ufw/meshbay index 5a610a5..732e5d8 100644 --- a/packaging/firewall/ufw/meshbay +++ b/packaging/firewall/ufw/meshbay @@ -2,8 +2,3 @@ title=MeshBay Chromecast relay description=HTTP relay for casting decrypted video to LAN devices ports=19550:19553/tcp|5353/udp - -[MeshBay Node] -title=MeshBay Node admin UI -description=Local administration interface (localhost only by default) -ports=18000/tcp diff --git a/packaging/rpm/meshbay-node.spec b/packaging/rpm/meshbay-node.spec index 20d69bc..2bebb26 100644 --- a/packaging/rpm/meshbay-node.spec +++ b/packaging/rpm/meshbay-node.spec @@ -14,7 +14,8 @@ Recommends: ffmpeg %description MeshBay Node indexes local directories and serves encrypted files to authenticated group members over WebRTC. Includes video streaming (fMP4 -remux via ffmpeg), group chat, and a local admin UI on localhost:18000. +remux via ffmpeg), group chat, and a token-gated loopback control API +on 127.0.0.1:18000 for the CLI and the desktop client. Installs node code into the shared venv at /opt/meshbay-common/venv/. Ships a default TMDB API token for the Videos app. @@ -41,7 +42,6 @@ fi /usr/bin/meshbay-node %{_unitdir}/meshbay-node@.service %{_userunitdir}/meshbay-node.service -/usr/lib/firewalld/services/meshbay-node.xml /etc/ufw/applications.d/meshbay %changelog |