aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/config.py
blob: 7be68a45a9a3e3d82d733b05ccfcdc9fb26943ce (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
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
"""
MeshBay Node configuration.

Config file: ~/.config/meshbay/node.toml
All values have sensible defaults and can be overridden by env vars
prefixed with MESHBAY_ (e.g. MESHBAY_HUB_URL).
"""

import logging
import os
from dataclasses import dataclass, field
from pathlib import Path

from meshbay_node.platform import config_dir, data_dir

try:
    import tomllib   # Python 3.11+
except ImportError:
    import tomli as tomllib   # type: ignore[no-redef]

log = logging.getLogger(__name__)

DEFAULT_STUN_SERVERS: list[str] = [
    "stun:stun.l.google.com:19302",
    "stun:stun1.l.google.com:19302",
    "stun:stun.cloudflare.com:3478",
    "stun:stun.services.mozilla.com:3478",
]

DEFAULT_CONFIG_PATH = config_dir() / "node.toml"

EXAMPLE_CONFIG = """\
# MeshBay Node configuration — multi-group example
# See: https://meshbay.org/docs/node-config

[hub]
url      = "https://meshbay.org"
username = "myusername"

[node]
# QUIC (MNP) direct path — LAN, port-forwarded, hub-less. Off by default: no
# client speaks QUIC yet, so leaving it on only opens a UDP port.
quic_enabled = false
quic_port = 19010
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.
invite_ttl_hours = 168   # 7 days
pair_ttl_hours   = 24
# How long a new device may wait for one of your existing devices to approve it.
device_request_ttl_minutes = 60

# How many people may watch a video at once. One ffmpeg runs per viewer for as
# long as they watch — it remuxes rather than re-encodes, so it costs little CPU
# and around 50 MB of memory, and spends most of the film idle. Past this, a
# viewer is told the server is busy. Raise it on a machine with memory to spare;
# lower it on a Pi.
max_concurrent_streams = 8

# HEVC sources have no browser decoder on most platforms, so streaming one is
# transcoded to H264 rather than the usual free copy — real CPU per viewer.
# Set to false only if every viewer's client is known to decode HEVC itself.
transcode_incompatible_video = true

# ICE candidate gathering. By default, virtual/VPN interfaces (Tailscale,
# libvirt, Docker) are auto-excluded — a STUN request that can't reach the
# server holds the WebRTC answer for 5 seconds.  Set this to restrict
# gathering to specific interfaces (by OS adapter name).
# ice_interfaces = ["wlp0s20f3", "eth0"]

# STUN servers for WebRTC ICE candidate gathering (NAT traversal). By default
# four public servers are used; set this to override. Every server in the list
# is queried in parallel each gather and the first answer wins, so one slow or
# blocked server no longer stalls the WebRTC answer.
# stun_servers = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"]

# Browser and native clients reach this node over WebRTC DataChannel via hub
# signaling — no inbound port to open. QUIC is the optional direct path.

# Multiple groups — each with one or more named directories ("roots").
#
# A root's name is the directory's basename, and it becomes the first segment of
# every path members see: /home/user/Media appears to everyone as "Media/".
# Two roots cannot share a name (compared without regard to case), and no root
# may sit inside another. Exactly one root receives uploads.
[[groups]]
id          = ""    # set after joining
name        = "My Media"
quic_port   = 19010

  [[groups.roots]]
  path   = "/home/user/Media"
  upload = true

  [[groups.roots]]
  path = "/run/media/user/USB/Musique"   # an external drive is fine: if it is
  kind = "audio"                         # unplugged the root goes unavailable
                                         # and its files stay in the index,
                                         # rather than looking deleted

# upload_dir: a separate directory for uploads. Files land directly in it,
# not in an "uploads" subdirectory. It appears as its own root in the index.
# upload_dir = "/home/user/Incoming"

# The single-directory form still works and means the same thing — one root,
# named after the directory, receiving uploads.
[[groups]]
id          = ""
name        = "Public Archive"
shared_dir  = "/home/user/Archive"
quic_port   = 19012
visibility  = "public"       # discoverable on the hub
# join_policy = "open"       # anyone the hub says is a member gets the group key,
                             # with no pairing code. Only for groups where that is
                             # genuinely intended: it means the hub can join too.

[keystore]
# unlock_file = "~/.config/meshbay/unlock.key"
# or set MESHBAY_UNLOCK_KEY env var

# Operator authority is not configured here. Run `meshbay-node operator pair` and
# enter the code in your browser: the node pins that browser's key, and invites
# and file deletion are signed with it.
"""


@dataclass
class HubConfig:
    url:      str = "https://meshbay.org"
    username: str = ""


@dataclass
class NodeConfig:
    quic_port:  int = 19010
    # The QUIC MNP listener. Off by default: no shipping client speaks QUIC yet
    # (the browser and the desktop client use WebRTC; the hub-less `group://`
    # sidecar is unbuilt), so starting it only opens a UDP port with nothing to
    # reach it. Turn on for LAN / port-forwarded / hub-less direct access once a
    # client for it exists. `punch_nat()` is a direct-connection helper, not a
    # NAT-traversal stack — a peer behind NAT still needs the port forwarded.
    quic_enabled: bool = False
    ui_port:    int = 18000
    # How long a one-time code stays usable. Invitations travel through a human
    # conversation and are answered days later; operator pairing happens during
    # the SSH session that printed it.
    invite_ttl_hours: int = 168   # 7 days
    pair_ttl_hours:   int = 24
    # A device-add code is read off one screen and typed into another, in one
    # sitting. 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.
    device_request_ttl_minutes: int = 60
    # How many people may watch a video at the same time. One ffmpeg runs per
    # viewer for as long as they watch, so this is the knob that decides when
    # the node answers "server busy" — see MAX_CONCURRENT_TRANSCODES in
    # transport/webrtc_server.py for what one costs.
    max_concurrent_streams: int = 8
    # HEVC (and any future codec in media_probe.py's
    # BROWSER_INCOMPATIBLE_VIDEO_CODECS) has no decoder in most browsers, so
    # streaming it needs a real re-encode to H264 rather than the usual free
    # copy. On by default since the alternative is a hard "codec not
    # supported" error; set to false if this node's viewers are all clients
    # that already decode the source codec directly, since transcoding costs
    # real CPU per concurrent viewer, unlike the copy path.
    transcode_incompatible_video: bool = True
    # ICE candidate gathering: which network interfaces to include or exclude.
    # By default, virtual and VPN interfaces (Tailscale, libvirt, Docker) are
    # auto-excluded because a STUN request that can't reach the server holds
    # the gather for the full 5-second timeout — measured at 6 s total on a
    # machine with a Tailscale wt0 interface.
    ice_interfaces: list[str] = field(default_factory=list)  # include-list overrides auto
    stun_servers: list[str] = field(default_factory=list)  # empty = DEFAULT_STUN_SERVERS
    ffmpeg_path:  str = "ffmpeg"
    ffprobe_path: str = "ffprobe"


@dataclass
class RootSpec:
    """One named directory inside a group. See `meshbay_node/roots.py`."""
    path:   str = ""
    name:   str = ""          # empty → the directory's basename, derived at load
    kind:   str = "generic"   # generic|video|audio|photo — a view hint, unused for now
    upload: bool = False      # exactly one root per group receives uploads
    direct: bool = False      # uploads land at root path, not in a subdirectory


@dataclass
class GroupConfig:
    id:         str = ""
    name:       str = ""
    # A group's content is several named roots. `shared_dir` is the single-root
    # form and is still read: it becomes one root named after its basename, which
    # is why every path gained a segment. See roots.py for why there is no
    # unprefixed shape.
    roots:      list[RootSpec] = field(default_factory=list)
    shared_dir: str = ""      # legacy single-root form, migrated at load
    upload_dir: str = ""      # separate filesystem path for uploads
    visibility: str = "private"   # public|private — discoverability, not admission
    # Admission. "invite" (default) means a newcomer needs a one-time pairing code
    # before the node wraps the group key for them; "open" means the node pins
    # whoever turns up first (TOFU) and serves them.
    #
    # Deliberately read from THIS file and never from the hub: a hub that could
    # declare a group open would walk into any group it liked. Being findable
    # (`visibility`) and being open (`join_policy`) are different questions.
    join_policy: str = "invite"   # invite|open
    quic_port:  int = 19010       # QUIC MNP port

    def __post_init__(self) -> None:
        """
        The single-directory form becomes one root, whoever built this.

        On the dataclass rather than in the TOML reader, because a GroupConfig is
        also built by the CLI, by `group attach` and by tests. Putting the
        migration in the parser alone left every one of those paths with a group
        that had no directory at all — and it presented as "skipping group",
        which reads like configuration rather than a bug.
        """
        if not self.roots and self.shared_dir.strip():
            self.roots = [RootSpec(path=self.shared_dir.strip(), upload=True)]
        if self.upload_dir.strip():
            for r in self.roots:
                r.upload = False
            self.roots.append(RootSpec(
                path=self.upload_dir.strip(), upload=True, direct=True))


@dataclass
class KeystoreConfig:
    path:        Path = field(default_factory=lambda: DEFAULT_CONFIG_PATH.parent / "keystore.enc")
    unlock_file: Path | None = None


@dataclass
class Config:
    hub:      HubConfig      = field(default_factory=HubConfig)
    node:     NodeConfig     = field(default_factory=NodeConfig)
    groups:   list[GroupConfig] = field(default_factory=list)
    keystore: KeystoreConfig = field(default_factory=KeystoreConfig)
    data_dir: Path = field(default_factory=data_dir)

    # Back-compat: single-group access
    @property
    def group(self) -> GroupConfig:
        return self.groups[0] if self.groups else GroupConfig()


def _positive(value: object, default: int, name: str) -> int:
    """A count that must be at least one, or the default with a word about it.

    Zero is the dangerous one: `asyncio.Semaphore(0)` is not "no limit", it is
    a node where no video ever plays and nothing in the log says why.
    """
    # bool before int: TOML `true` is a bool, and `int(True)` is 1 — a node
    # where exactly one person may watch, arrived at by a typo and announced
    # nowhere.
    if isinstance(value, bool) or not isinstance(value, (int, str)):
        log.warning("%s = %r is not a count — using %d", name, value, default)
        return default
    try:
        n = int(value)
    except (TypeError, ValueError):
        log.warning("%s = %r is not a number — using %d", name, value, default)
        return default
    if n < 1:
        log.warning("%s = %d would stop the feature entirely — using %d",
                    name, n, default)
        return default
    return n


def _read_roots(group: dict) -> list[RootSpec]:
    """
    A group's roots, from `[[groups.roots]]` or from the legacy `shared_dir`.

    Both forms are accepted and `shared_dir` is not deprecated for a single
    directory — it is the same thing said shorter. Naming both is refused rather
    than merged: which one receives uploads would be a guess, and a wrong guess
    is discovered weeks later.
    """
    specs = [
        RootSpec(
            path=str(r.get("path", "")),
            name=str(r.get("name", "")),
            kind=str(r.get("kind", "generic")),
            upload=bool(r.get("upload", False)),
        )
        for r in group.get("roots", []) or []
    ]
    legacy = str(group.get("shared_dir", "") or "").strip()
    if specs and legacy:
        log.warning(
            "group %r declares both shared_dir and [[groups.roots]] — using "
            "roots and ignoring shared_dir = %s",
            group.get("name", ""), legacy)
    # A bare shared_dir needs no handling here: GroupConfig.__post_init__ turns
    # it into one root for every construction path, not just this one.
    return specs


def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
    """
    Load config from TOML file. Supports both single [group] and
    multiple [[groups]] sections. Env vars override file values.
    """
    cfg = Config()

    if path.exists():
        raw = tomllib.loads(path.read_text(encoding="utf-8"))

        hub = raw.get("hub", {})
        cfg.hub.url      = hub.get("url",      cfg.hub.url)
        cfg.hub.username = hub.get("username", cfg.hub.username)

        nd = raw.get("node", {})
        # `port` (TCP+TLS) and `http_port` no longer exist — both listeners were removed
        # in Phase 11.5 (findings C1, C6). Regenerate node.toml with `meshbay-node init`.
        cfg.node.quic_port = nd.get("quic_port", cfg.node.quic_port)
        cfg.node.quic_enabled = bool(nd.get("quic_enabled", cfg.node.quic_enabled))
        cfg.node.ui_port   = nd.get("ui_port",   cfg.node.ui_port)
        cfg.node.invite_ttl_hours = int(
            nd.get("invite_ttl_hours", cfg.node.invite_ttl_hours))
        cfg.node.pair_ttl_hours = int(
            nd.get("pair_ttl_hours", cfg.node.pair_ttl_hours))
        cfg.node.device_request_ttl_minutes = int(
            nd.get("device_request_ttl_minutes",
                   cfg.node.device_request_ttl_minutes))
        cfg.node.max_concurrent_streams = _positive(
            nd.get("max_concurrent_streams", cfg.node.max_concurrent_streams),
            cfg.node.max_concurrent_streams, "max_concurrent_streams")
        cfg.node.transcode_incompatible_video = bool(
            nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video))
        ice_if = nd.get("ice_interfaces")
        if isinstance(ice_if, list):
            cfg.node.ice_interfaces = [str(s) for s in ice_if]
        stun = nd.get("stun_servers")
        if isinstance(stun, list):
            cfg.node.stun_servers = [str(s) for s in stun]
        if "ffmpeg_path" in nd:
            cfg.node.ffmpeg_path = str(nd["ffmpeg_path"])
        if "ffprobe_path" in nd:
            cfg.node.ffprobe_path = str(nd["ffprobe_path"])

        # Multi-group: [[groups]] array
        if "groups" in raw:
            for g in raw["groups"]:
                cfg.groups.append(GroupConfig(
                    id=g.get("id", ""),
                    name=g.get("name", ""),
                    roots=_read_roots(g),
                    # Ignored when roots are given explicitly (warned about in
                    # _read_roots); otherwise __post_init__ migrates it.
                    shared_dir="" if _read_roots(g) else g.get("shared_dir", ""),
                    upload_dir=g.get("upload_dir", ""),
                    visibility=g.get("visibility", "private"),
                    join_policy=g.get("join_policy", "invite"),
                    quic_port=g.get("quic_port", cfg.node.quic_port),
                ))
        # Back-compat: single [group] section
        elif "group" in raw:
            grp = raw["group"]
            cfg.groups.append(GroupConfig(
                id=grp.get("id", ""),
                name=grp.get("name", ""),
            ))

        if "data_dir" in raw:
            cfg.data_dir = Path(raw["data_dir"]).expanduser().resolve()

        if "admin_pk_ed25519" in raw:
            # Removed, not merely unused: a key named here granted operator
            # authority, and dropping it silently would refuse invites and file
            # deletion with a signature error that looks like something else.
            log.warning(
                "admin_pk_ed25519 in %s is ignored — operator authority now comes "
                "from the roster. Run `meshbay-node operator pair` and delete the "
                "line.", path)

        ks = raw.get("keystore", {})
        if "path" in ks:
            cfg.keystore.path = Path(ks["path"]).expanduser()
        if "unlock_file" in ks:
            cfg.keystore.unlock_file = Path(ks["unlock_file"]).expanduser()

    # Env var overrides
    if url  := os.environ.get("MESHBAY_HUB_URL"):
        cfg.hub.url = url
    if user := os.environ.get("MESHBAY_USERNAME"):
        cfg.hub.username = user
    if port := os.environ.get("MESHBAY_QUIC_PORT"):
        cfg.node.quic_port = int(port)
    if (qe := os.environ.get("MESHBAY_QUIC_ENABLED")) is not None:
        cfg.node.quic_enabled = qe.strip().lower() in ("1", "true", "yes", "on")
    if streams := os.environ.get("MESHBAY_MAX_CONCURRENT_STREAMS"):
        cfg.node.max_concurrent_streams = _positive(
            streams, cfg.node.max_concurrent_streams,
            "MESHBAY_MAX_CONCURRENT_STREAMS")

    return cfg


def write_example_config(path: Path = DEFAULT_CONFIG_PATH) -> None:
    """Write an example config file if none exists."""
    if not path.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(EXAMPLE_CONFIG, encoding="utf-8", newline="\n")