aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py159
1 files changed, 35 insertions, 124 deletions
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 1f1f2d2..947d1f9 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -28,7 +28,6 @@ import hashlib
import hmac
import logging
import os
-import re
import struct
import time
from pathlib import Path
@@ -86,8 +85,9 @@ from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
from meshbay_node import ops
-from meshbay_node.roots import RootSet, entry_abs_path
-from meshbay_node.roster import DEFAULT_INVITE_TTL
+from meshbay_node.roots import (
+ RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name,
+)
log = logging.getLogger(__name__)
@@ -135,81 +135,6 @@ JOIN_FAILURE_WINDOW = 600 # seconds
# into, back up or empty — rather than a hidden tree of per-user uuids that
# nobody could read, or files scattered wherever someone happened to be looking.
UPLOAD_DIR_NAME = "uploads"
-# Conservative allowlist: also what keeps markup out of filenames, which the node admin
-# UI used to render unescaped (finding H2).
-# An allowlist, still — C5a and H2 depend on it — but one that does not assume
-# the world writes in ASCII. `été.txt` and `rapport (1).pdf` were refused, and
-# the second of those is a name _free_name generates itself, so the node was
-# rejecting files it had named. `\w` is Unicode here, which admits letters and
-# digits of any script while `<`, `>`, `"`, `;`, `/`, `\` and control characters
-# stay out. The first character must be a letter or digit, so ".." and dotfiles
-# cannot start one, and a trailing space or dot is refused because it makes two
-# different files look identical in a list.
-SAFE_UPLOAD_NAME = re.compile(
- r"^[^\W_]" # letter or digit — never '.', '-' or space
- r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation
- r"(?<![ .])$", # and never ending on a space or a dot
- re.UNICODE)
-
-
-def _free_name(directory: Path, filename: str) -> str:
- """
- `filename`, or the first "name (n).ext" that is not taken.
-
- Never returns the name of a file that exists, so an upload cannot replace
- one — the property the per-user quarantine used to provide (C5a).
- """
- if not (directory / filename).exists():
- return filename
- stem, dot, ext = filename.rpartition(".")
- if not dot:
- stem, ext = filename, ""
- for n in range(2, 1000):
- candidate = f"{stem} ({n}){dot}{ext}"
- if not (directory / candidate).exists():
- return candidate
- raise FileExistsError(filename)
-
-
-def safe_subdir(roots: RootSet, rel: str) -> Path | None:
- """
- Resolve a client-supplied directory inside one of the group's roots, or refuse.
-
- The path arrives from the wire, so every part is checked: the first segment
- must name a root that is readable right now, each later segment against the
- same allowlist as filenames, and the resolved result against that root's
- directory. `..`, absolute paths, symlinks pointing out, and anything with a
- separator in a segment are all refused here rather than in the caller, so
- there is one place to get it right.
-
- The virtual root itself — `""` — is deliberately **not** resolvable. It is
- not a directory on anyone's disk: a file cannot be written there and a
- directory cannot be created there, because it belongs to no volume. Callers
- that used to receive the shared root for an empty path now receive None,
- which is the honest answer.
-
- The quarantine was the fix for C5a; what actually mattered in it — no
- overwrite, a name allowlist, and confinement — is kept by this plus the
- caller's existing checks.
- """
- found = roots.split(rel or "")
- if found is None:
- return None
- root, tail = found
- if not root.available:
- return None
- parts = [seg for seg in tail.split("/") if seg not in ("", ".")]
- if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts):
- return None
- try:
- target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve()
- base = root.path.resolve()
- except OSError:
- return None
- if target != base and base not in target.parents:
- return None
- return target
-
def _extract_dtls_fingerprint(sdp: str) -> bytes:
@@ -985,6 +910,12 @@ class WebRTCPeerSession:
# the client is told it has no role on a node it administers.
member = (await roster.get_member(group_id, user_id)
or await roster.get_member("", user_id))
+ if not member and self._group_join_policy(session_group) == "open":
+ await roster.set_member(
+ group_id=session_group, user_id=user_id, role=ROLE_MEMBER,
+ status="active", approved_by="open-join",
+ )
+ member = await roster.get_member(session_group, user_id)
await self._join_ok(
user_id, pk_x_raw, session_group,
role=member["role"] if member else "",
@@ -1635,16 +1566,12 @@ class WebRTCPeerSession:
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"})
+ try:
+ await self._run_op(
+ ops.set_member_upload, self._group_id or "", allowed)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
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
@@ -1928,14 +1855,11 @@ class WebRTCPeerSession:
self._audit("admin_auth_failed", f"member_revoke:{user_id[:8]}")
return
- roster = self._ctx.get("roster")
- if roster is None:
- self._send({"type": "error", "detail": "Roster not available"})
- return
-
- group_id = self._group_id or ""
- if not await roster.set_status(group_id, user_id, "revoked"):
- self._send({"type": "error", "detail": "Not a member of this group"})
+ try:
+ result = await self._run_op(
+ ops.revoke_member, user_id, self._group_id or "")
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
return
# Anyone connected right now keeps the key they already unwrapped; what
@@ -1948,14 +1872,11 @@ class WebRTCPeerSession:
except Exception:
pass
- log.info("Member revoked by %s: user=%s group=%s",
- self._user_id[:8], user_id[:8], group_id[:8] or "-")
self._audit("member_revoke", user_id)
self._send({
"type": MNP.MEMBER_REVOKE_ACK, "v": MNP_VERSION,
"user_id": user_id,
- "reminder": "they still hold the current group key — rotate it with "
- "meshbay-node gek-init",
+ "reminder": result.get("reminder", ""),
})
async def _do_keypair_bundle_delete(self) -> None:
@@ -2694,37 +2615,27 @@ class WebRTCPeerSession:
self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}")
return
- roster = self._ctx.get("roster")
- if roster is None:
- self._send({"type": "error", "detail": "Roster not available"})
- return
-
payload = pending["payload"]
- code = await roster.create_invite(
- group_id=payload["group_id"],
- user_id=payload["user_id"],
- role=ROLE_MEMBER,
- created_by=self._user_id or "",
- ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL),
- username=payload.get("username", ""),
- )
- invites = await roster.list_invites()
- expires = next(
- (i["expires_at"] for i in invites
- if i["user_id"] == payload["user_id"]
- and i["group_id"] == payload["group_id"]), "")
+ try:
+ result = await self._run_op(
+ ops.create_invite,
+ payload["group_id"],
+ payload.get("username", ""),
+ user_id=payload["user_id"],
+ created_by=self._user_id or "",
+ )
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
- log.info("Invite created: group=%s user=%s",
- payload["group_id"][:8], payload["user_id"][:8])
self._audit("invite_create", f"target={payload['user_id'][:8]}")
- # The code exists in the clear exactly here and in the operator's hands.
self._send({
"type": MNP.INVITE_RESULT,
"v": MNP_VERSION,
- "code": code,
- "expires_at": expires,
- "user_id": payload["user_id"],
- "username": payload.get("username", ""),
+ "code": result["code"],
+ "expires_at": result["expires_at"],
+ "user_id": result["user_id"],
+ "username": result.get("username", ""),
})
def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None: