591 lines
15 KiB
Markdown
591 lines
15 KiB
Markdown
# 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
|
||
|