first
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user