aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops/settings.py
blob: eade6ca6f0613316463ff44e077f02ec9d9455df (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
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"}