# Bundle Mesh — (2) infra, (3) prompts Claude, (4) server skeleton Ce document contient **les fichiers** demandés, prêts à être copiés dans le dépôt. Chaque section indique le chemin du fichier. --- ## 2) `infra/docker-compose.yml` (final, avec reverse-proxy TLS) ```yaml services: caddy: image: caddy:2 restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: - mesh-server mesh-server: build: ../server restart: unless-stopped environment: - MESH_JWT_SECRET=${MESH_JWT_SECRET} - MESH_PUBLIC_URL=${MESH_PUBLIC_URL} - GOTIFY_URL=${GOTIFY_URL} - GOTIFY_TOKEN=${GOTIFY_TOKEN} - STUN_URL=${STUN_URL} - TURN_URL=${TURN_URL} - TURN_REALM=${TURN_REALM} expose: - "8000" gotify: image: gotify/server:latest restart: unless-stopped environment: - GOTIFY_DEFAULTUSER_NAME=${GOTIFY_DEFAULTUSER_NAME:-admin} - GOTIFY_DEFAULTUSER_PASS=${GOTIFY_DEFAULTUSER_PASS:-adminadmin} ports: - "8080:80" # optionnel: exposer en LAN uniquement volumes: - gotify_data:/app/data coturn: image: coturn/coturn:latest restart: unless-stopped network_mode: "host" command: > -n --log-file=stdout --external-ip=${TURN_EXTERNAL_IP} --realm=${TURN_REALM} --user=${TURN_USER}:${TURN_PASS} --listening-port=3478 --min-port=49160 --max-port=49200 --fingerprint --lt-cred-mech --no-multicast-peers --no-cli volumes: gotify_data: caddy_data: caddy_config: ``` ### `infra/caddy/Caddyfile` ```caddyfile { email {$CADDY_EMAIL} } {$MESH_DOMAIN} { encode gzip @ws { path /ws } reverse_proxy @ws mesh-server:8000 { header_up Host {host} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-For {remote} } reverse_proxy mesh-server:8000 { header_up Host {host} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-For {remote} } } ``` ### `infra/.env.example` ```env # Public MESH_DOMAIN=mesh.example.com MESH_PUBLIC_URL=https://mesh.example.com CADDY_EMAIL=admin@example.com # Server MESH_JWT_SECRET=change_me_super_secret GOTIFY_URL=http://gotify:80 GOTIFY_TOKEN=CHANGE_ME_GOTIFY_APP_TOKEN # ICE STUN_URL=stun:stun.l.google.com:19302 TURN_URL=turn:turn.example.com:3478 TURN_REALM=mesh # TURN (coturn) TURN_EXTERNAL_IP=203.0.113.10 TURN_USER=mesh TURN_PASS=change_me_turn_password ``` ### Notes réseau - Ouvrir : **443 TCP** (Mesh via Caddy), **3478 UDP/TCP** (TURN), **49160-49200 UDP** (media relay TURN). - Gotify peut rester LAN-only. --- ## 3) Prompts Claude Code découpés ### `docs/claude-prompt-server.md` ```markdown # Claude Code Prompt — Mesh Server (FastAPI control plane) Génère le squelette du dossier `server/` pour Mesh. Contraintes - Python 3.12+ - FastAPI - WebSocket /ws - JWT auth - SQLite (MVP) - Server = control plane (aucun média ni fichier transitant) Fonctions - POST /auth/login (dev) -> JWT - POST /rooms -> create - POST /rooms/{room_id}/join - POST /caps -> capability token TTL 120s - GET /health WebSocket /ws - Auth JWT au handshake - presence - relay des messages: rtc.offer / rtc.answer / rtc.ice - relay contrôle: call.request, screen.share.request, share.file.request, share.folder.request, terminal.share.request, terminal.control.take, terminal.control.release - Validation: refuser relay si capability manquante/expirée pour l’action Notifications - Adapter Gotify (GOTIFY_URL + GOTIFY_TOKEN) - Router: notify sur chat.message.created, call.missed, share.completed, terminal.share.started Qualité - Typage, structure modules, tests stubs - .env.example + instructions README serveur Deliverable - Tous les fichiers nécessaires pour `uvicorn app.main:app --reload`. ``` ### `docs/claude-prompt-agent.md` ```markdown # Claude Code Prompt — Mesh Agent (Python, multi-OS) Génère le squelette du dossier `agent/` pour Mesh. Contraintes - Python 3.12+ - asyncio - httpx + websockets - config fichier (yaml/toml) + env Fonctions MVP - Connexion au Mesh Server (REST + WS) - Enregistrement device_id - Réception events - Notifications Gotify directes (gotify_url + token) - Terminal share preview: - spawn PTY local (bash) - capture output - abstraction transport (DataChannel prévu), MVP autorisé sur WS mais interface doit permettre swap Bonus V1 - File send via WebRTC DataChannel (interfaces + stubs acceptés si lib WebRTC non choisie) - Tray + autostart (stubs par OS) Qualité - Modules: core/config, mesh client, notifications, terminal, transfer - Tests stubs - README agent ``` ### `docs/claude-prompt-client.md` ```markdown # Claude Code Prompt — Mesh Client (Web) Génère le squelette du dossier `client/`. Stack - Vite + React + TypeScript - WebSocket client - WebRTC placeholders - xterm.js terminal viewer Écrans MVP - Login - Rooms: create/join - Room view: chat minimal + boutons (call/screen/file/terminal) Réseau - REST login - WS connect - Signaling handlers rtc.offer/answer/ice Terminal - composant xterm.js capable d’afficher TERM_OUT Qualité - code propre, modules lib/api.ts, lib/ws.ts, lib/rtc.ts - README client ``` --- ## 4) Server skeleton (FastAPI) — fichiers prêts à coller ### `server/pyproject.toml` ```toml [project] name = "mesh-server" version = "0.1.0" dependencies = [ "fastapi>=0.110", "uvicorn[standard]>=0.27", "python-dotenv>=1.0", "pyjwt>=2.8", "pydantic>=2.6", ] requires-python = ">=3.12" [tool.uvicorn] factory = false ``` ### `server/app/main.py` ```python from __future__ import annotations import os import time import json import uuid from typing import Any, Dict, Optional import jwt from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel JWT_SECRET = os.getenv("MESH_JWT_SECRET", "dev_secret_change_me") JWT_ALG = "HS256" CAP_TTL_SECONDS = int(os.getenv("MESH_CAP_TTL", "120")) app = FastAPI(title="Mesh Server", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"] , allow_headers=["*"], ) # In-memory MVP stores (replace by DB later) ROOMS: Dict[str, Dict[str, Any]] = {} # room_id -> {name, members:set(peer_id/user_id)} PEERS: Dict[str, Dict[str, Any]] = {} # peer_id -> {user_id, ws} # -------------------- Models -------------------- class LoginReq(BaseModel): username: str password: str class LoginResp(BaseModel): token: str class CreateRoomReq(BaseModel): name: str class CreateRoomResp(BaseModel): room_id: str class JoinRoomReq(BaseModel): room_id: str class CapsReq(BaseModel): room_id: str peer_id: str caps: list[str] target_peer_id: Optional[str] = None max_size: Optional[int] = None class CapsResp(BaseModel): cap_token: str exp: int # -------------------- Helpers -------------------- def now_ts() -> int: return int(time.time()) def issue_jwt(payload: dict, ttl_seconds: int) -> str: data = dict(payload) data["exp"] = now_ts() + ttl_seconds return jwt.encode(data, JWT_SECRET, algorithm=JWT_ALG) def verify_jwt(token: str) -> dict: try: return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid token") def ws_send(ws: WebSocket, msg: dict) -> None: # FastAPI WS requires await; this helper exists for symmetry; see async use. raise NotImplementedError def make_event(event_type: str, _from: str, to: str, payload: dict) -> dict: return { "type": event_type, "id": str(uuid.uuid4()), "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "from": _from, "to": to, "payload": payload, } # -------------------- REST -------------------- @app.get("/health") def health() -> dict: return {"ok": True, "ts": now_ts()} @app.post("/auth/login", response_model=LoginResp) def login(req: LoginReq) -> LoginResp: # Dev-only auth if not req.username or not req.password: raise HTTPException(status_code=400, detail="Missing credentials") token = issue_jwt({"sub": req.username, "user_id": req.username}, ttl_seconds=3600) return LoginResp(token=token) @app.post("/rooms", response_model=CreateRoomResp) def create_room(req: CreateRoomReq) -> CreateRoomResp: room_id = str(uuid.uuid4()) ROOMS[room_id] = {"name": req.name, "members": set()} return CreateRoomResp(room_id=room_id) @app.post("/rooms/{room_id}/join") def join_room(room_id: str) -> dict: if room_id not in ROOMS: raise HTTPException(status_code=404, detail="Room not found") return {"ok": True} @app.post("/caps", response_model=CapsResp) def caps(req: CapsReq) -> CapsResp: if req.room_id not in ROOMS: raise HTTPException(status_code=404, detail="Room not found") exp = now_ts() + CAP_TTL_SECONDS cap_token = jwt.encode( { "sub": req.peer_id, "room_id": req.room_id, "caps": req.caps, "target_peer_id": req.target_peer_id, "max_size": req.max_size, "exp": exp, }, JWT_SECRET, algorithm=JWT_ALG, ) return CapsResp(cap_token=cap_token, exp=exp) # -------------------- WebSocket -------------------- async def ws_auth(ws: WebSocket) -> dict: # token can be provided as query param: /ws?token=... token = ws.query_params.get("token") if not token: await ws.close(code=4401) raise HTTPException(status_code=401, detail="Missing token") data = verify_jwt(token) return data def cap_allows(cap_token: str | None, required_cap: str, room_id: str, from_peer: str, target_peer: str | None) -> bool: if not cap_token: return False try: data = jwt.decode(cap_token, JWT_SECRET, algorithms=[JWT_ALG]) except Exception: return False if data.get("room_id") != room_id: return False if data.get("sub") != from_peer: return False caps = set(data.get("caps") or []) if required_cap not in caps: return False tp = data.get("target_peer_id") if target_peer and tp and tp != target_peer: return False return True CAP_MAP = { # signaling / media control "call.request": "call", "screen.share.request": "screen", # shares "share.file.request": "share:file", "share.folder.request": "share:folder", # terminal "terminal.share.request": "terminal:view", "terminal.control.take": "terminal:control", "terminal.control.release": "terminal:control", # rtc relay is gated by the feature that initiated it; for MVP we accept rtc.* if a cap_token is present "rtc.offer": "rtc", "rtc.answer": "rtc", "rtc.ice": "rtc", } @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket): await ws.accept() auth = await ws_auth(ws) peer_id = str(uuid.uuid4()) user_id = auth.get("user_id") PEERS[peer_id] = {"user_id": user_id, "ws": ws} # welcome await ws.send_text(json.dumps(make_event("system.welcome", "server", peer_id, {"peer_id": peer_id, "user_id": user_id}))) try: while True: raw = await ws.receive_text() msg = json.loads(raw) mtype = msg.get("type") payload = msg.get("payload") or {} room_id = payload.get("room_id") target = payload.get("target") or payload.get("target_peer_id") cap_token = payload.get("cap_token") # room join bookkeeping (MVP) if mtype == "room.join": if not room_id or room_id not in ROOMS: await ws.send_text(json.dumps(make_event("error", "server", peer_id, {"code": "ROOM_NOT_FOUND"}))) continue ROOMS[room_id]["members"].add(peer_id) # broadcast joined for member in list(ROOMS[room_id]["members"]): mws = PEERS.get(member, {}).get("ws") if mws: await mws.send_text(json.dumps(make_event("room.joined", "server", room_id, {"peer_id": peer_id, "room_id": room_id}))) continue required = CAP_MAP.get(mtype) if required: if not room_id: await ws.send_text(json.dumps(make_event("error", "server", peer_id, {"code": "MISSING_ROOM_ID"}))) continue # Special-case rtc.*: require any valid cap_token with "rtc" cap OR reuse the feature cap. if mtype.startswith("rtc."): ok = cap_allows(cap_token, "rtc", room_id, peer_id, target) if not ok: await ws.send_text(json.dumps(make_event("error", "server", peer_id, {"code": "CAP_REQUIRED", "detail": "rtc"}))) continue else: ok = cap_allows(cap_token, required, room_id, peer_id, target) if not ok: await ws.send_text(json.dumps(make_event("error", "server", peer_id, {"code": "CAP_REQUIRED", "detail": required}))) continue # Relay: to specific target peer (typical for rtc.*) if target and target in PEERS: t_ws = PEERS[target]["ws"] await t_ws.send_text(json.dumps(make_event(mtype, peer_id, target, payload))) continue # Relay: to room if room_id and room_id in ROOMS: for member in list(ROOMS[room_id]["members"]): mws = PEERS.get(member, {}).get("ws") if mws: await mws.send_text(json.dumps(make_event(mtype, peer_id, room_id, payload))) continue # fallback await ws.send_text(json.dumps(make_event("error", "server", peer_id, {"code": "UNROUTABLE"}))) except WebSocketDisconnect: pass finally: # cleanup PEERS.pop(peer_id, None) for r in ROOMS.values(): r["members"].discard(peer_id) ``` ### `server/.env.example` ```env MESH_JWT_SECRET=change_me MESH_PUBLIC_URL=http://localhost:8000 GOTIFY_URL=http://localhost:8080 GOTIFY_TOKEN= STUN_URL=stun:stun.l.google.com:19302 TURN_URL=turn:turn.example.com:3478 TURN_REALM=mesh MESH_CAP_TTL=120 ``` ### `server/README.md` ```markdown # Mesh Server (MVP) ## Run locally ```bash cd server python -m venv .venv && . .venv/bin/activate pip install -U pip pip install -e . uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` ## Endpoints - GET /health - POST /auth/login - POST /rooms - POST /caps - WS /ws?token=... ## Notes - Ce MVP garde les rooms/peers en mémoire. - Remplacer par DB + persistance en V1. ``` ``` --- ## À faire ensuite (aligné P2P) - Émettre un vrai `rtc` cap_token par feature (call/screen/share/terminal) - Ajouter stockage DB - Ajouter router Gotify + préférences - Client/Agent: utiliser DataChannel réel