summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops/settings.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops/settings.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/settings.py340
1 files changed, 340 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops/settings.py b/packages/meshbay-node/src/meshbay_node/ops/settings.py
new file mode 100644
index 0000000..eade6ca
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/ops/settings.py
@@ -0,0 +1,340 @@
+"""Node-wide settings: denylist, node.toml values, transfer caps, scan timing, reload."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from meshbay_common.background import spawn
+
+from meshbay_node.config import DEFAULT_CONFIG_PATH
+from meshbay_node.ops.core import OpError, _config, _group_ctx, _roster
+from meshbay_node.ops.node_toml import _update_node_toml
+from meshbay_node.roster import Roster
+
+log = logging.getLogger("meshbay_node.ops")
+
+
+# ── Revocation denylist ──────────────────────────────────────────────────────
+
+async def read_denylist(state: dict) -> dict:
+ """Milestone 14.10 — what the node is currently refusing."""
+ denylist = state.get("denylist")
+ if not denylist:
+ return {"users": [], "groups": [], "jtis": [], "count": 0}
+ entries = denylist.entries()
+ return {**entries, "count": sum(len(v) for v in entries.values())}
+
+
+async def clear_denylist(state: dict, *, subject: str = "") -> dict:
+ """
+ Drop denylist entries — all of them, or one identifier.
+
+ Deliberately not silent: a cleared denylist re-admits whoever it was keeping
+ out, and the count is what tells the operator whether they undid one
+ revocation or all of them.
+ """
+ denylist = state.get("denylist")
+ if not denylist:
+ raise OpError("No denylist in this process", status=503)
+ removed = denylist.clear(subject)
+ log.warning("Denylist cleared (%s): %d entr(y/ies) removed",
+ subject or "all", removed)
+ return {"status": "cleared", "removed": removed, "subject": subject or "all"}
+
+
+# ── Node settings ────────────────────────────────────────────────────────────
+
+# What `set_node_settings` accepts, and how each value is validated. A module
+# constant so a test can hold its key set against `Roster.node_setting_keys()`:
+# this is the third list of the same settings, and the first two had already
+# drifted apart once — the reader's defaults covered fewer settings than the
+# resolver answered for, which is how node.toml's transfer pools came to be
+# parsed and then ignored. The kinds here are the *writer's* validation and
+# deliberately not the resolver's coercions.
+NODE_SETTING_WRITERS: dict[str, tuple[str, str]] = {
+ "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),
+ "max_concurrent_downloads": ("int", Roster.SETTING_MAX_DOWNLOADS),
+ "max_concurrent_uploads": ("int", Roster.SETTING_MAX_UPLOADS),
+ "max_upload_gb": ("size", Roster.SETTING_MAX_UPLOAD_GB),
+ "transcode_incompatible_video": ("bool", Roster.SETTING_TRANSCODE),
+ "stun_servers": ("stun_list", Roster.SETTING_STUN_SERVERS),
+ "ice_interfaces": ("list", Roster.SETTING_ICE_INTERFACES),
+}
+
+
+async def get_node_settings(state: dict) -> dict:
+ """Return current effective node settings."""
+ from meshbay_node.config import node_settings_defaults
+ roster = _roster(state)
+ config = _config(state)
+ defaults = node_settings_defaults(config.node)
+ if roster:
+ return await roster.node_settings(defaults)
+ return defaults
+
+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 = NODE_SETTING_WRITERS
+
+ 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 == "size":
+ # A quantity, not a count: half a gigabyte is a legitimate ceiling
+ # on a small disk, so this one is not run through the `int` branch
+ # above, whose floor of 1 would round it to "refuse everything".
+ # bool before float, as config.py does it: `true` is not 1 GB.
+ if isinstance(value, bool):
+ raise OpError(f"{key} must be a number")
+ try:
+ fv = float(value)
+ except (TypeError, ValueError):
+ raise OpError(f"{key} must be a number")
+ if fv <= 0:
+ raise OpError(f"{key} must be greater than zero")
+ setattr(nd, key, fv)
+ await roster.set_node_setting(setting_key, repr(fv), set_by)
+ updated[key] = fv
+ 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
+ elif kind in ("list", "stun_list"):
+ import json as _json
+ if not isinstance(value, list):
+ raise OpError(f"{key} must be a list")
+ v = [str(s) for s in value]
+ if kind == "stun_list":
+ for s in v:
+ if not s.startswith("stun:"):
+ raise OpError(f"Invalid STUN server: {s} (must start with stun:)")
+ setattr(nd, key, v)
+ await roster.set_node_setting(setting_key, _json.dumps(v), set_by)
+ updated[key] = v
+
+ if updated:
+ _update_node_toml(conf_path, updated)
+ if "max_concurrent_streams" in updated:
+ webrtc = state.get("webrtc")
+ # `webrtc._stream_sem` was assigned here for months. That attribute
+ # has never existed -- the pool is `ctx["_transcode_sem"]` -- so the
+ # `hasattr` guard was always False and the setting only ever took
+ # effect on a restart, which docs/MESHBAY_DESIGN.md §6.8 says it
+ # does not need.
+ if webrtc is not None:
+ webrtc.set_capacity(
+ max_concurrent_streams=updated["max_concurrent_streams"])
+ if ("max_concurrent_downloads" in updated
+ or "max_concurrent_uploads" in updated):
+ webrtc = state.get("webrtc")
+ if webrtc is not None:
+ webrtc.set_capacity(
+ max_concurrent_downloads=updated.get(
+ "max_concurrent_downloads"),
+ max_concurrent_uploads=updated.get(
+ "max_concurrent_uploads"))
+ if "max_upload_gb" in updated:
+ webrtc = state.get("webrtc")
+ if webrtc is not None:
+ webrtc.set_capacity(max_upload_gb=updated["max_upload_gb"])
+ if "stun_servers" in updated:
+ webrtc = state.get("webrtc")
+ if webrtc and hasattr(webrtc, '_stun'):
+ webrtc._stun = updated["stun_servers"]
+ from meshbay_node.transport.stun_multi import set_servers as _set_stun
+ _set_stun(updated["stun_servers"])
+ if "ice_interfaces" in updated:
+ from meshbay_node.transport.ice_filter import install as install_ice_filter
+ install_ice_filter(updated["ice_interfaces"] or None)
+
+ log.info("Node settings updated: %s", updated)
+ return {"updated": updated}
+
+
+# ── Transfers ────────────────────────────────────────────────────────────────
+
+async def set_transfer_limits(state: dict, group_id: str,
+ downloads: int, uploads: int) -> dict:
+ """How many transfers one member may run at once in this group.
+
+ Same shape as every other operator setting: lives on the node (roster.db,
+ not the hub and not node.toml, for the reason change 5 gives — a hub that
+ decided this would have authority over someone else's machine), signed
+ (webrtc_server checks the caller's admin authority before this runs), and
+ live, so the pools are updated in place rather than at the next restart.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ limits = await roster.set_transfer_limits(
+ group_id, {"download": downloads, "upload": uploads},
+ set_by=state.get("node_user_id", ""))
+ ctx["transfer_limits"] = limits
+ webrtc = state.get("webrtc")
+ slots = getattr(webrtc, "_ctx", {}).get("_transfer_slots") if webrtc else None
+ granted = slots.set_group_limits(group_id, limits) if slots else []
+ # And **tell them**. The node-wide path (`WebRTCTransport.set_capacity`)
+ # does this and this one did not: the leases were granted in the pool and
+ # the peers waiting on them were never told, so a cap raised from 2 to 4
+ # left both transfers sitting at "waiting" until the client's own watchdog
+ # re-asked a minute later. That is §5.2's first row — "node granted a slot,
+ # the push was lost" — reached by writing the grant and forgetting the send,
+ # which is the same omission as the missing `touch()` one layer up.
+ for lease in granted:
+ webrtc._notify_granted(lease)
+ log.info("Transfer limits for group %s: %s (%d started at once)",
+ group_id[:8], limits, len(granted))
+ return {"group_id": group_id, "limits": limits,
+ "started": [x.tr for x in granted]}
+
+async def list_transfers(state: dict) -> dict:
+ """Live transfer leases and queue depth.
+
+ The operator's window into "is anything actually holding a slot". When
+ somebody reports a transfer stuck at waiting, this is the only thing that
+ says whether the node ever had them in a queue — the alternative is reading
+ a log for a line that, by definition, is not being printed.
+
+ Carries no filename and no path: a lease holds neither, and this is exactly
+ where it would be tempting to add one.
+ """
+ webrtc = state.get("webrtc")
+ ctx = getattr(webrtc, "_ctx", {}) if webrtc else {}
+ slots = ctx.get("_transfer_slots")
+ if slots is None:
+ from meshbay_node.transfers import DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_PER_MEMBER, KINDS
+ # No pool built means nothing has transferred since the daemon started,
+ # which is a real answer and not an error.
+ #
+ # The caps still have to be the operator's own. Reporting the module
+ # defaults here was worse than reporting nothing: `transfers set 2 2`
+ # answered "applied now", and `transfers show` immediately said 0/8 —
+ # a setting written, acknowledged and displayed wrong, which reads
+ # exactly like the hot-swap that did nothing for months. Found by
+ # running it, not by a test: the test asserted the defaults and so
+ # agreed with the bug.
+ return {"pools": {
+ k: {"in_use": 0,
+ "cap": int(ctx.get(f"max_concurrent_{k}s")
+ or DEFAULT_MAX_CONCURRENT),
+ "per_member": DEFAULT_MAX_PER_MEMBER,
+ "queued": 0}
+ for k in KINDS}, "leases": [], "groups": _group_limits(state)}
+ out = slots.snapshot()
+ out["groups"] = _group_limits(state)
+ return out
+
+
+def _group_limits(state: dict) -> list[dict]:
+ """Each group's per-member caps, as the operator set them.
+
+ Reported because `transfers show` used to print only the node's default and
+ an operator reading "2 per member" had no way to tell whether that was this
+ group's setting or the fallback — and no way to change it either, since the
+ signed op had no door but MNP. Both were the same bug wearing two faces.
+ """
+ from meshbay_node.transfers import DEFAULT_MAX_PER_MEMBER
+
+ config = state.get("config")
+ groups_ctx = state.get("groups_ctx") or {}
+ out = []
+ for group in (getattr(config, "groups", None) or []):
+ limits = (groups_ctx.get(group.id) or {}).get("transfer_limits") or {}
+ out.append({
+ "group_id": group.id,
+ "name": group.name,
+ "download": int(limits.get("download") or DEFAULT_MAX_PER_MEMBER),
+ "upload": int(limits.get("upload") or DEFAULT_MAX_PER_MEMBER),
+ "set": bool(limits),
+ })
+ return out
+
+
+# ── Scan settings ────────────────────────────────────────────────────────────
+
+async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: float,
+ debounce_secs: float) -> dict:
+ """
+ How often the indexer's reconciliation backstop runs, and how long a
+ changed file is left alone before being hashed (indexer.py
+ DirectoryIndexer). Persisted like set_enabled_apps —
+ but there is also a *live* DirectoryIndexer object to update, since it
+ reads these once at construction and runs its own background loop with
+ them rather than consulting groups_ctx on every use.
+ """
+ roster = _roster(state)
+ await roster.set_scan_settings(group_id, reconcile_interval_secs, debounce_secs,
+ set_by=state.get("node_user_id", ""))
+ indexer = state.get("indexers", {}).get(group_id)
+ if indexer:
+ indexer.reconcile_secs = reconcile_interval_secs
+ indexer.debounce_secs = debounce_secs
+ # Apply the new interval now rather than after whatever backoff had
+ # already stretched the wait to.
+ indexer.note_activity()
+ # Optional, unlike _group_ctx(): a group can be persisted here before it
+ # is hot-loaded (or in a test that only cares about the roster/indexer
+ # side), and that must not turn a successful write into a 404.
+ ctx = state.get("groups_ctx", {}).get(group_id)
+ if ctx is not None:
+ ctx["reconcile_interval_secs"] = reconcile_interval_secs
+ ctx["debounce_secs"] = debounce_secs
+ log.info("Scan settings for group %s: reconcile=%.0fs debounce=%.0fs",
+ group_id[:8], reconcile_interval_secs, debounce_secs)
+ return {"reconcile_interval_secs": reconcile_interval_secs,
+ "debounce_secs": debounce_secs, "group_id": group_id}
+
+
+# ── Reload ──────────────────────────────────────────────────────────────────
+
+async def reload_config(state: dict) -> dict:
+ """Hot-reload node.toml without dropping connections. Blocks until the
+ reload actually finishes — see start_reload for why the loopback route
+ uses that instead."""
+ reload_fn = state.get("reload_fn")
+ if not reload_fn:
+ raise OpError("Reload not available", status=503)
+ await reload_fn()
+ return {"status": "reloaded"}
+
+
+async def start_reload(state: dict) -> dict:
+ """
+ Same as reload_config, but does not wait for the reload to finish.
+
+ The loopback route uses this one: the Electron bridge caps every call at
+ a fixed 30s (main.js node:call), and hot-loading a brand-new group runs
+ its full initial scan synchronously inside _reload_config_inner()
+ (daemon.py) before that coroutine returns — minutes, not seconds, on a
+ real library (found against a 45 GB group on the same slow disk the
+ StarWars benchmark used). The reload keeps running on the daemon's own
+ event loop either way; add_root/remove_root below already fire it the
+ same way for exactly this reason.
+ """
+ reload_fn = state.get("reload_fn")
+ if not reload_fn:
+ raise OpError("Reload not available", status=503)
+ spawn(reload_fn())
+ return {"status": "reloading"}