diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 57 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 88 |
3 files changed, 151 insertions, 1 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index f4dcca5..45aca7a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -256,6 +256,13 @@ class NodeDaemon: # Admission policy comes from node.toml, never from the hub: # a hub that could declare a group open would be handed its key. "join_policy": group_cfg.join_policy, + # Whether ordinary members may upload. Read once here, into + # the context, because the upload handler is synchronous and + # a database round trip per chunk would be absurd. The + # signed operation that changes it updates this dict in + # place, so the two never drift within a run. + "member_upload": await self._roster.member_upload_allowed( + group_cfg.id) if self._roster else True, } if not groups_ctx: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 226b784..c811016 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -100,6 +100,26 @@ CREATE TABLE IF NOT EXISTS members ( PRIMARY KEY (group_id, user_id) ); +-- Per-group settings the operator changes while the node runs. +-- +-- Not node.toml: that file is hand-written, full of comments explaining +-- decisions, and `ops.py` deliberately appends to it rather than round-tripping +-- it through a TOML writer. A setting toggled from a panel has to take effect +-- without an edit to the operator's file and without a restart, so it lives +-- here, where the node already keeps what it decided rather than what it was +-- configured with. +-- +-- Absent means default. Nothing writes a row until someone changes something, +-- so an existing node has the same behaviour it had before this table existed. +CREATE TABLE IF NOT EXISTS group_settings ( + group_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + set_by TEXT NOT NULL DEFAULT '', + set_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (group_id, key) +); + CREATE TABLE IF NOT EXISTS invites ( code_hash TEXT PRIMARY KEY, group_id TEXT NOT NULL, @@ -518,6 +538,43 @@ class Roster: # ── Invites ────────────────────────────────────────────────────────────── + # ── Group settings ────────────────────────────────────────────────────── + + # Whether members who are not the operator may upload. Default is yes: a + # group that nobody may add to is the unusual case, and an existing node + # must not change behaviour because a table was added under it. + SETTING_MEMBER_UPLOAD = "member_upload" + + async def get_setting(self, group_id: str, key: str, + default: str | None = None) -> str | None: + async with self._db.execute( + "SELECT value FROM group_settings WHERE group_id = ? AND key = ?", + (group_id, key)) as cur: + row = await cur.fetchone() + return row["value"] if row else default + + async def set_setting(self, group_id: str, key: str, value: str, + set_by: str = "") -> None: + await self._db.execute( + "INSERT INTO group_settings (group_id, key, value, set_by, set_at) " + "VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(group_id, key) DO UPDATE SET " + "value = excluded.value, set_by = excluded.set_by, " + "set_at = excluded.set_at", + (group_id, key, value, set_by, _now())) + await self._db.commit() + + async def member_upload_allowed(self, group_id: str) -> bool: + """Whether an ordinary member may upload to this group.""" + value = await self.get_setting(group_id, self.SETTING_MEMBER_UPLOAD, "1") + return value != "0" + + async def set_member_upload(self, group_id: str, allowed: bool, + set_by: str = "") -> bool: + await self.set_setting(group_id, self.SETTING_MEMBER_UPLOAD, + "1" if allowed else "0", set_by) + return allowed + 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 9d16f82..22e5e15 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -62,6 +62,7 @@ from meshbay_common.adminop import ( OP_MEMBER_REVOKE, OP_GEK_ROTATE, OP_MEMBER_UNPIN, + OP_MEMBER_UPLOAD, admin_transcript, ) from meshbay_common.crypto import pk_to_b64, wrap_gek_aes @@ -469,6 +470,8 @@ class WebRTCPeerSession: self._spawn(self._do_device_list(msg)) elif mtype == MNP.DEVICE_REVOKE: self._spawn(self._do_device_revoke(msg)) + elif mtype == MNP.MEMBER_UPLOAD: + self._do_member_upload(msg) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -687,7 +690,11 @@ class WebRTCPeerSession: "proof": base64.b64encode(node_proof).decode(), "sig": base64.b64encode( self._ctx["sk_node"].sign(node_transcript)).decode(), - "is_node_admin": bool(node_user_id and self._user_id == node_user_id), + "is_node_admin": self._is_node_admin(), + # So the interface knows whether to offer uploading at all. Not a + # permission — the node refuses regardless — but without it the + # only way to discover the answer is to try. + "member_upload": bool(self._group_ctx().get("member_upload", True)), } if node_user_id: ack["node_user_id"] = node_user_id @@ -1568,6 +1575,58 @@ class WebRTCPeerSession: self._send({"type": MNP.MEMBER_UNPIN_ACK, "v": MNP_VERSION, "user_id": user_id}) + def _do_member_upload(self, msg: dict) -> None: + """ + Turn uploading by ordinary members on or off, for this group. + + Signed like every other operator action. The setting decides who may + write to the operator's disk, so a node that took it from an unsigned + message would let any member turn it back on for everyone — the control + would be a suggestion. + """ + if "allowed" not in msg: + self._send({"type": "error", "detail": "Missing allowed"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + # The subject is what the operator is shown before signing, so it has to + # name the outcome rather than the operation. + self._issue_admin_challenge( + OP_MEMBER_UPLOAD, "on" if msg.get("allowed") else "off") + + async def _admin_exec_member_upload( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + allowed = pending["subject"] == "on" + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"member_upload:{pending['subject']}") + return + roster = self._ctx.get("roster") + if roster is None: + self._send({"type": "error", "detail": "No roster on this node"}) + return + await roster.set_member_upload(self._group_id or "", allowed, + set_by=self._user_id) + # Stored *and* applied. The upload path is synchronous and reads this + # dict; leaving it to the next restart would make the panel say one + # thing while the node did another. + self._group_ctx()["member_upload"] = allowed + self._audit("member_upload", pending["subject"]) + + # Everyone already connected is told, rather than finding out by having + # an upload refused. Enforcement does not depend on this reaching them — + # it is the node that refuses — but a button that stays visible until + # the next reconnection is a button people press. + notice = {"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION, + "allowed": allowed} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + async def _run_op(self, fn, *args, **kwargs): """ Call an operation from `meshbay_node.ops` with the daemon's own view. @@ -1995,6 +2054,19 @@ class WebRTCPeerSession: "filename": filename}) return + # The operator can close uploading to everyone but themselves. Enforced + # here rather than by hiding a button: the button is a courtesy to the + # people who are not trying, and this is the part that holds against + # someone who is. `is_node_admin` is computed from the identity this + # node pinned, never from a hub claim. + if not ctx.get("member_upload", True) and not self._is_node_admin(): + self._send({"type": "error", + "detail": "Uploading is turned off for this group", + "code": "member_upload_off", + "filename": filename}) + self._audit("upload_refused", filename[:64]) + return + roots: RootSet | None = ctx.get("roots") upload_root = roots.upload_root if roots else None if upload_root is None: @@ -2189,6 +2261,17 @@ class WebRTCPeerSession: if ident: self._pinned_pk = ident["pk_ed25519"] + def _is_node_admin(self) -> bool: + """ + Whether the peer on this connection is the node's operator. + + Was written out twice — once in the handshake ack and once at the gate + below it — which is how the two come to disagree. From the node's own + record of who it belongs to, never from a hub claim. + """ + node_user_id = self._ctx.get("node_user_id") + return bool(node_user_id and self._user_id == node_user_id) + def _has_admin_authority(self) -> bool: """ Cheap synchronous pre-check: is there anyone who could authorize this? @@ -2269,6 +2352,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_MEMBER_UNPIN: self._spawn( self._admin_exec_member_unpin(pending, transcript, sig_bytes)) + elif pending["op"] == OP_MEMBER_UPLOAD: + self._spawn( + self._admin_exec_member_upload(pending, transcript, sig_bytes)) else: self._send({"type": "error", "detail": "Unknown admin operation"}) |