Files
mesh/docs/bundle_2_3_4.md
Gilles Soulier 1d177e96a6 first
2026-01-05 13:20:54 +01:00

15 KiB
Raw Blame History

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 laction

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 dafficher 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 rtc cap_token par feature (call/screen/share/terminal)
  • Ajouter stockage DB
  • Ajouter router Gotify + préférences
  • Client/Agent: utiliser DataChannel réel