aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
commite9d5e979fdab9a1cc3c729d602e6f27207b9480c (patch)
treeb5993f2c81b760ba56f251457edf84dd91ad63dc /packages/meshbay-node/src/meshbay_node/transport/quic_server.py
parent50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (diff)
downloadmeshbay-e9d5e979fdab9a1cc3c729d602e6f27207b9480c.tar.gz
feat(node): several named roots per group, and one implementation per operation
Stage A — a group's content is a set of named roots --------------------------------------------------- `shared_dir` becomes a list of {name, path, kind}. The name is the directory's basename, derived once at add time and *stored*: recomputing it would re-identify a whole library the day someone renames a folder on disk. Duplicate names are refused case-insensitively and no root may contain another — both compared with NFC folding, because most of these directories live on exFAT or NTFS where `Films` and `films` are one directory. Every index path carries its root name, in a one-root group as much as in a five-root one. One path shape has to be got right once; two have to be kept right for ever. **A root that goes away freezes; it never empties.** Unmounting a volume makes watchdog report every file under it as deleted, or presents an empty directory to the next scan. Acting on either propagates deletions for a whole library to every member, as though the owner had erased it. So a deletion is acted on only once its root is confirmed readable, and availability is tracked per root — one unplugged drive leaves the others serving. 12 tests, verified to fail against an indexer without the check. Events are not trusted to be complete either: ReadDirectoryChangesW drops them under load and inotify on a FUSE mount misses changes made outside it. A periodic reconciliation sweep is the only thing that recovers a missed event. MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content hash, a node id and an endpoint — no paths, no filenames — and private groups register nothing (H7). Stage B — one implementation behind every front door ---------------------------------------------------- C1 and C6 were both "a second path into the node with its own weaker handshake". Two implementations of `revoke` with two authorization checks is that shape one size down. `meshbay_node/ops.py` holds each operation once, takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback API is one `_op(...)` line per endpoint; the MNP handlers call the same functions. test_ops.py asserts the shape rather than trusting it. Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload` (SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP operations: rotation is the half of revocation that revocation cannot do, since the ex-member holds the current key, and the node generates the replacement with its own CSPRNG — no key material crosses the wire, which is what the C5b rule is actually about. Two bugs found by running it rather than by testing it ------------------------------------------------------ GroupIndex is keyed by **content hash**, so the same bytes at two paths are one entry — which is also why a scan reports ten files and indexes nine. Reconciliation compared paths, so it decided the second path was a missed event every 60 s, rewrote the entry and pushed an index update to every connected peer. Seen in a live node's log. `meshbay-node reload` crashed on first use with `subprocess` unimported: the module compiles fine, which is the "syntax, not names" trap already recorded for the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be added to the parser without an entry there. Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the wire carried "0.2" — harmless only because nothing imported it. And _do_dir_create/_do_dir_delete referenced an undefined `filename` on their error path. 740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_server.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py40
1 files changed, 36 insertions, 4 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
index 73e668c..ce6fe17 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -35,6 +35,7 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived, StreamReset
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
+from meshbay_node.roots import RootSet, entry_abs_path
from meshbay_common.handshake import (
NONCE_LEN,
ROLE_CLIENT,
@@ -98,6 +99,37 @@ class Denylist:
log.info("Denied jti: %s", jti[:8])
self._save()
+ def entries(self) -> dict[str, list[str]]:
+ """What is currently refused, for the operator to inspect (14.10)."""
+ return {
+ "users": sorted(self.user_ids),
+ "groups": sorted(self.group_ids),
+ "jtis": sorted(self.jtis),
+ }
+
+ def clear(self, subject: str = "") -> int:
+ """
+ Drop everything, or one identifier. Returns how many entries went.
+
+ Not silent by design: clearing re-admits whoever it was keeping out, and
+ the count is what tells the operator whether they undid one revocation
+ or all of them.
+ """
+ before = len(self.user_ids) + len(self.group_ids) + len(self.jtis)
+ if subject:
+ self.user_ids.discard(subject)
+ self.group_ids.discard(subject)
+ self.jtis.discard(subject)
+ else:
+ self.user_ids.clear()
+ self.group_ids.clear()
+ self.jtis.clear()
+ after = len(self.user_ids) + len(self.group_ids) + len(self.jtis)
+ removed = before - after
+ if removed:
+ self._save()
+ return removed
+
def _load(self) -> None:
if not self._path or not self._path.exists():
return
@@ -352,7 +384,7 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._send(stream_id, {"type": "error", "detail": "File not found"})
return
- file_path = ctx["shared_root"] / entry.path / entry.name
+ file_path = entry_abs_path(ctx["roots"], entry)
if not file_path.exists():
self._send(stream_id, {"type": "error", "detail": "File not on disk"})
return
@@ -379,7 +411,7 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._send(stream_id, {"type": "error", "detail": "File not found"})
return
- file_path = ctx["shared_root"] / entry.path / entry.name
+ file_path = entry_abs_path(ctx["roots"], entry)
if not file_path.exists():
self._send(stream_id, {"type": "error", "detail": "File not on disk"})
return
@@ -506,7 +538,7 @@ class QuicChunkServer:
sk_node: Ed25519PrivateKey,
hub_pk_pem: bytes,
gek: bytes,
- shared_root: Path,
+ roots: RootSet,
index: GroupIndex,
host: str = "::", # listen IPv4 + IPv6 (dual-stack Linux)
port: int = 19000,
@@ -519,7 +551,7 @@ class QuicChunkServer:
"sk_node": sk_node,
"hub_pk_pem": hub_pk_pem,
"gek": gek,
- "shared_root": shared_root,
+ "roots": roots,
"index": index,
}
if groups: