diff options
20 files changed, 522 insertions, 1 deletions
diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md index a5fb8d3..c1ce3fc 100644 --- a/docs/meshbay-draft-v6.md +++ b/docs/meshbay-draft-v6.md @@ -53,6 +53,7 @@ | 11 | Hub role | The hub gains a **runtime instance-policy store** (`hub_settings`). First policy: an admin switches **public groups off** hub-wide, enforced server-side on every hub-mediated path. `suspend` vs `revoke` on a group are now written down as the distinct things they are | §2.8 | | 12 | Group registry | A group name is **unique per owner account**, not globally; the group's identity is still its UUID. Listed everywhere as `name@owner` | §2.9 | | 13 | Enrichment | **Chat link previews** — the node unfurls a pasted URL into an OpenGraph card. A new instance of the §2.7 "node on demand, asking device caches, nothing durable" rule; new SSRF surface, gated. MNP 0.12 | §2.10 | +| 14 | Node page | The Node page (D5) **exposes five `[node]` settings** — TTLs for invitations, pairing and device requests, the concurrent stream cap, and the transcode toggle. Editable from the panel, persisted in both `roster.db` and `node.toml` | §2.11 | --- @@ -362,6 +363,35 @@ pinning the checked IP — deferred. **MNP 0.12** — `link_preview_req` / `link_preview_resp`, additive: an older node logs "unknown type" and the client shows the bare link. +### 2.11 Node page — `[node]` settings exposed to the operator + +New. The Node page (D5) connects over MNP and shows groups, roots and the roster; +it does not show the daemon's own tuning. Five `[node]` settings from `node.toml` +are worth surfacing there — they affect what the node does, not only how it starts, +and their current value is invisible until something goes wrong (an invitation that +expired silently, a viewer told "server busy" with no knob to turn). + +| Setting | Type | Default | What it controls | +|---|---|---|---| +| `invite_ttl_hours` | positive int | 168 (7 days) | How long a member invitation code remains valid. An invitation travels through a human conversation and may be read days later; too short and the recipient finds a dead code with no message telling them what happened | +| `pair_ttl_hours` | positive int | 24 | How long an operator pairing code remains valid. Shorter than an invitation because the code is typed during the SSH session that printed it | +| `device_request_ttl_minutes` | positive int | 60 | How long a device-add request may wait for approval from an existing device. Comfort rather than security: the code is bound to the requesting keys by its hash, so a longer window widens nothing an attacker can use | +| `max_concurrent_streams` | positive int | 8 | Maximum simultaneous video streams. One ffmpeg process runs per viewer; it remuxes rather than re-encodes so CPU cost is low, but each uses about 50 MB of memory. When reached, new viewers are told the server is busy. Override: `MESHBAY_MAX_CONCURRENT_STREAMS`. Zero or negative values are ignored with a warning | +| `transcode_incompatible_video` | bool | true | When true, HEVC and other browser-incompatible video codecs are transcoded to H.264 during streaming so browsers can play them. Unlike remuxing, this costs real CPU per concurrent viewer. Set to false only when all viewers are known to decode the source codec natively | + +**Editable from the panel, persisted in both `roster.db` and `node.toml`.** The panel +writes to both stores simultaneously: `roster.db` for immediate effect (no restart needed), +`node.toml` so the value survives a DB wipe or a fresh install. On startup, `node.toml` is +read as today; if `roster.db` holds an override for a key, it takes precedence. A manual +`node.toml` edit plus a reload overwrites the roster value. The TOML write is a targeted +line replacement (the same technique `ops.py` uses for roots), not a round-trip through a +writer — comments and formatting survive. + +**Wire shape.** `node_status_ack` gains a `settings` object carrying the five values above +(effective, i.e. roster override if present, config default otherwise). `node_settings_set` +writes changes and returns `node_settings_set_ack` with the updated keys. Both are +operator-only. + --- ## 3. Filesystem portability as a security property diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index a3fb4de..ff951e7 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -166,6 +166,8 @@ class MNP: GROUP_ATTACH_ACK = "group_attach_ack" GROUP_DETACH = "group_detach" # operator → node: stop hosting a group GROUP_DETACH_ACK = "group_detach_ack" + NODE_SETTINGS_SET = "node_settings_set" # operator → node: change daemon settings + NODE_SETTINGS_SET_ACK = "node_settings_set_ack" NODE_RELOAD = "node_reload" # operator → node: re-read node.toml NODE_RELOAD_ACK = "node_reload_ack" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 4aab150..c31653a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -2373,6 +2373,9 @@ function NodePage({ token, username, userId, groups }) { const [rosterGroup, setRosterGroup] = useState(''); const [denylist, setDenylist] = useState(null); const [showDenylist, setShowDenylist] = useState(false); + const [nodeSettings, setNodeSettings] = useState(null); + const [editSettings, setEditSettings] = useState(null); + const [savingSettings, setSavingSettings] = useState(false); const [attachGroup_, setAttachGroup_] = useState(''); const [attachDir, setAttachDir] = useState(''); const [attachUpload, setAttachUpload] = useState(''); @@ -2434,6 +2437,7 @@ function NodePage({ token, username, userId, groups }) { transportRef.current = transport; setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); + setNodeSettings(result.settings || null); setStatus('connected'); return; } catch (err) { @@ -2460,6 +2464,10 @@ function NodePage({ token, username, userId, groups }) { }; }, [connectAndFetch]); + useEffect(() => { + if (nodeSettings && !editSettings) setEditSettings({ ...nodeSettings }); + }, [nodeSettings]); + const refresh = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -2467,6 +2475,7 @@ function NodePage({ token, username, userId, groups }) { const result = await transport.fetchNodeStatus(); setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); + setNodeSettings(result.settings || null); } catch {} }, []); @@ -2673,6 +2682,22 @@ function NodePage({ token, username, userId, groups }) { } }, [refresh]); + const saveSettings = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected || !editSettings) return; + setSavingSettings(true); + setActionMsg(''); + try { + await transport.updateNodeSettings(editSettings); + setNodeSettings({ ...editSettings }); + setActionMsg(t('node.settings_saved')); + } catch (err) { + setActionMsg(err.message); + } finally { + setSavingSettings(false); + } + }, [editSettings]); + if (status === 'idle' || status === 'connecting') { return html`<div class="page-content"> <h2>${t('node.title')}</h2> @@ -2888,6 +2913,58 @@ function NodePage({ token, username, userId, groups }) { `} </div> + ${editSettings && html` + <div class="node-group"> + <span class="settings-heading">${t('node.settings')}</span> + <p class="node-hint">${t('node.settings_edit_hint')}</p> + <div class="node-settings-grid"> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_invite_ttl')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.invite_ttl_hours} + onInput=${e => setEditSettings(s => ({...s, invite_ttl_hours: parseInt(e.target.value) || 1}))} /> + <span class="node-setting-unit">${t('node.setting_unit_hours')}</span> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_pair_ttl')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.pair_ttl_hours} + onInput=${e => setEditSettings(s => ({...s, pair_ttl_hours: parseInt(e.target.value) || 1}))} /> + <span class="node-setting-unit">${t('node.setting_unit_hours')}</span> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_device_ttl')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.device_request_ttl_minutes} + onInput=${e => setEditSettings(s => ({...s, device_request_ttl_minutes: parseInt(e.target.value) || 1}))} /> + <span class="node-setting-unit">${t('node.setting_unit_minutes')}</span> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_max_streams')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.max_concurrent_streams} + onInput=${e => setEditSettings(s => ({...s, max_concurrent_streams: parseInt(e.target.value) || 1}))} /> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_transcode')}</span> + <div class="node-setting-input"> + <input type="checkbox" checked=${editSettings.transcode_incompatible_video} + onChange=${e => setEditSettings(s => ({...s, transcode_incompatible_video: e.target.checked}))} /> + </div> + </label> + </div> + <div class="node-settings-actions"> + <button class="btn btn-primary btn-small" disabled=${savingSettings || busy} + onClick=${saveSettings}> + ${savingSettings ? t('node.settings_saving') : t('node.settings_save')}</button> + </div> + </div> + `} + ${/* In Electron, the Create Group wizard handles attaching. Keep for browser users who manage nodes via MNP. */ !platform.node.available && (() => { 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 7017736..d1cbf2b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -608,6 +608,20 @@ export default { 'node.restart_needed': 'Nach dem Hinzufügen: Gruppenschlüssel initialisieren (meshbay-node gek-init --group <name>).', 'node.no_groups': 'Keine Gruppen auf diesem Node konfiguriert.', 'node.stale': 'nicht auf dem Hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'nicht verfügbar', 'node.files': { one: '1 Datei', other: '{n} Dateien' }, 'node.peers': { one: '1 Peer', other: '{n} Peers' }, 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 50f9ac5..6ddfa91 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -709,4 +709,18 @@ export default { 'node.detach_confirm': 'Remove "{name}" from this node?', 'node.detached': 'Group removed.', 'node.stale': 'not on hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', }; 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 2aa4599..c942869 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -604,6 +604,20 @@ export default { 'node.restart_needed': 'Después de añadir: inicialice la clave de grupo (meshbay-node gek-init --group <nombre>).', 'node.no_groups': 'No hay grupos configurados en este node.', 'node.stale': 'no está en el hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'no disponible', 'node.files': { one: '1 archivo', other: '{n} archivos' }, 'node.peers': { one: '1 par', other: '{n} pares' }, 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 4750349..8eefb29 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -608,6 +608,20 @@ export default { 'node.restart_needed': 'Après l\'ajout : initialisez la clé de groupe (meshbay-node gek-init --group <name>).', 'node.no_groups': 'Aucun groupe configuré sur ce node.', 'node.stale': 'absent du hub', + 'node.settings': 'Paramètres du node', + 'node.settings_edit_hint': 'Les modifications sont appliquées immédiatement et sauvegardées dans node.toml.', + 'node.settings_save': 'Enregistrer', + 'node.settings_saving': 'Enregistrement…', + 'node.settings_saved': 'Paramètres enregistrés.', + 'node.setting_invite_ttl': 'Durée des invitations', + 'node.setting_pair_ttl': 'Durée du code d\'appairage', + 'node.setting_device_ttl': 'Durée des demandes d\'appareil', + 'node.setting_max_streams': 'Flux vidéo simultanés max', + 'node.setting_transcode': 'Transcoder les vidéos incompatibles', + 'node.setting_unit_hours': 'heures', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'Activé', + 'node.setting_off': 'Désactivé', 'node.unavailable': 'indisponible', 'node.files': { one: '1 fichier', 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 12e0c77..4ee261c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -606,6 +606,20 @@ export default { 'node.restart_needed': "Dopo l'aggiunta: inizializzi la chiave di gruppo (meshbay-node gek-init --group <nome>).", 'node.no_groups': 'Nessun gruppo configurato su questo node.', 'node.stale': 'non presente sul hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'non disponibile', 'node.files': { one: '1 file', 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 7f4a0c2..e30f5bf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -594,6 +594,20 @@ export default { 'node.restart_needed': '追加後、グループ鍵を初期化してください(meshbay-node gek-init --group <name>)。', 'node.no_groups': 'この node にはグループが設定されていません。', 'node.stale': 'hub に未登録', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': '利用不可', 'node.files': { other: '{n} 件のファイル', 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 5ea6371..2f66e00 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -608,6 +608,20 @@ export default { 'node.restart_needed': 'Na het toevoegen: initialiseer de groepssleutel (meshbay-node gek-init --group <naam>).', 'node.no_groups': 'Geen groepen geconfigureerd op deze node.', 'node.stale': 'niet op hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'niet beschikbaar', 'node.files': { one: '1 bestand', 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 6aa6cd1..f809182 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -627,6 +627,20 @@ export default { 'node.restart_needed': 'Po dodaniu: zainicjalizuj klucz grupy (meshbay-node gek-init --group <nazwa>).', 'node.no_groups': 'Na tym node nie skonfigurowano żadnych grup.', 'node.stale': 'brak na hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'niedostępny', 'node.files': { one: '{n} plik', 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 2cff7a6..2e1a9f5 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 @@ -605,6 +605,20 @@ export default { 'node.restart_needed': 'Após adicionar: inicialize a chave do grupo (meshbay-node gek-init --group <nome>).', 'node.no_groups': 'Nenhum grupo configurado neste node.', 'node.stale': 'fora do hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'indisponível', 'node.files': { one: '1 arquivo', other: '{n} arquivos' }, 'node.peers': { one: '1 par', other: '{n} pares' }, 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 704204d..1ec23eb 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 @@ -580,6 +580,20 @@ export default { 'node.restart_needed': '添加后请初始化群组密钥(meshbay-node gek-init --group <名称>)。', 'node.no_groups': '此 node 上未配置任何群组。', 'node.stale': '不在 hub 上', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': '不可用', 'node.files': { other: '{n} 个文件', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2a2bd8a..739f9c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2660,6 +2660,56 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } } .node-attach-dir-row input { flex: 1; } +.node-settings-grid { + display: flex; + flex-direction: column; + gap: 0; + margin-top: 8px; +} +.node-setting { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border); + cursor: default; +} +.node-setting-label { + font-size: 0.85em; + color: var(--text-secondary); +} +.node-setting-value { + font-size: 0.85em; + font-weight: 500; +} +.node-setting-input { + display: flex; + align-items: center; + gap: 6px; +} +.node-setting-input input[type="number"] { + width: 80px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-base); + color: var(--text); + font-size: 0.85em; + text-align: right; +} +.node-setting-input input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); +} +.node-setting-unit { + font-size: 0.8em; + color: var(--text-secondary); +} +.node-settings-actions { + margin-top: 12px; +} + /* ── Create Group Wizard ──────────────────────────────────────────────────── */ .wizard-root { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 5a6e36b..b60bd93 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -1310,6 +1310,14 @@ class MeshBayTransport { return msg; } + async updateNodeSettings(settings) { + const msg = await this._sendAndWait({ + type: 'node_settings_set', v: '0.1', settings, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + async addRoot(groupId, path, { name, kind, upload } = {}, signFn) { const msg = await this._sendAndWait({ type: 'root_add', v: '0.1', diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 266693a..e1e4dee 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -267,6 +267,20 @@ class NodeDaemon: await self._roster.open() await self._roster.purge_expired() + # Apply any roster overrides to node config (panel-edited values + # take precedence over node.toml defaults). + nd = self._config.node + defaults = { + "invite_ttl_hours": nd.invite_ttl_hours, + "pair_ttl_hours": nd.pair_ttl_hours, + "device_request_ttl_minutes": nd.device_request_ttl_minutes, + "max_concurrent_streams": nd.max_concurrent_streams, + "transcode_incompatible_video": nd.transcode_incompatible_video, + } + effective = await self._roster.node_settings(defaults) + for k, v in effective.items(): + setattr(nd, k, v) + # X25519 key material for GEK unwrapping from cryptography.hazmat.primitives import serialization sk_x_raw = keys.sk_x25519.private_bytes( diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index c921b05..d1314f0 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -332,7 +332,19 @@ async def list_groups(state: dict) -> dict: members = await roster.list_members() has_operator = any(m["role"] == "operator" and m["status"] == "active" for m in members) - return {"groups": out, "operator_paired": has_operator} + nd = config.node if config else None + defaults = { + "invite_ttl_hours": nd.invite_ttl_hours if nd else 168, + "pair_ttl_hours": nd.pair_ttl_hours if nd else 24, + "device_request_ttl_minutes": nd.device_request_ttl_minutes if nd else 60, + "max_concurrent_streams": nd.max_concurrent_streams if nd else 8, + "transcode_incompatible_video": nd.transcode_incompatible_video if nd else True, + } + if roster: + settings = await roster.node_settings(defaults) + else: + settings = defaults + return {"groups": out, "operator_paired": has_operator, "settings": settings} async def attach_group(state: dict, name: str, shared_dir: str, @@ -472,6 +484,57 @@ def _find_group_range(lines: list[str], group_id: str) -> tuple[int, int] | None return None +def _update_node_toml(conf_path: Path, updates: dict) -> None: + """Write changed [node] settings back to node.toml without disturbing comments. + + For each key, if the line exists (commented or not) it is replaced in place; + otherwise the key is appended to the end of the [node] section. + """ + if not conf_path.exists(): + return + text = conf_path.read_text() + lines = text.split("\n") + + node_start = None + node_end = len(lines) + for i, line in enumerate(lines): + stripped = line.strip() + if stripped == "[node]": + node_start = i + elif node_start is not None and re.match(r'^\[', stripped): + node_end = i + break + + if node_start is None: + lines.append("") + lines.append("[node]") + node_start = len(lines) - 1 + node_end = len(lines) + + remaining = dict(updates) + for i in range(node_start + 1, node_end): + for key in list(remaining): + pattern = re.compile( + r'^(\s*#?\s*)' + re.escape(key) + r'\s*=\s*.*$') + if pattern.match(lines[i]): + value = remaining.pop(key) + if isinstance(value, bool): + lines[i] = f"{key} = {'true' if value else 'false'}" + else: + lines[i] = f"{key} = {value}" + break + + for key, value in remaining.items(): + if isinstance(value, bool): + insert_line = f"{key} = {'true' if value else 'false'}" + else: + insert_line = f"{key} = {value}" + lines.insert(node_end, insert_line) + node_end += 1 + + conf_path.write_text("\n".join(lines)) + + def _insert_roots_block(conf_path: Path, group_id: str, root_block: str) -> None: """Append a [[groups.roots]] block inside the matching [[groups]] section.""" @@ -718,6 +781,56 @@ async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict: return {"allowed": allowed, "group_id": group_id} +# ── Node settings ──────────────────────────────────────────────────────────── + +async def set_node_settings(state: dict, settings: dict) -> dict: + """Update node-level daemon settings. Writes to both roster.db and node.toml.""" + roster = _roster(state) + config = _config(state) + nd = config.node + conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) + + allowed_keys = { + "invite_ttl_hours": ("int", roster.SETTING_INVITE_TTL), + "pair_ttl_hours": ("int", roster.SETTING_PAIR_TTL), + "device_request_ttl_minutes": ("int", roster.SETTING_DEVICE_TTL), + "max_concurrent_streams": ("int", roster.SETTING_MAX_STREAMS), + "transcode_incompatible_video": ("bool", roster.SETTING_TRANSCODE), + } + + set_by = state.get("node_user_id", "") + updated = {} + for key, value in settings.items(): + if key not in allowed_keys: + continue + kind, setting_key = allowed_keys[key] + if kind == "int": + try: + v = int(value) + except (TypeError, ValueError): + raise OpError(f"{key} must be an integer") + if v < 1: + raise OpError(f"{key} must be positive") + setattr(nd, key, v) + await roster.set_node_setting(setting_key, str(v), set_by) + updated[key] = v + elif kind == "bool": + v = bool(value) + setattr(nd, key, v) + await roster.set_node_setting(setting_key, "1" if v else "0", set_by) + updated[key] = v + + if updated: + _update_node_toml(conf_path, updated) + if "max_concurrent_streams" in updated: + webrtc = state.get("webrtc") + if webrtc and hasattr(webrtc, '_stream_sem'): + webrtc._stream_sem = asyncio.Semaphore(updated["max_concurrent_streams"]) + + log.info("Node settings updated: %s", updated) + return {"updated": updated} + + # ── Applications ───────────────────────────────────────────────────────────── async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index ff5282d..cf0d421 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -754,6 +754,41 @@ class Roster: str(float(debounce_secs)), set_by) return await self.scan_settings(group_id) + # ── Node-wide daemon settings ─────────────────────────────────────────── + # Same pattern as TMDB config: stored under NODE_WIDE_GROUP_ID. + # On startup, node.toml values are the defaults; the roster override + # takes precedence at runtime. Changing a setting writes to both + # roster.db (immediate) and node.toml (survives a DB wipe). + SETTING_INVITE_TTL = "invite_ttl_hours" + SETTING_PAIR_TTL = "pair_ttl_hours" + SETTING_DEVICE_TTL = "device_request_ttl_minutes" + SETTING_MAX_STREAMS = "max_concurrent_streams" + SETTING_TRANSCODE = "transcode_incompatible_video" + + async def node_settings(self, defaults: dict) -> dict: + """Current effective settings: roster override if present, else config default.""" + result = {} + for key, setting in [ + ("invite_ttl_hours", self.SETTING_INVITE_TTL), + ("pair_ttl_hours", self.SETTING_PAIR_TTL), + ("device_request_ttl_minutes", self.SETTING_DEVICE_TTL), + ("max_concurrent_streams", self.SETTING_MAX_STREAMS), + ("transcode_incompatible_video", self.SETTING_TRANSCODE), + ]: + stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) + if stored is not None: + if key == "transcode_incompatible_video": + result[key] = stored != "0" + else: + result[key] = int(stored) + else: + result[key] = defaults.get(key) + return result + + async def set_node_setting(self, key: str, value: str, + set_by: str = "") -> None: + await self.set_setting(self.NODE_WIDE_GROUP_ID, key, value, set_by) + async def create_invite( self, group_id: str, diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 1bd7203..a934418 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -504,6 +504,8 @@ class WebRTCPeerSession: self._do_group_attach(msg) elif mtype == MNP.GROUP_DETACH: self._do_group_detach(msg) + elif mtype == MNP.NODE_SETTINGS_SET: + self._spawn(self._do_node_settings_set(msg)) elif mtype == MNP.NODE_RELOAD: self._spawn(self._do_node_reload(msg)) elif mtype == MNP.KEYPAIR_BUNDLE_STORE: @@ -2187,6 +2189,24 @@ class WebRTCPeerSession: log.error("node_status failed: %s", e, exc_info=True) self._send({"type": "error", "detail": "Internal error"}) + async def _do_node_settings_set(self, msg: dict) -> None: + if not self._is_node_admin(): + self._send({"type": "error", "detail": "Not the node operator"}) + return + settings = msg.get("settings", {}) + if not settings: + self._send({"type": "error", "detail": "No settings provided"}) + return + try: + result = await self._run_op(ops.set_node_settings, settings) + self._send({"type": MNP.NODE_SETTINGS_SET_ACK, "v": MNP_VERSION, + **result}) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + except Exception as e: + log.error("node_settings_set failed: %s", e, exc_info=True) + self._send({"type": "error", "detail": "Internal error"}) + async def _do_roster_read(self, msg: dict) -> None: if not self._is_node_admin(): self._send({"type": "error", "detail": "Not the node operator"}) diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 514a143..b25b36a 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -291,6 +291,13 @@ def create_ui_app(state: dict) -> FastAPI: "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, @@ -438,6 +445,12 @@ def create_ui_app(state: dict) -> FastAPI: # seconds, on a real library) — see ops.start_reload for why. return await _op(lambda: ops.start_reload(state)) + # ── Node settings (operator only, localhost) ─────────────────────────── + + @app.put("/api/node-settings") + async def update_node_settings(payload: dict): + return await _op(lambda: ops.set_node_settings(state, payload)) + # ── Chat endpoints ─────────────────────────────────────────────────────── _chat_subscribers: list[WebSocket] = [] @@ -521,6 +534,24 @@ def _fmt_size(n: int) -> str: 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. @@ -766,6 +797,7 @@ def _render_page(state: dict, roster_view: dict | None = None, <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> |