15 KiB
15 KiB
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)
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
{
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
# 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
# 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
# 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
# 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
[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
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
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
# 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
rtccap_token par feature (call/screen/share/terminal) - Ajouter stockage DB
- Ajouter router Gotify + préférences
- Client/Agent: utiliser DataChannel réel