aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py29
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py61
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/__init__.py21
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/client.py148
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/http_server.py336
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py1
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/server.py286
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/tls_cert.py36
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py6
9 files changed, 42 insertions, 882 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index a7a0785..b681355 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -26,27 +26,24 @@ url = "https://meshbay.org"
username = "myusername"
[node]
-port = 19000 # TCP+TLS (MNP v1)
-quic_port = 19010 # QUIC (MNP v2)
-http_port = 19001 # HTTP file API (public content)
-ui_port = 18000 # local web UI
+quic_port = 19010 # QUIC (MNP) — LAN, port-forwarded, hub-less direct access
+ui_port = 18000 # local admin UI (127.0.0.1 only)
-# Multiple groups — each with its own directory and ports
+# 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 its own directory
[[groups]]
id = "" # set after joining
name = "My Media"
shared_dir = "/home/user/Media"
-port = 19000
quic_port = 19010
-http_port = 19001
[[groups]]
id = ""
name = "Public Archive"
shared_dir = "/home/user/Archive"
-port = 19002
quic_port = 19012
-http_port = 19003
visibility = "public"
[keystore]
@@ -68,9 +65,7 @@ class HubConfig:
@dataclass
class NodeConfig:
- port: int = 19000
quic_port: int = 19010
- http_port: int = 19001
ui_port: int = 18000
@@ -80,9 +75,7 @@ class GroupConfig:
name: str = ""
shared_dir: str = ""
visibility: str = "private" # public|private
- port: int = 19000 # TCP+TLS MNP port for this group
quic_port: int = 19010 # QUIC MNP port
- http_port: int = 19001 # HTTP file API port
@dataclass
@@ -121,9 +114,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.hub.username = hub.get("username", cfg.hub.username)
nd = raw.get("node", {})
- cfg.node.port = nd.get("port", cfg.node.port)
+ # `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.http_port = nd.get("http_port", cfg.node.http_port)
cfg.node.ui_port = nd.get("ui_port", cfg.node.ui_port)
# Multi-group: [[groups]] array
@@ -134,9 +127,7 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
name=g.get("name", ""),
shared_dir=g.get("shared_dir", ""),
visibility=g.get("visibility", "private"),
- port=g.get("port", cfg.node.port),
quic_port=g.get("quic_port", cfg.node.quic_port),
- http_port=g.get("http_port", cfg.node.http_port),
))
# Back-compat: single [group] section
elif "group" in raw:
@@ -163,8 +154,8 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.hub.url = url
if user := os.environ.get("MESHBAY_USERNAME"):
cfg.hub.username = user
- if port := os.environ.get("MESHBAY_PORT"):
- cfg.node.port = int(port)
+ if port := os.environ.get("MESHBAY_QUIC_PORT"):
+ cfg.node.quic_port = int(port)
return cfg
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index fe12909..c930e54 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -8,9 +8,9 @@ Startup sequence:
4. Fetch GEK bundle from hub (if group configured)
5. Start directory indexer (watchdog)
6. Create chat stores (one SQLite DB per group)
- 7. Create WebRTC transport (browser clients via DataChannel)
- 8. Start QUIC+TCP chunk servers (native clients)
- 9. Start HTTP file API (public content)
+ 7. Create WebRTC transport (browser + native clients via DataChannel)
+ 8. Start QUIC chunk server (LAN / port-forwarded / hub-less direct access)
+ 9. (Phase 11.5: the unauthenticated HTTP file API and the TCP+TLS server were removed)
10. Start hub WebSocket (signaling, revocations, WebRTC offers)
11. Start local web UI on node.ui_port (localhost only)
12. Run until SIGINT/SIGTERM
@@ -43,11 +43,9 @@ from meshbay_node.hub_client import HubClient, HubConfig
from meshbay_node.indexer import DirectoryIndexer
from meshbay_node.keystore import NodeKeys, load_or_create_keystore
from meshbay_node.transport import (
- ChunkServer,
Denylist,
QUIC_AVAILABLE,
WEBRTC_AVAILABLE,
- create_http_app,
)
if QUIC_AVAILABLE:
@@ -103,12 +101,10 @@ class NodeDaemon:
"hub_url": config.hub.url,
"username": config.hub.username,
"groups": [g.name for g in config.groups],
- "node_port": config.node.port,
"quic_port": config.node.quic_port,
"endpoint_hint": None,
"indexes": {},
}
- self._tcp_server: ChunkServer | None = None
self._quic_server = None
self._webrtc = None
self._denylist = Denylist() if Denylist else None
@@ -118,7 +114,6 @@ class NodeDaemon:
self._indexers: list[DirectoryIndexer] = []
self._tasks: list[asyncio.Task] = []
self._hub: HubClient | None = None
- self._http_servers: list[uvicorn.Server] = []
async def run(self) -> None:
log.info("MeshBay Node starting up")
@@ -265,7 +260,7 @@ class NodeDaemon:
else:
log.warning("WebRTC not available (aiortc not installed)")
- # 7. QUIC + TCP chunk servers
+ # 7. QUIC chunk server (LAN / port-forwarded / hub-less direct access)
if QUIC_AVAILABLE:
self._quic_server = QuicChunkServer(
sk_node=keys.sk_ed25519,
@@ -282,19 +277,6 @@ class NodeDaemon:
log.info("QUIC server on port %d (%d groups)",
self._config.node.quic_port, len(groups_ctx))
- self._tcp_server = ChunkServer(
- sk_node=keys.sk_ed25519,
- hub_pk_pem=session.hub_pk_pem,
- gek=first["gek"],
- shared_root=first["shared_root"],
- index=first["index"],
- host="0.0.0.0",
- port=self._config.node.port,
- groups=groups_ctx,
- )
- await self._tcp_server.start()
- log.info("TCP+TLS server on port %d", self._config.node.port)
-
# 8. Hub WebSocket (signaling + revocations + WebRTC offers)
async def on_webrtc_offer(sdp, peer_id, ice_candidates):
if not self._webrtc:
@@ -338,32 +320,10 @@ class NodeDaemon:
self._tasks.append(ws_task)
log.info("Hub WS task started")
- # 9. HTTP file API (one per group)
- for gid, gctx in groups_ctx.items():
- group_cfg = next(
- (g for g in self._config.groups if g.id == gid), None)
- if not group_cfg:
- continue
- http_app = create_http_app(
- sk_node=keys.sk_ed25519,
- hub_pk_pem=session.hub_pk_pem,
- shared_root=gctx["shared_root"],
- index=gctx["index"],
- group_id=gid,
- group_name=group_cfg.name,
- gek=gctx.get("gek"),
- )
- http_cfg = uvicorn.Config(
- http_app,
- host="0.0.0.0",
- port=group_cfg.http_port,
- log_level="warning",
- )
- http_server = uvicorn.Server(http_cfg)
- self._http_servers.append(http_server)
- self._tasks.append(asyncio.create_task(http_server.serve()))
- log.info("HTTP API on port %d for group %s",
- group_cfg.http_port, group_cfg.name)
+ # 9. (removed in Phase 11.5) The per-group HTTP file API used to start here.
+ # It served the Mesh Group Index and raw plaintext files on 0.0.0.0 with no
+ # authentication, for private groups too — finding C1. Every client path now
+ # goes through the MNP handshake (JWT + group claim + GEK proof).
# 10. Update admin UI state (UI already running from step 2)
self._state["groups_ctx"] = groups_ctx
@@ -541,11 +501,6 @@ class NodeDaemon:
if self._quic_server:
await self._quic_server.stop()
- if self._tcp_server:
- await self._tcp_server.stop()
-
- for server in self._http_servers:
- server.should_exit = True
log.info("Node stopped")
diff --git a/packages/meshbay-node/src/meshbay_node/transport/__init__.py b/packages/meshbay-node/src/meshbay_node/transport/__init__.py
index df9c209..e423e35 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/__init__.py
@@ -1,7 +1,17 @@
-"""MeshBay Node transport layer — TCP+TLS (v1), QUIC (v2), WebRTC (browsers)."""
-from .server import ChunkServer
-from .client import ChunkClient
-from .http_server import create_http_app
+"""
+MeshBay Node transport layer — WebRTC DataChannel (primary), QUIC (direct/LAN).
+
+Transport decision (2026-08-13, second security review):
+ - WebRTC/ICE is the primary path for browser AND native clients. ICE/STUN is the
+ only NAT traversal validated on this project (2 ISPs, IPv4 STUN + IPv6, 4G CGNAT).
+ - QUIC is kept at parity for LAN, port-forwarded and hub-less `group://` access.
+ `punch_nat()` is a direct-connection helper, not a traversal stack.
+ - TCP+TLS (`server.py`/`client.py`) and the node HTTP file API (`http_server.py`)
+ were REMOVED in Phase 11.5. The HTTP API served private group indexes and
+ plaintext files with no authentication on 0.0.0.0 (finding C1); the TCP server
+ accepted a bare JWT with no GEK proof (finding C6). Neither is coming back —
+ every client path must go through the unified MNP handshake.
+"""
# QUIC transport (MNP v2) — requires aioquic>=1.0
try:
@@ -14,7 +24,7 @@ except ImportError:
Denylist = None # type: ignore[assignment,misc]
QUIC_AVAILABLE = False
-# WebRTC transport (browsers) — requires aiortc>=1.9
+# WebRTC transport (browsers + native clients) — requires aiortc>=1.9
try:
from .webrtc_server import WebRTCTransport, WebRTCPeerSession
WEBRTC_AVAILABLE = True
@@ -24,7 +34,6 @@ except ImportError:
WEBRTC_AVAILABLE = False
__all__ = [
- "ChunkServer", "ChunkClient", "create_http_app",
"QuicChunkServer", "QuicChunkClient", "Denylist", "QUIC_AVAILABLE",
"WebRTCTransport", "WebRTCPeerSession", "WEBRTC_AVAILABLE",
]
diff --git a/packages/meshbay-node/src/meshbay_node/transport/client.py b/packages/meshbay-node/src/meshbay_node/transport/client.py
deleted file mode 100644
index 63d50af..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/client.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""
-MeshBay — TCP+TLS chunk client (MNP v1).
-
-Used by the web client (or other nodes) to fetch files from a Mesh Node.
-Verifies Ed25519 chunk signatures using the node's public key from the hub.
-"""
-
-import asyncio
-import base64
-import logging
-import struct
-from pathlib import Path
-
-import blake3
-import msgpack
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- decrypt_chunk,
- verify_chunk_signature,
-)
-from meshbay_common.protocol import MNP
-from meshbay_node.transport.tls_cert import client_ssl_context
-
-log = logging.getLogger(__name__)
-
-MAX_MSG = 64 * 1024 * 1024
-
-
-async def _send(writer, obj):
- data = msgpack.packb(obj, use_bin_type=True)
- writer.write(struct.pack(">I", len(data)) + data)
- await writer.drain()
-
-async def _recv(reader):
- header = await reader.readexactly(4)
- length = struct.unpack(">I", header)[0]
- if length > MAX_MSG:
- raise ValueError(f"Message too large: {length}")
- return msgpack.unpackb(await reader.readexactly(length), raw=False)
-
-
-class ChunkClient:
- """
- Async client for fetching encrypted chunks from a ChunkServer.
-
- Usage:
- async with ChunkClient(host, port, jwt_token, gek, pk_node_b64) as client:
- data = await client.fetch_chunk(file_id, chunk_index=0)
- """
-
- def __init__(
- self,
- host: str,
- port: int,
- jwt_token: str,
- gek: bytes,
- pk_node_b64: str, # node's Ed25519 PK from hub — used for sig verification
- group_id: str = "",
- ):
- self._host = host
- self._port = port
- self._jwt_token = jwt_token
- self._gek = gek
- self._group_id = group_id
- self._pk_node = Ed25519PublicKey.from_public_bytes(
- base64.b64decode(pk_node_b64))
- self._reader: asyncio.StreamReader | None = None
- self._writer: asyncio.StreamWriter | None = None
-
- async def __aenter__(self):
- await self.connect()
- return self
-
- async def __aexit__(self, *_):
- await self.close()
-
- async def connect(self) -> None:
- ssl_ctx = client_ssl_context()
- self._reader, self._writer = await asyncio.open_connection(
- self._host, self._port, ssl=ssl_ctx)
-
- handshake_msg = {
- "type": MNP.HANDSHAKE,
- "v": MNP_VERSION,
- "token": self._jwt_token,
- }
- if self._group_id:
- handshake_msg["group_id"] = self._group_id
- await _send(self._writer, handshake_msg)
- ack = await _recv(self._reader)
- if ack.get("type") != MNP.HANDSHAKE_ACK:
- raise ConnectionError(f"Handshake rejected: {ack}")
- log.debug("Connected to node %s:%d", self._host, self._port)
-
- async def close(self) -> None:
- if self._writer:
- self._writer.close()
- await self._writer.wait_closed()
-
- async def fetch_index(self) -> bytes:
- """Request the Mesh Group Index. Returns raw wire bytes (encrypted)."""
- await _send(self._writer, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION})
- msg = await _recv(self._reader)
- return base64.b64decode(msg["index_b64"])
-
- async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes:
- """
- Fetch, verify, and decrypt one chunk.
- Returns plaintext bytes.
- """
- await _send(self._writer, {
- "type": MNP.FILE_REQUEST,
- "v": MNP_VERSION,
- "file_id": file_id,
- "chunk_index": chunk_index,
- })
- msg = await _recv(self._reader)
-
- if msg.get("type") == "error":
- raise LookupError(msg.get("detail", "Unknown error"))
-
- ct = base64.b64decode(msg["ct_b64"])
- nonce = base64.b64decode(msg["nonce_b64"])
- ct_hash = base64.b64decode(msg["ct_hash_b64"])
- pt_hash = base64.b64decode(msg["pt_hash_b64"])
- sig = base64.b64decode(msg["sig_b64"])
- file_hash = base64.b64decode(msg["file_hash_b64"])
- ci = msg["chunk_index"]
-
- # 1. Verify Ed25519 signature
- verify_chunk_signature(self._pk_node, ci, nonce, ct_hash, sig)
-
- # 2. Verify ciphertext hash
- if blake3.blake3(ct).digest() != ct_hash:
- raise ValueError("Ciphertext hash mismatch")
-
- # 3. Decrypt
- ckey = derive_chunk_key(self._gek, file_hash, ci)
- plaintext = decrypt_chunk(ckey, nonce, ct)
-
- # 4. Verify plaintext hash
- if blake3.blake3(plaintext).digest() != pt_hash:
- raise ValueError("Plaintext hash mismatch after decryption")
-
- return plaintext
diff --git a/packages/meshbay-node/src/meshbay_node/transport/http_server.py b/packages/meshbay-node/src/meshbay_node/transport/http_server.py
deleted file mode 100644
index 151c2e8..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/http_server.py
+++ /dev/null
@@ -1,336 +0,0 @@
-"""
-MeshBay Node — HTTP file API (port 19001, public content).
-
-Serves public group content over standard HTTP so browsers can
-access files without any special protocol.
-
-Endpoints:
- GET / node info (JSON)
- GET /index public Mesh Group Index (JSON)
- GET /file/{file_id} full file download (streaming)
- GET /file/{file_id}/{chunk} single encrypted chunk (JSON)
- GET /hls/{file_id}/playlist.m3u8 HLS playlist
- GET /hls/{file_id}/{segment}.ts HLS segment (binary TS)
-
-Auth: Bearer JWT in Authorization header (or ?token= query param).
-For public groups: auth optional (anonymous browse allowed).
-For chunk download: auth required (JWT verified offline with hub PK).
-
-Note: this server handles PUBLIC content only (no GEK decryption).
-Private group content requires a client that can do ChaCha20 (Phase 5).
-"""
-
-import asyncio
-import base64
-import json
-import logging
-import os
-import struct
-import subprocess
-import tempfile
-from pathlib import Path
-
-import blake3
-import jwt
-from fastapi import FastAPI, Header, HTTPException, Query, Request
-from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import sign_chunk, pk_to_b64
-from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk
-from meshbay_node import __version__
-from meshbay_node.indexer import GroupIndex
-from meshbay_node.indexer.group_index import GroupIndex
-
-log = logging.getLogger(__name__)
-
-CHUNK_SIZE = 1024 * 1024 # 1 MB
-HLS_SEGMENT_DURATION = 4 # seconds per HLS segment
-
-
-def create_http_app(
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- shared_root: Path,
- index: GroupIndex,
- group_id: str,
- group_name: str,
- gek: bytes | None = None, # None for public groups
-) -> FastAPI:
- """
- Create the node's public HTTP API FastAPI app.
- Bind to 0.0.0.0:19001 (or configured port) for external access.
- """
- app = FastAPI(
- title="MeshBay Node HTTP API",
- version=__version__,
- docs_url=None,
- redoc_url=None,
- )
-
- # ── Auth helper ───────────────────────────────────────────────────────────
-
- def _verify_token_optional(
- authorization: str | None,
- token_param: str | None,
- ) -> dict | None:
- """Verify JWT if provided. Returns decoded payload or None."""
- raw = None
- if authorization and authorization.lower().startswith("bearer "):
- raw = authorization[7:]
- elif token_param:
- raw = token_param
- if not raw:
- return None
- try:
- return jwt.decode(raw, hub_pk_pem, algorithms=["EdDSA"])
- except Exception:
- return None
-
- def _require_token(
- authorization: str | None,
- token_param: str | None,
- ) -> dict:
- decoded = _verify_token_optional(authorization, token_param)
- if decoded is None:
- raise HTTPException(status_code=401, detail="Authentication required")
- return decoded
-
- # ── Node info ─────────────────────────────────────────────────────────────
-
- @app.get("/")
- async def node_info():
- return {
- "node_version": __version__,
- "mnp_version": MNP_VERSION,
- "group_id": group_id,
- "group_name": group_name,
- "file_count": index.count,
- "pk_node": pk_to_b64(sk_node.public_key()),
- }
-
- # ── Public index ──────────────────────────────────────────────────────────
-
- @app.get("/index")
- async def get_index(
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Public Mesh Group Index as JSON. No auth required for public groups."""
- entries = [
- {
- "id": e.id,
- "name": e.name,
- "path": e.path,
- "size": e.size,
- "type": e.type,
- "duration": e.duration,
- }
- for e in index.entries
- ]
- return {
- "group_id": group_id,
- "group_name": group_name,
- "version": index.version,
- "entries": entries,
- }
-
- # ── Full file download (streaming) ────────────────────────────────────────
-
- @app.get("/file/{file_id}")
- async def download_file(
- file_id: str,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Stream an entire file. Public groups: no auth needed."""
- entry = index.get_entry(file_id)
- if not entry:
- raise HTTPException(status_code=404, detail="File not found in index")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- return FileResponse(
- path=str(file_path),
- filename=entry.name,
- media_type=_media_type(entry.name),
- )
-
- # ── Chunk endpoint (encrypted, for MNP-aware clients) ────────────────────
-
- @app.get("/file/{file_id}/{chunk_index}")
- async def get_chunk(
- file_id: str,
- chunk_index: int,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """
- Serve one encrypted chunk (JSON). Auth required.
- Clients that understand MNP can decrypt with the GEK they got from the hub.
- """
- _require_token(authorization, token)
-
- entry = index.get_entry(file_id)
- if not entry:
- raise HTTPException(status_code=404, detail="File not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- # Read chunk
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- plaintext = f.read(CHUNK_SIZE)
-
- if not plaintext:
- raise HTTPException(status_code=416, detail="Chunk out of range")
-
- file_hash = bytes.fromhex(entry.id)
- pt_hash = blake3.blake3(plaintext).digest()
-
- if gek:
- # Private group: encrypt chunk
- ckey = derive_chunk_key(gek, file_hash, chunk_index)
- nonce, ct = encrypt_chunk(ckey, plaintext)
- ct_hash = blake3.blake3(ct).digest()
- sig = sign_chunk(sk_node, chunk_index, nonce, ct_hash)
- return {
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "encrypted": True,
- "nonce_b64": base64.b64encode(nonce).decode(),
- "ct_b64": base64.b64encode(ct).decode(),
- "ct_hash_b64": base64.b64encode(ct_hash).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- "file_hash_b64": base64.b64encode(file_hash).decode(),
- }
- else:
- # Public group: serve plaintext chunk (TLS provides transport encryption)
- pt_hash_b = blake3.blake3(plaintext).digest()
- sig_payload = chunk_index.to_bytes(4, "big") + bytes(12) + pt_hash_b
- sig = sk_node.sign(sig_payload)
- return {
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "encrypted": False,
- "data_b64": base64.b64encode(plaintext).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- }
-
- # ── HLS streaming ─────────────────────────────────────────────────────────
-
- @app.get("/hls/{file_id}/playlist.m3u8")
- async def hls_playlist(
- file_id: str,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Generate HLS playlist for a video file."""
- entry = index.get_entry(file_id)
- if not entry or entry.type != "video":
- raise HTTPException(status_code=404, detail="Video file not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- duration = entry.duration or _probe_duration(file_path)
- if not duration:
- raise HTTPException(status_code=422, detail="Cannot determine video duration")
-
- n_segments = max(1, int(duration / HLS_SEGMENT_DURATION) + 1)
- token_param = f"?token={token}" if token else ""
-
- lines = [
- "#EXTM3U",
- "#EXT-X-VERSION:3",
- f"#EXT-X-TARGETDURATION:{HLS_SEGMENT_DURATION}",
- "#EXT-X-MEDIA-SEQUENCE:0",
- ]
- for i in range(n_segments):
- seg_dur = min(HLS_SEGMENT_DURATION, duration - i * HLS_SEGMENT_DURATION)
- if seg_dur <= 0:
- break
- lines.append(f"#EXTINF:{seg_dur:.3f},")
- lines.append(f"/hls/{file_id}/{i}.ts{token_param}")
- lines.append("#EXT-X-ENDLIST")
-
- return StreamingResponse(
- iter(["\n".join(lines)]),
- media_type="application/vnd.apple.mpegurl",
- )
-
- @app.get("/hls/{file_id}/{segment_index}.ts")
- async def hls_segment(
- file_id: str,
- segment_index: int,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Serve one HLS segment as MPEG-TS via ffmpeg transcoding."""
- entry = index.get_entry(file_id)
- if not entry or entry.type != "video":
- raise HTTPException(status_code=404, detail="Video not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- start_time = segment_index * HLS_SEGMENT_DURATION
-
- async def generate():
- proc = await asyncio.create_subprocess_exec(
- "ffmpeg", "-hide_banner", "-loglevel", "error",
- "-ss", str(start_time),
- "-i", str(file_path),
- "-t", str(HLS_SEGMENT_DURATION),
- "-c:v", "copy", "-c:a", "copy",
- "-f", "mpegts", "pipe:1",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.DEVNULL,
- )
- assert proc.stdout
- while chunk := await proc.stdout.read(65536):
- yield chunk
- await proc.wait()
-
- return StreamingResponse(generate(), media_type="video/mp2t")
-
- return app
-
-
-# ── Helpers ───────────────────────────────────────────────────────────────────
-
-def _media_type(filename: str) -> str:
- ext = Path(filename).suffix.lower()
- return {
- ".mp4": "video/mp4", ".mkv": "video/x-matroska",
- ".webm": "video/webm", ".avi": "video/x-msvideo",
- ".mp3": "audio/mpeg", ".flac": "audio/flac",
- ".ogg": "audio/ogg", ".opus": "audio/opus",
- ".jpg": "image/jpeg", ".png": "image/png",
- ".pdf": "application/pdf",
- }.get(ext, "application/octet-stream")
-
-
-def _probe_duration(path: Path) -> float | None:
- """Use ffprobe to get video duration in seconds."""
- try:
- result = subprocess.run(
- ["ffprobe", "-v", "quiet", "-print_format", "json",
- "-show_format", str(path)],
- capture_output=True, text=True, timeout=10,
- )
- data = json.loads(result.stdout)
- return float(data["format"]["duration"])
- except Exception:
- return None
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 f439e62..ede756e 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -41,7 +41,6 @@ from meshbay_common.crypto import (
from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
-from meshbay_node.transport.tls_cert import server_ssl_context
log = logging.getLogger(__name__)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/server.py b/packages/meshbay-node/src/meshbay_node/transport/server.py
deleted file mode 100644
index b77f1f2..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/server.py
+++ /dev/null
@@ -1,286 +0,0 @@
-"""
-MeshBay Node — TCP+TLS chunk server (MNP v1).
-
-Serves encrypted file chunks to authenticated clients over TLS.
-Each connection:
- 1. Client sends MNP handshake with JWT bearer token
- 2. Server verifies JWT offline (hub PK cached)
- 3. Client sends chunk requests
- 4. Server reads from disk, encrypts on-the-fly, signs, sends
-
-Wire protocol: length-prefixed msgpack (4-byte big-endian length header).
-All messages carry {"type": ..., "v": MNP_VERSION}.
-"""
-
-import asyncio
-import base64
-import logging
-import struct
-import time
-from pathlib import Path
-
-import blake3
-import jwt
-import msgpack
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- encrypt_chunk,
- sign_chunk,
- pk_to_b64,
-)
-from meshbay_common.protocol import MNP
-from meshbay_node.indexer import GroupIndex
-from meshbay_node.transport.tls_cert import server_ssl_context
-
-log = logging.getLogger(__name__)
-
-CHUNK_SIZE = 1024 * 1024 # 1 MB
-MAX_MSG = 64 * 1024 * 1024 # 64 MB max message size (safety)
-
-
-# ── Wire helpers ──────────────────────────────────────────────────────────────
-
-async def _send(writer: asyncio.StreamWriter, obj: dict) -> None:
- data = msgpack.packb(obj, use_bin_type=True)
- writer.write(struct.pack(">I", len(data)) + data)
- await writer.drain()
-
-async def _recv(reader: asyncio.StreamReader) -> dict:
- header = await reader.readexactly(4)
- length = struct.unpack(">I", header)[0]
- if length > MAX_MSG:
- raise ValueError(f"Message too large: {length}")
- data = await reader.readexactly(length)
- return msgpack.unpackb(data, raw=False)
-
-
-# ── Chunk serving ─────────────────────────────────────────────────────────────
-
-def _serve_chunk(
- sk_node: Ed25519PrivateKey,
- gek: bytes,
- file_path: Path,
- file_hash: bytes,
- chunk_index: int,
-) -> dict:
- """Read, encrypt, sign one chunk. Blocking — run in executor."""
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- plaintext = f.read(CHUNK_SIZE)
-
- pt_hash = blake3.blake3(plaintext).digest()
- ckey = derive_chunk_key(gek, file_hash, chunk_index)
- nonce, ct = encrypt_chunk(ckey, plaintext)
- ct_hash = blake3.blake3(ct).digest()
- sig = sign_chunk(sk_node, chunk_index, nonce, ct_hash)
-
- return {
- "type": MNP.FILE_CHUNK,
- "v": MNP_VERSION,
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "nonce_b64": base64.b64encode(nonce).decode(),
- "ct_b64": base64.b64encode(ct).decode(),
- "ct_hash_b64": base64.b64encode(ct_hash).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- "file_hash_b64": base64.b64encode(file_hash).decode(),
- }
-
-
-# ── Connection handler ────────────────────────────────────────────────────────
-
-class _ConnectionHandler:
- def __init__(
- self,
- reader: asyncio.StreamReader,
- writer: asyncio.StreamWriter,
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- gek: bytes,
- shared_root: Path,
- index: GroupIndex,
- groups: dict[str, dict] | None = None,
- ):
- self._reader = reader
- self._writer = writer
- self._sk_node = sk_node
- self._hub_pk_pem = hub_pk_pem
- self._gek = gek
- self._shared_root = shared_root
- self._index = index
- self._groups = groups
- self._peer = writer.get_extra_info("peername")
- self._user_id: str | None = None
- self._group_id: str | None = None
-
- async def handle(self) -> None:
- try:
- await self._handshake()
- await self._serve_loop()
- except asyncio.IncompleteReadError:
- log.debug("[%s] Client disconnected", self._peer)
- except Exception as e:
- log.warning("[%s] Error: %s", self._peer, e)
- await _send(self._writer, {"type": "error", "detail": str(e)})
- finally:
- self._writer.close()
-
- async def _handshake(self) -> None:
- msg = await _recv(self._reader)
- if msg.get("type") != MNP.HANDSHAKE:
- raise ValueError(f"Expected handshake, got {msg.get('type')!r}")
-
- token = msg.get("token", "")
- group_id = msg.get("group_id", "")
- try:
- decoded = jwt.decode(token, self._hub_pk_pem, algorithms=["EdDSA"])
- except Exception as e:
- raise PermissionError(f"Invalid JWT: {e}") from e
-
- if group_id and group_id not in decoded.get("groups", []):
- raise PermissionError("Not a member of this group")
-
- if group_id and self._groups and group_id not in self._groups:
- raise PermissionError("Group not hosted on this node")
-
- self._user_id = decoded["sub"]
- self._group_id = group_id
-
- if group_id and self._groups and group_id in self._groups:
- ctx = self._groups[group_id]
- self._gek = ctx["gek"]
- self._shared_root = ctx["shared_root"]
- self._index = ctx["index"]
-
- log.info("[%s] Handshake OK — user=%s group=%s", self._peer, self._user_id[:8], group_id[:8] if group_id else "none")
-
- await _send(self._writer, {
- "type": MNP.HANDSHAKE_ACK,
- "v": MNP_VERSION,
- "node_pk": pk_to_b64(self._sk_node.public_key()),
- })
-
- async def _serve_loop(self) -> None:
- loop = asyncio.get_event_loop()
- while True:
- msg = await _recv(self._reader)
- mtype = msg.get("type")
-
- if mtype == MNP.INDEX_SYNC:
- wire = self._index.serialize()
- await _send(self._writer, {
- "type": MNP.INDEX_SYNC,
- "v": MNP_VERSION,
- "index_b64": base64.b64encode(wire).decode(),
- })
-
- elif mtype == MNP.FILE_REQUEST:
- file_id = msg["file_id"]
- chunk_index = msg["chunk_index"]
-
- entry = self._index.get_entry(file_id)
- if entry is None:
- await _send(self._writer, {
- "type": "error",
- "detail": f"File not found: {file_id[:8]}",
- })
- continue
-
- file_path = self._shared_root / entry.path / entry.name
- if not file_path.exists():
- await _send(self._writer, {
- "type": "error", "detail": "File not on disk"})
- continue
-
- file_hash = bytes.fromhex(entry.id)
- chunk = await loop.run_in_executor(
- None, _serve_chunk,
- self._sk_node, self._gek, file_path, file_hash, chunk_index)
- await _send(self._writer, chunk)
-
- else:
- log.warning("[%s] Unknown message type: %s", self._peer, mtype)
-
-
-# ── Server ────────────────────────────────────────────────────────────────────
-
-class ChunkServer:
- """
- Async TCP+TLS server that serves encrypted file chunks.
-
- Usage:
- server = ChunkServer(
- host="0.0.0.0", port=19000,
- sk_node=sk, hub_pk_pem=pk_pem,
- gek=gek, shared_root=Path("/data"),
- index=group_index,
- )
- await server.start()
- # ... when shutting down:
- await server.stop()
- """
-
- def __init__(
- self,
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- gek: bytes,
- shared_root: Path,
- index: GroupIndex,
- host: str = "0.0.0.0",
- port: int = 19000,
- cert_path: Path | None = None,
- key_path: Path | None = None,
- groups: dict[str, dict] | None = None,
- ):
- self._sk_node = sk_node
- self._hub_pk_pem = hub_pk_pem
- self._gek = gek
- self._shared_root = shared_root
- self._index = index
- self._host = host
- self._port = port
- self._cert_path = cert_path
- self._key_path = key_path
- self._groups = groups
- self._server: asyncio.Server | None = None
-
- @property
- def port(self) -> int:
- return self._port
-
- async def start(self) -> None:
- ssl_ctx = server_ssl_context(
- cert_path=self._cert_path or Path.home() / ".config/meshbay/node_tls.crt",
- key_path=self._key_path or Path.home() / ".config/meshbay/node_tls.key",
- )
- self._server = await asyncio.start_server(
- self._handle_connection,
- host=self._host,
- port=self._port,
- ssl=ssl_ctx,
- )
- log.info("ChunkServer listening on %s:%d (TLS)", self._host, self._port)
-
- async def stop(self) -> None:
- if self._server:
- self._server.close()
- await self._server.wait_closed()
- self._server = None
- log.info("ChunkServer stopped")
-
- async def _handle_connection(
- self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
- ) -> None:
- handler = _ConnectionHandler(
- reader, writer,
- self._sk_node, self._hub_pk_pem,
- self._gek, self._shared_root, self._index,
- groups=self._groups,
- )
- await handler.handle()
diff --git a/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py b/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
index 1354ac9..374fd08 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
@@ -1,17 +1,18 @@
"""
-Self-signed TLS certificate generation for the node.
+Self-signed TLS certificate generation for the node's QUIC listener.
The cert is used for transport confidentiality only.
Node identity is verified via Ed25519 PK (from hub), not TLS cert chain.
-Clients connect with ssl.CERT_NONE + verify Ed25519 at the MNP handshake layer.
-Certificate is generated once and cached at ~/.config/meshbay/node_tls.pem/.key.
+Phase 11.5 note: the certificate hash is also the intended channel-binding anchor for
+the QUIC handshake proof (11.5.6), since QUIC has no DTLS fingerprint to bind to.
+
+Certificate is generated once and cached at ~/.config/meshbay/node_tls.crt/.key.
"""
import logging
import os
from pathlib import Path
-import ssl
import datetime
import ipaddress
@@ -69,27 +70,6 @@ def generate_self_signed_cert(
return cert_path, key_path
-def server_ssl_context(
- cert_path: Path = DEFAULT_CERT,
- key_path: Path = DEFAULT_KEY,
-) -> ssl.SSLContext:
- """SSL context for the node's TCP server."""
- if not cert_path.exists() or not key_path.exists():
- generate_self_signed_cert(cert_path, key_path)
-
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
- ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
- ctx.minimum_version = ssl.TLSVersion.TLSv1_3
- return ctx
-
-
-def client_ssl_context() -> ssl.SSLContext:
- """
- SSL context for clients connecting to a node.
- CERT_NONE because we verify node identity via Ed25519 PK at the MNP layer.
- """
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
- ctx.check_hostname = False
- ctx.verify_mode = ssl.CERT_NONE
- ctx.minimum_version = ssl.TLSVersion.TLSv1_3
- return ctx
+# `server_ssl_context()` / `client_ssl_context()` were removed in Phase 11.5 along with
+# the TCP+TLS transport they served. QUIC builds its own QuicConfiguration and calls
+# generate_self_signed_cert() directly.
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index b4885af..31deb66 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -48,7 +48,6 @@ def create_ui_app(state: dict) -> FastAPI:
"status": state.get("status", "starting"),
"hub_url": state.get("hub_url", ""),
"username": state.get("username", ""),
- "node_port": state.get("node_port", 0),
"quic_port": state.get("quic_port", 0),
"endpoint_hint": state.get("endpoint_hint"),
"group_count": len(groups_ctx),
@@ -154,9 +153,7 @@ def create_ui_app(state: dict) -> FastAPI:
return {
"hub_url": config.hub.url,
"username": config.hub.username,
- "node_port": config.node.port,
"quic_port": config.node.quic_port,
- "http_port": config.node.http_port,
"ui_port": config.node.ui_port,
"data_dir": str(config.data_dir),
"groups": [
@@ -493,8 +490,7 @@ def _render_page(state: dict) -> str:
<h2>Node Configuration</h2>
<div class="card">
<p><b>Hub:</b> {state.get("hub_url", "—")}</p>
- <p><b>QUIC port:</b> {state.get("quic_port", "—")} &mdash;
- <b>TCP port:</b> {state.get("node_port", "—")}</p>
+ <p><b>QUIC port:</b> {state.get("quic_port", "—")}</p>
<p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p>
</div>