summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/config.py
blob: 42ebcfd1e8b4fb26244d9e9cb1898854facb93e5 (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
"""
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

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

log = logging.getLogger(__name__)

DEFAULT_CONFIG_PATH = Path.home() / ".config" / "meshbay" / "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_port = 19010  # QUIC (MNP) — LAN, port-forwarded, hub-less direct access
ui_port  = 18000   # local admin UI (127.0.0.1 only)

# 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 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

# 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

# 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
    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
    # 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


@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


@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
    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)]


@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=lambda: Path.home() / ".local" / "share" / "meshbay")

    # 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())

        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.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.max_concurrent_streams = _positive(
            nd.get("max_concurrent_streams", cfg.node.max_concurrent_streams),
            cfg.node.max_concurrent_streams, "max_concurrent_streams")

        # 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", ""),
                    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 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)