diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 14:08:32 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 14:08:32 +0200 |
| commit | cfc91e0a424163869c64d30e55d55a53f18a3dbf (patch) | |
| tree | 04ed3bbec11690f62f1e2a738bf89f4b763332e5 /packages | |
| parent | ba45a3c94806f612fa62812e0b36d08b581a2e47 (diff) | |
| download | meshbay-cfc91e0a424163869c64d30e55d55a53f18a3dbf.tar.gz | |
refactor(node): JSON-only control API, Node page absorbs the admin dashboard
Remove the node daemon's server-rendered admin UI (GET / and /audit, the
_render_* helpers and inline templates) and the `meshbay-node ui` CLI verb.
The loopback control API stays; it is now JSON only, ruff-clean, and 453
lines (was 1074). Also drop three never-wired endpoints (/api/config,
/api/chat/history, /ws/chat, plus broadcast_chat) and the pointless
18000/tcp firewall profiles.
The desktop client's Node page (static/node-page.js) takes over what the
dashboard showed, reorganised into six tabs (Overview, Groups, Roster,
Peers, Audit, Settings):
- Overview: version, node id, QUIC port, hub, index-cache maintenance
- Roster: node-wide view with unpin
- Peers and Audit: auto-load on open, no Load button
- Audit: real usernames and group names (resolved from the roster and
node.toml), Previous/Next pagination newest-first, Export CSV of every
matching row
- Settings: node settings, STUN, ICE, denylist, then Unlink from hub
Backend: audit.get_entries gains `offset`; /api/audit and /api/peers
resolve ids to names via a new _display_names helper; CSP tightened to
default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and
2.12 corrected -- the Node page uses the loopback API, not MNP.
One capability is intentionally dropped: browser-based admin on a headless
server. The CLI covers every operation there.
See docs/refactor-node-ui.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
Diffstat (limited to 'packages')
19 files changed, 1068 insertions, 774 deletions
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" |