This commit is contained in:
Gilles Soulier
2026-01-05 13:13:08 +01:00
parent 8e14adafc6
commit 1d177e96a6
149 changed files with 29541 additions and 1 deletions

31
server/.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Created by: Claude
# Date: 2026-01-01
# Purpose: Environment variables template for Mesh Server
# Refs: deployment.md
# Server Configuration
MESH_PUBLIC_URL=https://mesh.example.com
MESH_HOST=10.0.0.50
MESH_PORT=8065
# Security
MESH_JWT_SECRET=your-secret-key-change-this-in-production
MESH_JWT_ALGORITHM=HS256
MESH_JWT_ACCESS_TOKEN_EXPIRE_MINUTES=120
# Gotify Integration
GOTIFY_URL=https://gotify.example.com
GOTIFY_TOKEN=your-gotify-token
# WebRTC / ICE
STUN_URL=stun:stun.l.google.com:19302
TURN_HOST=turn.example.com
TURN_PORT=3478
TURN_USER=mesh
TURN_PASS=change-this-in-production
# Database (optional)
DATABASE_URL=sqlite:///./mesh.db
# Logging
LOG_LEVEL=INFO

262
server/CLAUDE.md Normal file
View File

@@ -0,0 +1,262 @@
# CLAUDE.md — Mesh Server
This file provides server-specific guidance for the Mesh control plane implementation.
## Server Role
The Mesh Server is the **control plane only**. It:
- Authenticates users and agents
- Manages rooms and ACL
- Issues capability tokens (60-180s TTL)
- Provides WebRTC signaling
- Orchestrates P2P sessions (QUIC)
- Sends Gotify notifications
**The server NEVER transports media or heavy data.** This is a fundamental architectural constraint.
## Technology Stack
- **Python 3.12+** (IMPORTANT: Utiliser `python3` pour les commandes, pas `python`)
- **FastAPI** for REST API
- **WebSocket** for real-time events
- **JWT** for authentication
- **SQLAlchemy** for database (SQLite initially, PostgreSQL for production)
- **httpx** for Gotify integration
- **Docker** recommandé pour éviter les problèmes de compatibilité de versions
## Project Structure
```
server/
├── src/
│ ├── main.py # FastAPI app entry point
│ ├── config.py # Configuration management
│ ├── auth/
│ │ ├── jwt.py # JWT token management
│ │ ├── capabilities.py # Capability token generation
│ │ └── models.py # Auth data models
│ ├── rooms/
│ │ ├── manager.py # Room management
│ │ ├── acl.py # Access control lists
│ │ └── models.py # Room data models
│ ├── websocket/
│ │ ├── connection.py # WebSocket connection manager
│ │ ├── events.py # Event handlers
│ │ └── router.py # WebSocket routing
│ ├── signaling/
│ │ ├── webrtc.py # WebRTC signaling
│ │ └── p2p.py # QUIC P2P session orchestration
│ ├── notifications/
│ │ └── gotify.py # Gotify client
│ └── db/
│ ├── models.py # Database models
│ └── session.py # Database session management
├── tests/
├── requirements.txt
├── Dockerfile
└── CLAUDE.md
```
## Development Commands
### Setup Local (avec virtualenv)
```bash
cd server
python3 -m venv venv
source venv/bin/activate # or venv\Scripts\activate on Windows
pip install -r requirements.txt
cp .env.example .env
# Éditer .env avec votre configuration
```
### Setup avec Docker (Recommandé)
```bash
cd server
cp .env.example .env
# Éditer .env avec votre configuration
# Construire l'image
docker build -t mesh-server:dev .
# Lancer le serveur
docker run -d --name mesh-server -p 8000:8000 --env-file .env mesh-server:dev
# Voir les logs
docker logs mesh-server -f
# Arrêter et supprimer
docker rm -f mesh-server
```
### Run Development Server (Local)
```bash
python3 -m uvicorn src.main:app --reload --host 0.0.0.0 --port 8000
```
### Run Tests
```bash
# Tester l'API (avec le serveur lancé)
python3 test_api.py
# Tests unitaires
pytest tests/
```
### Type Checking
```bash
mypy src/
```
## Event Protocol
All WebSocket events follow this structure (see [protocol_events_v_2.md](../protocol_events_v_2.md)):
```json
{
"type": "event.type",
"id": "uuid",
"timestamp": "ISO-8601",
"from": "peer_id|device_id|server",
"to": "peer_id|device_id|room_id|server",
"payload": {}
}
```
## Capability Tokens
Capability tokens are **short-lived JWTs** (60-180s) that authorize specific P2P actions:
```python
{
"sub": "peer_id or device_id",
"room_id": "room_id",
"caps": ["call", "screen", "share:file", "terminal:view"],
"exp": timestamp,
"target_peer_id": "optional",
"max_size": "optional",
"max_rate": "optional"
}
```
**Critical rules**:
- Never accept WebRTC offers without valid capability tokens
- Never create P2P sessions without valid capability tokens
- Validate token expiration on every use
- Terminal control requires explicit `terminal:control` capability
## Security Checklist
- [ ] All WebSocket connections authenticated with JWT
- [ ] Capability tokens have short TTL (60-180s)
- [ ] Room ACL enforced server-side
- [ ] No sensitive data in logs
- [ ] TURN credentials are temporary (when implemented)
- [ ] Rate limiting on auth endpoints
- [ ] Input validation on all endpoints
## Database Schema
Key entities:
- **User**: user_id, username, hashed_password, created_at
- **Device**: device_id, user_id, name, last_seen
- **Room**: room_id, name, owner_user_id, created_at
- **RoomMember**: room_id, user_id, role (owner/member/guest)
- **Session**: session_id, peer_id, device_id, room_id, expires_at
## WebSocket Events to Implement
### System
- `system.hello` (client → server)
- `system.welcome` (server → client)
### Rooms
- `room.join`, `room.joined`
- `room.leave`, `room.left`
- `presence.update`
### Chat
- `chat.message.send`, `chat.message.created`
### WebRTC Signaling
- `rtc.offer`, `rtc.answer`, `rtc.ice`
### P2P Sessions (QUIC)
- `p2p.session.request`, `p2p.session.created`
- `p2p.session.closed`
### Terminal Control
- `terminal.control.take`, `terminal.control.granted`
- `terminal.control.release`
## Gotify Integration
Send notifications for:
- New chat messages (when user offline)
- Missed calls
- Share completed
- Terminal share started
- Agent offline
**Never include secrets in notification text.**
## Error Handling
Return structured errors:
```json
{
"type": "error",
"code": "CAP_EXPIRED",
"message": "Capability token expired"
}
```
Error codes:
- `CAP_REQUIRED`: Missing capability token
- `CAP_EXPIRED`: Token expired
- `CAP_INVALID`: Token invalid or tampered
- `ROOM_NOT_FOUND`: Room doesn't exist
- `ACCESS_DENIED`: User not in room or insufficient permissions
- `P2P_SESSION_DENIED`: P2P session creation failed
- `UNROUTABLE`: Peer not connected
## Testing Strategy
1. **Unit tests**: Individual functions (JWT, capabilities, validation)
2. **Integration tests**: WebSocket event flows
3. **E2E tests**: Complete user journeys (join room, send message, start call)
## Notes sur Python et Dépendances
### Python 3.13 Compatibility
⚠️ **Important**: Les dépendances actuelles (`pydantic==2.5.3` avec `pydantic-core==2.14.6`) ne sont pas compatibles avec Python 3.13.
**Solutions**:
1. **Docker (Recommandé)**: Utiliser l'image Docker qui utilise Python 3.12
2. **Mise à jour**: Upgrader vers `pydantic>=2.10` pour Python 3.13 (nécessite tests de régression)
3. **Downgrade**: Installer Python 3.12 localement
### Dépendances Importantes
- `pydantic[email]==2.5.3` - Inclut `email-validator` pour la validation des emails
- `passlib[bcrypt]==1.7.4` + `bcrypt==4.1.2` - Hash des mots de passe
- `python-jose[cryptography]==3.3.0` + `pyjwt[crypto]==2.8.0` - JWT tokens
- `sqlalchemy==2.0.25` + `alembic==1.13.1` - ORM et migrations
### Commandes Python
Toujours utiliser `python3` explicitement sur les systèmes Unix/Linux:
```bash
python3 -m pip install -r requirements.txt
python3 -m uvicorn src.main:app --reload
python3 test_api.py
```
## Performance Considerations
- Keep WebSocket connections lightweight
- Use connection pooling for database
- Cache room membership lookups
- Implement backpressure for high-frequency events
- Monitor active connections count
---
**Remember**: The server is control plane only. Any attempt to route media or large data through the server violates the architecture.

21
server/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Created by: Claude
# Date: 2026-01-01
# Purpose: Dockerfile for Mesh Server
# Refs: deployment.md
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Expose port
EXPOSE 8000
# Run server
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

273
server/README.md Normal file
View File

@@ -0,0 +1,273 @@
<!--
Created by: Claude
Date: 2026-01-02
Purpose: Documentation du serveur Mesh
Refs: server/CLAUDE.md
-->
# Mesh Server
Serveur de control plane pour la plateforme Mesh.
## Installation
### Option 1: Docker (Recommandé)
```bash
# Copier et configurer l'environnement
cp .env.example .env
# Éditer .env avec vos valeurs (notamment MESH_JWT_SECRET)
# Construire l'image Docker
docker build -t mesh-server .
# Lancer le serveur
docker run -d --name mesh-server -p 8000:8000 --env-file .env mesh-server
# Voir les logs
docker logs mesh-server -f
```
### Option 2: Installation Locale
**Note**: Nécessite Python 3.12+ (Python 3.13 non supporté actuellement)
```bash
# Créer un environnement virtuel
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Installer les dépendances
pip install -r requirements.txt
# Copier et configurer l'environnement
cp .env.example .env
# Éditer .env avec vos valeurs
```
## Configuration
Éditer le fichier `.env`:
```bash
# Serveur
MESH_PUBLIC_URL=http://localhost:8000
MESH_JWT_SECRET=your-secret-key-min-32-chars
# Gotify
GOTIFY_URL=http://gotify:8080
GOTIFY_TOKEN=your-gotify-token
# TURN/STUN
STUN_URL=stun:stun.l.google.com:19302
TURN_HOST=turn.example.com
# Base de données
DATABASE_URL=sqlite:///./mesh.db
```
## Démarrage
### Avec Docker
```bash
# Le serveur est déjà lancé après docker run
# Pour voir les logs:
docker logs mesh-server -f
# Pour arrêter:
docker stop mesh-server
# Pour redémarrer:
docker start mesh-server
```
### En local
```bash
# Lancer le serveur en mode développement
python3 -m uvicorn src.main:app --reload
# Ou avec le script
python3 -m src.main
```
Le serveur démarre sur http://localhost:8000
## API Documentation
Documentation interactive disponible sur:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Endpoints Principaux
### Authentification
- `POST /api/auth/register` - Créer un compte
- `POST /api/auth/login` - Se connecter
- `GET /api/auth/me` - Informations utilisateur
- `POST /api/auth/capability` - Demander un capability token
### Rooms
- `POST /api/rooms/` - Créer une room
- `GET /api/rooms/` - Lister mes rooms
- `GET /api/rooms/{room_id}` - Détails d'une room
- `GET /api/rooms/{room_id}/members` - Membres d'une room
### WebSocket
- `WS /ws?token=JWT_TOKEN` - Connexion temps réel
## Structure du Projet
```
server/
├── src/
│ ├── main.py # Point d'entrée
│ ├── config.py # Configuration
│ ├── api/ # Routes API REST
│ │ ├── auth.py # Authentification
│ │ └── rooms.py # Gestion des rooms
│ ├── auth/ # Authentification & sécurité
│ │ ├── security.py # JWT, hashing, capability tokens
│ │ ├── schemas.py # Schémas Pydantic
│ │ └── dependencies.py # Dépendances FastAPI
│ ├── db/ # Base de données
│ │ ├── base.py # Configuration SQLAlchemy
│ │ └── models.py # Modèles ORM
│ └── websocket/ # WebSocket temps réel
│ ├── manager.py # Gestionnaire de connexions
│ ├── events.py # Types d'événements
│ └── handlers.py # Handlers d'événements
├── alembic/ # Migrations
├── tests/ # Tests
├── requirements.txt
├── .env.example
└── Dockerfile
```
## Développement
### Tests
```bash
# Test de l'API (avec serveur lancé)
python3 test_api.py
# Tests unitaires
pytest tests/
```
### Migrations
```bash
# Créer une migration
alembic revision --autogenerate -m "Description"
# Appliquer les migrations
alembic upgrade head
# Revenir en arrière
alembic downgrade -1
```
### Linting
```bash
# Type checking
mypy src/
# Formatage
black src/
# Linting
flake8 src/
```
## Événements WebSocket
Voir [docs/protocol_events_v_2.md](../docs/protocol_events_v_2.md) pour le protocole complet.
### Exemples
**system.hello**:
```json
{
"type": "system.hello",
"from": "peer_123",
"to": "server",
"payload": {
"peer_type": "client",
"version": "0.1.0"
}
}
```
**room.join**:
```json
{
"type": "room.join",
"from": "peer_123",
"to": "server",
"payload": {
"room_id": "room_uuid"
}
}
```
**chat.message.send**:
```json
{
"type": "chat.message.send",
"from": "peer_123",
"to": "server",
"payload": {
"room_id": "room_uuid",
"content": "Hello world"
}
}
```
## Sécurité
- Tous les endpoints protégés nécessitent un JWT Bearer token
- Les capability tokens ont un TTL court (60-180s)
- Les mots de passe sont hashés avec bcrypt
- Les WebSocket nécessitent un token JWT valide
Voir [docs/security.md](../docs/security.md) pour plus de détails.
## Variables d'Environnement
| Variable | Description | Défaut |
|----------|-------------|--------|
| MESH_PUBLIC_URL | URL publique du serveur | http://localhost:8000 |
| MESH_HOST | Host d'écoute | 0.0.0.0 |
| MESH_PORT | Port d'écoute | 8000 |
| MESH_JWT_SECRET | Secret pour JWT | (requis) |
| MESH_JWT_ALGORITHM | Algorithme JWT | HS256 |
| MESH_JWT_ACCESS_TOKEN_EXPIRE_MINUTES | Expiration token | 120 |
| GOTIFY_URL | URL Gotify | (requis) |
| GOTIFY_TOKEN | Token Gotify | (requis) |
| STUN_URL | URL STUN | stun:stun.l.google.com:19302 |
| TURN_HOST | Host TURN | (optionnel) |
| TURN_PORT | Port TURN | 3478 |
| DATABASE_URL | URL base de données | sqlite:///./mesh.db |
| LOG_LEVEL | Niveau de log | INFO |
## Troubleshooting
### Erreur de connexion à la DB
```bash
# Vérifier que la DB existe
ls -la mesh.db
# Créer les tables manuellement
python -c "from src.db.base import Base, engine; Base.metadata.create_all(engine)"
```
### Token JWT invalide
Vérifier que `MESH_JWT_SECRET` est défini et identique entre serveur et client.
### WebSocket déconnecté immédiatement
Vérifier que le token JWT est passé en query param: `/ws?token=YOUR_TOKEN`

53
server/alembic.ini Normal file
View File

@@ -0,0 +1,53 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Configuration Alembic pour migrations de base de données
# Refs: server/CLAUDE.md
[alembic]
# Chemin vers le dossier des migrations
script_location = alembic
# Template pour les noms de fichiers de migration
file_template = %%(rev)s_%%(slug)s
# Fuseau horaire
timezone = UTC
# Autres configurations
truncate_slug_length = 40
revision_environment = false
sqlalchemy.url = sqlite:///./mesh.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

74
server/alembic/env.py Normal file
View File

@@ -0,0 +1,74 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Configuration de l'environnement Alembic
# Refs: server/CLAUDE.md
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import sys
from pathlib import Path
# Ajouter le dossier src au path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.db.base import Base
from src.db.models import User, Device, Room, RoomMember, Message, P2PSession
from src.config import settings
# Alembic Config object
config = context.config
# Interpréter le fichier de configuration pour les loggers
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Métadonnées des modèles pour autogenerate
target_metadata = Base.metadata
# Overrider l'URL de la DB depuis les settings
config.set_main_option("sqlalchemy.url", settings.database_url)
def run_migrations_offline() -> None:
"""
Exécuter les migrations en mode 'offline'.
Configure le contexte avec juste une URL, sans créer d'Engine.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""
Exécuter les migrations en mode 'online'.
Crée un Engine et associe une connexion au contexte.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

36
server/requirements.txt Normal file
View File

@@ -0,0 +1,36 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Python dependencies for Mesh Server
# Refs: CLAUDE.md
# Web Framework
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# WebSocket
websockets==12.0
# Authentication & Security
pyjwt[crypto]==2.8.0
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
bcrypt==4.1.2
# HTTP Client (for Gotify)
httpx==0.26.0
# Data Validation
pydantic[email]==2.5.3
pydantic-settings==2.1.0
# Database (SQLite/PostgreSQL)
sqlalchemy==2.0.25
alembic==1.13.1
# Utils
python-dotenv==1.0.0
# Development
pytest==7.4.4
pytest-asyncio==0.23.3

4
server/src/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
# Created by: Claude
# Date: 2026-01-01
# Purpose: Server package initialization
# Refs: CLAUDE.md

View File

@@ -0,0 +1,4 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Package des routes API
# Refs: server/CLAUDE.md

206
server/src/api/auth.py Normal file
View File

@@ -0,0 +1,206 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Routes API pour l'authentification
# Refs: server/CLAUDE.md, docs/security.md
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import uuid
from datetime import timedelta
from ..db.base import get_db
from ..db.models import User
from ..auth.schemas import UserCreate, UserLogin, Token, CapabilityTokenRequest, CapabilityTokenResponse
from ..auth.security import (
get_password_hash,
verify_password,
create_access_token,
create_capability_token
)
from ..auth.dependencies import get_current_active_user
router = APIRouter(prefix="/api/auth", tags=["authentication"])
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
"""
Enregistrer un nouvel utilisateur.
Args:
user_data: Données de l'utilisateur (username, email, password)
db: Session de base de données
Returns:
Token JWT avec informations utilisateur
Raises:
HTTPException: Si le username existe déjà
"""
# Vérifier si l'utilisateur existe déjà
existing_user = db.query(User).filter(
(User.username == user_data.username) |
(User.email == user_data.email if user_data.email else False)
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username or email already registered"
)
# Créer le nouvel utilisateur
user_id = str(uuid.uuid4())
hashed_password = get_password_hash(user_data.password)
new_user = User(
user_id=user_id,
username=user_data.username,
email=user_data.email,
hashed_password=hashed_password,
is_active=True
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# Créer le token d'accès
access_token = create_access_token(data={"sub": user_id})
return Token(
access_token=access_token,
token_type="bearer",
user_id=user_id,
username=user_data.username
)
@router.post("/login", response_model=Token)
async def login(credentials: UserLogin, db: Session = Depends(get_db)):
"""
Connecter un utilisateur existant.
Args:
credentials: Identifiants (username, password)
db: Session de base de données
Returns:
Token JWT avec informations utilisateur
Raises:
HTTPException: Si les identifiants sont invalides
"""
# Rechercher l'utilisateur par username
user = db.query(User).filter(User.username == credentials.username).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Vérifier le mot de passe
if not verify_password(credentials.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Vérifier que l'utilisateur est actif
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
# Créer le token d'accès
access_token = create_access_token(data={"sub": user.user_id})
return Token(
access_token=access_token,
token_type="bearer",
user_id=user.user_id,
username=user.username
)
@router.post("/capability", response_model=CapabilityTokenResponse)
async def request_capability_token(
request: CapabilityTokenRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Demander un capability token pour une action P2P.
Les capability tokens ont un TTL court (60-180s) et autorisent
des actions spécifiques comme les appels ou les transferts de fichiers.
Args:
request: Détails de la capability demandée
current_user: Utilisateur authentifié
db: Session de base de données
Returns:
Capability token JWT
Raises:
HTTPException: Si l'utilisateur n'a pas accès à la room
"""
# TODO: Vérifier que l'utilisateur a accès à la room
# Pour l'instant, on autorise toutes les demandes
# Déterminer le TTL selon le type de capability
ttl_seconds = 120 # 2 minutes par défaut
# Terminal control a un TTL plus long
if "terminal:control" in request.capabilities:
ttl_seconds = 180 # 3 minutes
# Créer les claims additionnels
extra_claims = {}
if request.target_peer_id:
extra_claims["target_peer_id"] = request.target_peer_id
if request.target_device_id:
extra_claims["target_device_id"] = request.target_device_id
if request.max_size:
extra_claims["max_size"] = request.max_size
if request.max_rate:
extra_claims["max_rate"] = request.max_rate
# Créer le capability token
cap_token = create_capability_token(
subject=current_user.user_id,
room_id=request.room_id,
capabilities=request.capabilities,
expires_delta=timedelta(seconds=ttl_seconds),
**extra_claims
)
return CapabilityTokenResponse(
cap_token=cap_token,
expires_in=ttl_seconds
)
@router.get("/me")
async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
"""
Obtenir les informations de l'utilisateur connecté.
Args:
current_user: Utilisateur authentifié
Returns:
Informations de l'utilisateur
"""
return {
"user_id": current_user.user_id,
"username": current_user.username,
"email": current_user.email,
"is_active": current_user.is_active,
"created_at": current_user.created_at.isoformat()
}

227
server/src/api/p2p.py Normal file
View File

@@ -0,0 +1,227 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: API endpoints pour l'orchestration des sessions P2P (QUIC)
# Refs: server/CLAUDE.md, docs/signaling_v_2.md
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
import uuid
from datetime import datetime, timedelta
from ..db.base import get_db
from ..db.models import P2PSession, P2PSessionKind, Room, RoomMember, User
from ..auth.dependencies import get_current_user
from ..auth.security import create_capability_token
from ..config import settings
router = APIRouter(prefix="/api/p2p", tags=["p2p"])
class P2PSessionRequest(BaseModel):
"""
Requête de création de session P2P.
Le client demande une session P2P avec un peer cible pour un type d'action spécifique.
"""
room_id: str
target_peer_id: str
kind: str # 'file', 'folder', 'terminal'
capabilities: List[str] # Liste des capacités demandées
class Config:
json_schema_extra = {
"example": {
"room_id": "room_123",
"target_peer_id": "peer_456",
"kind": "file",
"capabilities": ["share:file"]
}
}
class P2PSessionResponse(BaseModel):
"""Réponse avec les détails de la session P2P créée."""
session_id: str
session_token: str
expires_at: str
kind: str
initiator_peer_id: str
target_peer_id: str
@router.post("/session", response_model=P2PSessionResponse, status_code=status.HTTP_201_CREATED)
async def create_p2p_session(
request: P2PSessionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Créer une session P2P entre deux peers.
Cette fonction:
1. Valide que l'utilisateur est membre de la room
2. Génère un session_id et un session_token
3. Crée l'enregistrement dans la DB
4. Retourne les informations de session
Note: Le serveur ne distribue PAS les endpoints QUIC ici.
Les agents s'échangent leurs endpoints via WebSocket (p2p.session.created event).
"""
# Vérifier que la room existe
room = db.query(Room).filter(Room.room_id == request.room_id).first()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found"
)
# Vérifier que l'utilisateur est membre de la room
membership = db.query(RoomMember).filter(
RoomMember.room_id == room.id,
RoomMember.user_id == current_user.id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this room"
)
# Valider le kind
valid_kinds = ["file", "folder", "terminal"]
if request.kind not in valid_kinds:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid session kind. Must be one of: {', '.join(valid_kinds)}"
)
# Mapper string -> enum
kind_enum_map = {
"file": P2PSessionKind.FILE,
"folder": P2PSessionKind.FOLDER,
"terminal": P2PSessionKind.TERMINAL
}
# Générer session_id et session_token
session_id = str(uuid.uuid4())
# Le session_token est un capability token avec les permissions demandées
# TTL: 180 secondes (3 minutes) pour laisser le temps d'établir la connexion QUIC
session_token = create_capability_token(
subject=current_user.user_id,
room_id=request.room_id,
capabilities=request.capabilities,
expires_delta=timedelta(seconds=180),
session_id=session_id,
target_peer_id=request.target_peer_id,
kind=request.kind
)
# Calculer expires_at
expires_at = datetime.utcnow() + timedelta(seconds=180)
# Créer la session en base de données
# Note: initiator_device_id et target_device_id sont optionnels pour l'instant
# Ils seront remplis via WebSocket quand les peers se connectent
new_session = P2PSession(
session_id=session_id,
kind=kind_enum_map[request.kind],
session_token=session_token,
room_id=room.id,
expires_at=expires_at
)
db.add(new_session)
db.commit()
db.refresh(new_session)
return P2PSessionResponse(
session_id=session_id,
session_token=session_token,
expires_at=expires_at.isoformat(),
kind=request.kind,
initiator_peer_id="", # Sera rempli via WebSocket
target_peer_id=request.target_peer_id
)
@router.get("/sessions")
async def list_active_sessions(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Lister les sessions P2P actives de l'utilisateur.
Retourne les sessions qui n'ont pas encore expiré.
"""
# Récupérer les sessions actives (non expirées)
now = datetime.utcnow()
# Trouver toutes les rooms dont l'utilisateur est membre
room_memberships = db.query(RoomMember).filter(
RoomMember.user_id == current_user.id
).all()
room_ids = [m.room_id for m in room_memberships]
# Récupérer les sessions actives dans ces rooms
sessions = db.query(P2PSession).filter(
P2PSession.room_id.in_(room_ids),
P2PSession.expires_at > now
).all()
return {
"sessions": [
{
"session_id": s.session_id,
"kind": s.kind.value,
"created_at": s.created_at.isoformat(),
"expires_at": s.expires_at.isoformat()
}
for s in sessions
]
}
@router.delete("/session/{session_id}")
async def close_p2p_session(
session_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Fermer une session P2P.
Permet à un utilisateur de terminer une session P2P active.
"""
# Récupérer la session
session = db.query(P2PSession).filter(
P2PSession.session_id == session_id
).first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# Vérifier que l'utilisateur est membre de la room de cette session
membership = db.query(RoomMember).filter(
RoomMember.room_id == session.room_id,
RoomMember.user_id == current_user.id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to close this session"
)
# Supprimer la session
db.delete(session)
db.commit()
return {"message": "Session closed successfully"}

339
server/src/api/rooms.py Normal file
View File

@@ -0,0 +1,339 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Routes API pour les rooms
# Refs: server/CLAUDE.md
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
import uuid
from typing import List
from ..db.base import get_db
from ..db.models import Room, RoomMember, User, UserRole, PresenceStatus
from ..auth.dependencies import get_current_active_user
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
class RoomCreate(BaseModel):
"""Schéma pour créer une room."""
name: str
class RoomResponse(BaseModel):
"""Schéma de réponse pour une room."""
room_id: str
name: str
owner_id: str
created_at: str
member_count: int
class Config:
from_attributes = True
class RoomMemberResponse(BaseModel):
"""Schéma de réponse pour un membre de room."""
user_id: str
username: str
role: str
presence_status: str
joined_at: str
class AddMemberRequest(BaseModel):
"""Schéma pour ajouter un membre à une room."""
username: str
@router.post("/", response_model=RoomResponse, status_code=status.HTTP_201_CREATED)
async def create_room(
room_data: RoomCreate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Créer une nouvelle room.
L'utilisateur qui crée la room en devient automatiquement owner.
Args:
room_data: Données de la room (nom)
current_user: Utilisateur authentifié
db: Session de base de données
Returns:
Room créée
"""
room_id = str(uuid.uuid4())
# Créer la room
new_room = Room(
room_id=room_id,
name=room_data.name,
owner_id=current_user.id,
is_active=True
)
db.add(new_room)
db.flush() # Pour obtenir l'ID
# Ajouter le créateur comme owner
owner_membership = RoomMember(
room_id=new_room.id,
user_id=current_user.id,
role=UserRole.OWNER,
presence_status=PresenceStatus.ONLINE
)
db.add(owner_membership)
db.commit()
db.refresh(new_room)
return RoomResponse(
room_id=new_room.room_id,
name=new_room.name,
owner_id=current_user.user_id,
created_at=new_room.created_at.isoformat(),
member_count=1
)
@router.get("/", response_model=List[RoomResponse])
async def list_my_rooms(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Lister les rooms de l'utilisateur connecté.
Args:
current_user: Utilisateur authentifié
db: Session de base de données
Returns:
Liste des rooms dont l'utilisateur est membre
"""
# Récupérer les memberships de l'utilisateur
memberships = db.query(RoomMember).filter(
RoomMember.user_id == current_user.id
).all()
rooms = []
for membership in memberships:
room = db.query(Room).filter(Room.id == membership.room_id).first()
if room and room.is_active:
member_count = db.query(RoomMember).filter(
RoomMember.room_id == room.id
).count()
rooms.append(RoomResponse(
room_id=room.room_id,
name=room.name,
owner_id=room.owner.user_id,
created_at=room.created_at.isoformat(),
member_count=member_count
))
return rooms
@router.get("/{room_id}", response_model=RoomResponse)
async def get_room(
room_id: str,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Obtenir les détails d'une room.
Args:
room_id: ID de la room
current_user: Utilisateur authentifié
db: Session de base de données
Returns:
Détails de la room
Raises:
HTTPException: Si la room n'existe pas ou l'utilisateur n'y a pas accès
"""
room = db.query(Room).filter(Room.room_id == room_id).first()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found"
)
# Vérifier que l'utilisateur est membre
membership = db.query(RoomMember).filter(
RoomMember.room_id == room.id,
RoomMember.user_id == current_user.id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this room"
)
member_count = db.query(RoomMember).filter(
RoomMember.room_id == room.id
).count()
return RoomResponse(
room_id=room.room_id,
name=room.name,
owner_id=room.owner.user_id,
created_at=room.created_at.isoformat(),
member_count=member_count
)
@router.get("/{room_id}/members", response_model=List[RoomMemberResponse])
async def get_room_members(
room_id: str,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Obtenir la liste des membres d'une room.
Args:
room_id: ID de la room
current_user: Utilisateur authentifié
db: Session de base de données
Returns:
Liste des membres
Raises:
HTTPException: Si la room n'existe pas ou l'utilisateur n'y a pas accès
"""
room = db.query(Room).filter(Room.room_id == room_id).first()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found"
)
# Vérifier que l'utilisateur est membre
is_member = db.query(RoomMember).filter(
RoomMember.room_id == room.id,
RoomMember.user_id == current_user.id
).first()
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this room"
)
# Récupérer tous les membres
members = db.query(RoomMember).filter(
RoomMember.room_id == room.id
).all()
result = []
for member in members:
user = db.query(User).filter(User.id == member.user_id).first()
if user:
result.append(RoomMemberResponse(
user_id=user.user_id,
username=user.username,
role=member.role.value,
presence_status=member.presence_status.value,
joined_at=member.joined_at.isoformat()
))
return result
@router.post("/{room_id}/members", response_model=RoomMemberResponse, status_code=status.HTTP_201_CREATED)
async def add_member_to_room(
room_id: str,
member_data: AddMemberRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Ajouter un membre à une room.
Seul l'owner de la room peut ajouter des membres.
Args:
room_id: ID de la room
member_data: Données du membre à ajouter (username)
current_user: Utilisateur authentifié
db: Session de base de données
Returns:
Membre ajouté
Raises:
HTTPException: Si la room n'existe pas, l'utilisateur n'est pas owner,
ou l'utilisateur à ajouter n'existe pas
"""
room = db.query(Room).filter(Room.room_id == room_id).first()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found"
)
# Vérifier que l'utilisateur est owner
membership = db.query(RoomMember).filter(
RoomMember.room_id == room.id,
RoomMember.user_id == current_user.id
).first()
if not membership or membership.role != UserRole.OWNER:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only room owner can add members"
)
# Trouver l'utilisateur à ajouter
user_to_add = db.query(User).filter(User.username == member_data.username).first()
if not user_to_add:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User '{member_data.username}' not found"
)
# Vérifier si déjà membre
existing_membership = db.query(RoomMember).filter(
RoomMember.room_id == room.id,
RoomMember.user_id == user_to_add.id
).first()
if existing_membership:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User is already a member of this room"
)
# Ajouter le membre
new_membership = RoomMember(
room_id=room.id,
user_id=user_to_add.id,
role=UserRole.MEMBER,
presence_status=PresenceStatus.OFFLINE
)
db.add(new_membership)
db.commit()
db.refresh(new_membership)
return RoomMemberResponse(
user_id=user_to_add.user_id,
username=user_to_add.username,
role=new_membership.role.value,
presence_status=new_membership.presence_status.value,
joined_at=new_membership.joined_at.isoformat()
)

View File

@@ -0,0 +1,4 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Package d'authentification et autorisation
# Refs: server/CLAUDE.md

View File

@@ -0,0 +1,90 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Dépendances FastAPI pour l'authentification
# Refs: server/CLAUDE.md
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import Optional
from ..db.base import get_db
from ..db.models import User
from .security import decode_access_token
from .schemas import TokenData
# Schéma de sécurité Bearer
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""
Dépendance pour obtenir l'utilisateur courant depuis le token JWT.
Args:
credentials: Credentials HTTP Bearer
db: Session de base de données
Returns:
Utilisateur authentifié
Raises:
HTTPException: Si le token est invalide ou l'utilisateur n'existe pas
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Décoder le token
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise credentials_exception
# Extraire l'user_id
user_id: Optional[str] = payload.get("sub")
if user_id is None:
raise credentials_exception
# Récupérer l'utilisateur depuis la DB
user = db.query(User).filter(User.user_id == user_id).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Dépendance pour obtenir l'utilisateur courant actif.
Args:
current_user: Utilisateur courant
Returns:
Utilisateur actif
Raises:
HTTPException: Si l'utilisateur est inactif
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
return current_user

View File

@@ -0,0 +1,49 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Schémas Pydantic pour l'authentification
# Refs: server/CLAUDE.md
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
class UserCreate(BaseModel):
"""Schéma pour la création d'un utilisateur."""
username: str = Field(..., min_length=3, max_length=50)
email: Optional[EmailStr] = None
password: str = Field(..., min_length=8)
class UserLogin(BaseModel):
"""Schéma pour la connexion d'un utilisateur."""
username: str
password: str
class Token(BaseModel):
"""Schéma pour la réponse avec token JWT."""
access_token: str
token_type: str = "bearer"
user_id: str
username: str
class TokenData(BaseModel):
"""Schéma pour les données extraites d'un token."""
user_id: Optional[str] = None
class CapabilityTokenRequest(BaseModel):
"""Schéma pour demander un capability token."""
room_id: str
capabilities: list[str]
target_peer_id: Optional[str] = None
target_device_id: Optional[str] = None
max_size: Optional[int] = None
max_rate: Optional[int] = None
class CapabilityTokenResponse(BaseModel):
"""Schéma pour la réponse avec capability token."""
cap_token: str
expires_in: int # Secondes

178
server/src/auth/security.py Normal file
View File

@@ -0,0 +1,178 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Utilitaires de sécurité (hashing, JWT)
# Refs: server/CLAUDE.md, docs/security.md
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from passlib.context import CryptContext
from jose import JWTError, jwt
import uuid
from ..config import settings
# Configuration du hashing de mots de passe
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Vérifie qu'un mot de passe en clair correspond au hash.
Args:
plain_password: Mot de passe en clair
hashed_password: Hash bcrypt du mot de passe
Returns:
True si le mot de passe correspond
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash un mot de passe avec bcrypt.
Args:
password: Mot de passe en clair
Returns:
Hash bcrypt du mot de passe
"""
return pwd_context.hash(password)
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Crée un JWT access token.
Args:
data: Données à encoder dans le token (ex: {"sub": user_id})
expires_delta: Durée de validité optionnelle
Returns:
JWT token encodé
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.mesh_jwt_access_token_expire_minutes)
to_encode.update({
"exp": expire,
"iat": datetime.utcnow(),
"jti": str(uuid.uuid4()) # JWT ID unique
})
encoded_jwt = jwt.encode(
to_encode,
settings.mesh_jwt_secret,
algorithm=settings.mesh_jwt_algorithm
)
return encoded_jwt
def decode_access_token(token: str) -> Optional[Dict[str, Any]]:
"""
Décode et valide un JWT access token.
Args:
token: JWT token à décoder
Returns:
Payload du token si valide, None sinon
"""
try:
payload = jwt.decode(
token,
settings.mesh_jwt_secret,
algorithms=[settings.mesh_jwt_algorithm]
)
return payload
except JWTError:
return None
def create_capability_token(
subject: str,
room_id: str,
capabilities: list[str],
expires_delta: Optional[timedelta] = None,
**extra_claims
) -> str:
"""
Crée un capability token pour autoriser des actions P2P.
Les capability tokens ont un TTL court (60-180s) et autorisent
des actions spécifiques comme les appels WebRTC ou les transferts QUIC.
Args:
subject: Identifiant du sujet (peer_id ou device_id)
room_id: ID de la room
capabilities: Liste de capabilities (ex: ["call", "share:file"])
expires_delta: Durée de validité (défaut: 120s)
**extra_claims: Claims additionnels (target_peer_id, max_size, etc.)
Returns:
Capability token JWT
"""
if not expires_delta:
expires_delta = timedelta(seconds=120) # 2 minutes par défaut
to_encode = {
"sub": subject,
"room_id": room_id,
"caps": capabilities,
"exp": datetime.utcnow() + expires_delta,
"iat": datetime.utcnow(),
"jti": str(uuid.uuid4()),
"type": "capability"
}
# Ajouter les claims additionnels
to_encode.update(extra_claims)
encoded_jwt = jwt.encode(
to_encode,
settings.mesh_jwt_secret,
algorithm=settings.mesh_jwt_algorithm
)
return encoded_jwt
def validate_capability_token(token: str, required_cap: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Valide un capability token et optionnellement vérifie une capability spécifique.
Args:
token: Capability token JWT
required_cap: Capability requise optionnelle (ex: "terminal:control")
Returns:
Payload du token si valide, None sinon
"""
try:
payload = jwt.decode(
token,
settings.mesh_jwt_secret,
algorithms=[settings.mesh_jwt_algorithm]
)
# Vérifier que c'est bien un capability token
if payload.get("type") != "capability":
return None
# Vérifier la capability si demandée
if required_cap:
caps = payload.get("caps", [])
if required_cap not in caps:
return None
return payload
except JWTError:
return None

52
server/src/config.py Normal file
View File

@@ -0,0 +1,52 @@
# Created by: Claude
# Date: 2026-01-01
# Purpose: Configuration management for Mesh Server
# Refs: CLAUDE.md
from pydantic_settings import BaseSettings
from pydantic import Field
from typing import Optional
class Settings(BaseSettings):
"""Mesh Server configuration settings."""
# Server
mesh_public_url: str = "http://localhost:8000"
mesh_host: str = "0.0.0.0"
mesh_port: int = 8000
# Security
mesh_jwt_secret: str
mesh_jwt_algorithm: str = "HS256"
mesh_jwt_access_token_expire_minutes: int = 120
# Gotify (optionnel)
gotify_url: Optional[str] = None
gotify_token: Optional[str] = None
# Alias en majuscules pour compatibilité
GOTIFY_URL: Optional[str] = None
GOTIFY_TOKEN: Optional[str] = None
# WebRTC/ICE
stun_url: str = "stun:stun.l.google.com:19302"
turn_host: Optional[str] = None
turn_port: int = 3478
turn_user: Optional[str] = None
turn_pass: Optional[str] = None
turn_external_ip: Optional[str] = None
turn_realm: Optional[str] = None
# Database
database_url: str = Field(default="sqlite:///./mesh.db", env="DATABASE_URL")
# Logging
log_level: str = "INFO"
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()

View File

@@ -0,0 +1,4 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Package de gestion de la base de données
# Refs: server/CLAUDE.md

35
server/src/db/base.py Normal file
View File

@@ -0,0 +1,35 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Configuration de base pour SQLAlchemy
# Refs: server/CLAUDE.md
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from ..config import settings
# Créer l'engine de base de données
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {},
echo=settings.log_level == "DEBUG"
)
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class pour les modèles
Base = declarative_base()
def get_db():
"""
Générateur de session de base de données.
Utilisé comme dépendance FastAPI.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

155
server/src/db/models.py Normal file
View File

@@ -0,0 +1,155 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Modèles SQLAlchemy pour Mesh
# Refs: server/CLAUDE.md, docs/protocol_events_v_2.md
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Boolean
from sqlalchemy.orm import relationship
import enum
from .base import Base
class UserRole(str, enum.Enum):
"""Rôles utilisateur dans une room."""
OWNER = "owner"
MEMBER = "member"
GUEST = "guest"
class PresenceStatus(str, enum.Enum):
"""Statuts de présence."""
ONLINE = "online"
BUSY = "busy"
OFFLINE = "offline"
class P2PSessionKind(str, enum.Enum):
"""Types de sessions P2P."""
FILE = "file"
FOLDER = "folder"
TERMINAL = "terminal"
class User(Base):
"""
Modèle utilisateur.
Représente un utilisateur du système Mesh.
"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(String, unique=True, index=True, nullable=False) # UUID
username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, unique=True, index=True, nullable=True)
hashed_password = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_active = Column(Boolean, default=True)
# Relations
devices = relationship("Device", back_populates="user", cascade="all, delete-orphan")
room_memberships = relationship("RoomMember", back_populates="user", cascade="all, delete-orphan")
owned_rooms = relationship("Room", back_populates="owner", cascade="all, delete-orphan")
class Device(Base):
"""
Modèle device (agent desktop).
Représente une instance d'agent Mesh sur une machine.
"""
__tablename__ = "devices"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(String, unique=True, index=True, nullable=False) # UUID
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String, nullable=False) # Ex: "Laptop-Linux", "Desktop-Windows"
last_seen = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True)
# Relations
user = relationship("User", back_populates="devices")
class Room(Base):
"""
Modèle room (salon de communication).
Représente un espace de communication pour 2-4 personnes.
"""
__tablename__ = "rooms"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(String, unique=True, index=True, nullable=False) # UUID
name = Column(String, nullable=False)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_active = Column(Boolean, default=True)
# Relations
owner = relationship("User", back_populates="owned_rooms")
members = relationship("RoomMember", back_populates="room", cascade="all, delete-orphan")
messages = relationship("Message", back_populates="room", cascade="all, delete-orphan")
class RoomMember(Base):
"""
Modèle d'appartenance à une room.
Représente la relation entre un utilisateur et une room avec son rôle.
"""
__tablename__ = "room_members"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role = Column(Enum(UserRole), default=UserRole.MEMBER, nullable=False)
joined_at = Column(DateTime, default=datetime.utcnow)
presence_status = Column(Enum(PresenceStatus), default=PresenceStatus.OFFLINE)
# Relations
room = relationship("Room", back_populates="members")
user = relationship("User", back_populates="room_memberships")
class Message(Base):
"""
Modèle message de chat.
Représente un message dans une room.
"""
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(String, unique=True, index=True, nullable=False) # UUID
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
content = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relations
room = relationship("Room", back_populates="messages")
user = relationship("User")
class P2PSession(Base):
"""
Modèle session P2P.
Représente une session QUIC entre deux agents.
"""
__tablename__ = "p2p_sessions"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(String, unique=True, index=True, nullable=False) # UUID
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
initiator_device_id = Column(String, nullable=True) # Optionnel, rempli via WebSocket
target_device_id = Column(String, nullable=True) # Optionnel, rempli via WebSocket
kind = Column(Enum(P2PSessionKind), nullable=False)
session_token = Column(String, nullable=False) # JWT pour validation
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True)
closed_at = Column(DateTime, nullable=True)
# Relations
room = relationship("Room")

147
server/src/main.py Normal file
View File

@@ -0,0 +1,147 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Main FastAPI application entry point for Mesh Server
# Refs: CLAUDE.md, protocol_events_v_2.md
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
import logging
import uuid
from .config import settings
from .db.base import get_db, engine, Base
from .api import auth, rooms, p2p
from .websocket.manager import manager
from .websocket.handlers import EventHandler
from .auth.security import decode_access_token
# Configurer le logging
logging.basicConfig(
level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Créer les tables de la base de données
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Mesh Server",
description="Control plane for Mesh P2P communication platform",
version="0.1.0"
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # À configurer en production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Inclure les routers API
app.include_router(auth.router)
app.include_router(rooms.router)
app.include_router(p2p.router)
@app.get("/")
async def root():
"""Root endpoint."""
return {
"service": "mesh-server",
"version": "0.1.0",
"status": "operational"
}
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"status": "healthy"}
@app.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
token: str = Query(...),
db: Session = Depends(get_db)
):
"""
Endpoint WebSocket principal pour les connexions temps réel.
Args:
websocket: Connexion WebSocket
token: JWT token pour authentification (query param)
db: Session de base de données
Le client doit se connecter avec: ws://server/ws?token=JWT_TOKEN
"""
# Vérifier le token JWT
payload = decode_access_token(token)
if not payload:
await websocket.close(code=1008, reason="Invalid token")
logger.warning("WebSocket connection rejected: invalid token")
return
user_id = payload.get("sub")
if not user_id:
await websocket.close(code=1008, reason="Invalid token payload")
logger.warning("WebSocket connection rejected: invalid payload")
return
# Générer un peer_id unique pour cette connexion
peer_id = str(uuid.uuid4())
# Enregistrer la connexion
await manager.connect(peer_id, user_id, websocket)
# Créer le handler d'événements
event_handler = EventHandler(db)
try:
while True:
# Recevoir les messages
data = await websocket.receive_json()
# Traiter l'événement
await event_handler.handle_event(data, peer_id, websocket)
except WebSocketDisconnect:
# Déconnexion normale
manager.disconnect(peer_id)
logger.info(f"WebSocket disconnected normally: peer_id={peer_id}")
except Exception as e:
# Erreur inattendue
logger.error(f"WebSocket error for peer_id={peer_id}: {str(e)}")
manager.disconnect(peer_id)
@app.on_event("startup")
async def startup_event():
"""Tâches au démarrage de l'application."""
logger.info(f"Mesh Server starting on {settings.mesh_host}:{settings.mesh_port}")
logger.info(f"Public URL: {settings.mesh_public_url}")
logger.info(f"Database: {settings.database_url}")
@app.on_event("shutdown")
async def shutdown_event():
"""Tâches à l'arrêt de l'application."""
logger.info("Mesh Server shutting down")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"src.main:app",
host=settings.mesh_host,
port=settings.mesh_port,
reload=True
)

View File

@@ -0,0 +1,4 @@
# Created by: Claude
# Date: 2026-01-03
# Purpose: Package pour les notifications (Gotify)
# Refs: server/CLAUDE.md

View File

@@ -0,0 +1,207 @@
# Created by: Claude
# Date: 2026-01-03
# Purpose: Client Gotify pour les notifications push
# Refs: server/CLAUDE.md, https://gotify.net/docs/msgextras
import httpx
import logging
from typing import Optional, Dict, Any
from ..config import settings
logger = logging.getLogger(__name__)
class GotifyClient:
"""Client pour envoyer des notifications via Gotify."""
def __init__(self):
self.url = settings.GOTIFY_URL
self.token = settings.GOTIFY_TOKEN
self.enabled = bool(self.url and self.token)
if not self.enabled:
logger.warning("Gotify non configuré - notifications désactivées")
else:
logger.info(f"Gotify configuré: {self.url}")
async def send_notification(
self,
title: str,
message: str,
priority: int = 5,
extras: Optional[Dict[str, Any]] = None,
) -> bool:
"""
Envoyer une notification Gotify.
Args:
title: Titre de la notification
message: Message de la notification
priority: Priorité (0=min, 10=max, défaut=5)
extras: Données supplémentaires (ex: actions, images)
Returns:
True si envoyé avec succès, False sinon
"""
if not self.enabled:
logger.debug(f"Gotify disabled - Would send: {title}")
return False
try:
async with httpx.AsyncClient(timeout=5.0) as client:
payload = {
"title": title,
"message": message,
"priority": priority,
}
if extras:
payload["extras"] = extras
response = await client.post(
f"{self.url}/message",
params={"token": self.token},
json=payload,
)
response.raise_for_status()
logger.info(f"Notification Gotify envoyée: {title}")
return True
except httpx.HTTPError as e:
logger.error(f"Erreur envoi Gotify: {e}")
return False
except Exception as e:
logger.error(f"Erreur inattendue Gotify: {e}")
return False
async def send_chat_notification(
self,
from_username: str,
room_name: str,
message: str,
room_id: str,
) -> bool:
"""
Notification pour un nouveau message de chat.
Args:
from_username: Nom de l'expéditeur
room_name: Nom de la room
message: Contenu du message
room_id: ID de la room
Returns:
True si envoyé
"""
title = f"💬 {from_username} dans {room_name}"
# Tronquer le message si trop long
preview = message[:100] + "..." if len(message) > 100 else message
# Extras avec actions Gotify
extras = {
"client::display": {
"contentType": "text/markdown"
},
"client::notification": {
"click": {
"url": f"mesh://room/{room_id}"
}
},
"android::action": {
"onReceive": {
"intentUrl": f"mesh://room/{room_id}"
}
}
}
return await self.send_notification(
title=title,
message=preview,
priority=6, # Priorité normale-haute pour chat
extras=extras,
)
async def send_call_notification(
self,
from_username: str,
room_name: str,
room_id: str,
call_type: str = "audio/vidéo",
) -> bool:
"""
Notification pour un appel entrant.
Args:
from_username: Nom de l'appelant
room_name: Nom de la room
room_id: ID de la room
call_type: Type d'appel (audio, vidéo, audio/vidéo)
Returns:
True si envoyé
"""
title = f"📞 Appel {call_type} de {from_username}"
message = f"Appel entrant dans {room_name}"
extras = {
"client::notification": {
"click": {
"url": f"mesh://room/{room_id}"
}
},
"android::action": {
"onReceive": {
"intentUrl": f"mesh://room/{room_id}"
}
}
}
return await self.send_notification(
title=title,
message=message,
priority=8, # Haute priorité pour appels
extras=extras,
)
async def send_file_notification(
self,
from_username: str,
room_name: str,
filename: str,
room_id: str,
) -> bool:
"""
Notification pour un fichier partagé.
Args:
from_username: Nom de l'expéditeur
room_name: Nom de la room
filename: Nom du fichier
room_id: ID de la room
Returns:
True si envoyé
"""
title = f"📁 {from_username} a partagé un fichier"
message = f"Fichier: {filename}\nDans: {room_name}"
extras = {
"client::notification": {
"click": {
"url": f"mesh://room/{room_id}"
}
}
}
return await self.send_notification(
title=title,
message=message,
priority=5,
extras=extras,
)
# Instance globale du client Gotify
gotify_client = GotifyClient()

View File

@@ -0,0 +1,4 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Package WebSocket pour connexions temps réel
# Refs: server/CLAUDE.md, docs/protocol_events_v_2.md

View File

@@ -0,0 +1,182 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Types d'événements WebSocket
# Refs: docs/protocol_events_v_2.md
from pydantic import BaseModel
from typing import Any, Optional
from datetime import datetime
import uuid
class EventType:
"""Constantes pour les types d'événements."""
# Système
SYSTEM_HELLO = "system.hello"
SYSTEM_WELCOME = "system.welcome"
# Rooms
ROOM_JOIN = "room.join"
ROOM_JOINED = "room.joined"
ROOM_LEFT = "room.left"
# Présence
PRESENCE_UPDATE = "presence.update"
# Chat
CHAT_MESSAGE_SEND = "chat.message.send"
CHAT_MESSAGE_CREATED = "chat.message.created"
# WebRTC Signaling
RTC_OFFER = "rtc.offer"
RTC_ANSWER = "rtc.answer"
RTC_ICE = "rtc.ice"
# P2P Sessions (QUIC)
P2P_SESSION_REQUEST = "p2p.session.request"
P2P_SESSION_CREATED = "p2p.session.created"
P2P_SESSION_CLOSED = "p2p.session.closed"
# Terminal Control
TERMINAL_CONTROL_TAKE = "terminal.control.take"
TERMINAL_CONTROL_GRANTED = "terminal.control.granted"
TERMINAL_CONTROL_RELEASE = "terminal.control.release"
# Erreurs
ERROR = "error"
class WebSocketEvent(BaseModel):
"""
Modèle de base pour tous les événements WebSocket.
Structure selon protocol_events_v_2.md:
{
"type": "event.type",
"id": "uuid",
"timestamp": "ISO-8601",
"from": "peer_id|device_id|server",
"to": "peer_id|device_id|room_id|server",
"payload": {}
}
"""
type: str
id: str = None
timestamp: str = None
from_: str = "server" # Alias pour "from"
to: str
payload: dict = {}
class Config:
populate_by_name = True
fields = {"from_": "from"}
def __init__(self, **data):
# Générer ID et timestamp si non fournis
if "id" not in data or not data["id"]:
data["id"] = str(uuid.uuid4())
if "timestamp" not in data or not data["timestamp"]:
data["timestamp"] = datetime.utcnow().isoformat() + "Z"
super().__init__(**data)
def dict(self, **kwargs):
"""Override pour utiliser 'from' au lieu de 'from_'."""
d = super().dict(**kwargs)
if "from_" in d:
d["from"] = d.pop("from_")
return d
# Schémas de payload spécifiques
class SystemHelloPayload(BaseModel):
"""Payload pour system.hello."""
peer_type: str # "client" ou "agent"
version: str
class SystemWelcomePayload(BaseModel):
"""Payload pour system.welcome."""
peer_id: str
user_id: str
class RoomJoinPayload(BaseModel):
"""Payload pour room.join."""
room_id: str
class RoomJoinedPayload(BaseModel):
"""Payload pour room.joined."""
peer_id: str
user_id: str
username: str
role: str
room_id: str
class ChatMessageSendPayload(BaseModel):
"""Payload pour chat.message.send."""
room_id: str
content: str
class ChatMessageCreatedPayload(BaseModel):
"""Payload pour chat.message.created."""
message_id: str
room_id: str
from_user_id: str
from_username: str
content: str
created_at: str
class RTCOfferPayload(BaseModel):
"""Payload pour rtc.offer."""
room_id: str
target_peer_id: str
sdp: str
cap_token: str
class RTCAnswerPayload(BaseModel):
"""Payload pour rtc.answer."""
room_id: str
target_peer_id: str
sdp: str
cap_token: str
class RTCIcePayload(BaseModel):
"""Payload pour rtc.ice."""
room_id: str
target_peer_id: str
candidate: dict
cap_token: str
class P2PSessionRequestPayload(BaseModel):
"""Payload pour p2p.session.request."""
room_id: str
target_device_id: str
kind: str # "file", "folder", "terminal"
cap_token: str
meta: Optional[dict] = {}
class P2PSessionCreatedPayload(BaseModel):
"""Payload pour p2p.session.created."""
session_id: str
kind: str
expires_in: int
auth: dict
endpoints: dict
class ErrorPayload(BaseModel):
"""Payload pour error."""
code: str
message: str
details: Optional[dict] = {}

View File

@@ -0,0 +1,473 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Handlers pour les événements WebSocket
# Refs: server/CLAUDE.md, docs/protocol_events_v_2.md
from fastapi import WebSocket
from sqlalchemy.orm import Session
import logging
import uuid
from datetime import datetime
from .manager import manager
from .events import (
EventType,
WebSocketEvent,
SystemHelloPayload,
SystemWelcomePayload,
RoomJoinPayload,
RoomJoinedPayload,
ChatMessageSendPayload,
ChatMessageCreatedPayload,
ErrorPayload
)
from ..db.models import Room, RoomMember, Message, User, PresenceStatus
from ..notifications.gotify import gotify_client
logger = logging.getLogger(__name__)
class EventHandler:
"""Gestionnaire des événements WebSocket."""
def __init__(self, db: Session):
self.db = db
async def handle_event(self, event_data: dict, peer_id: str, websocket: WebSocket):
"""
Router principal pour gérer les événements entrants.
Args:
event_data: Données de l'événement
peer_id: ID du peer émetteur
websocket: Connexion WebSocket
"""
event_type = event_data.get("type")
if not event_type:
await self.send_error(websocket, "INVALID_EVENT", "Missing event type")
return
# Router vers le handler approprié
handlers = {
EventType.SYSTEM_HELLO: self.handle_system_hello,
EventType.ROOM_JOIN: self.handle_room_join,
EventType.ROOM_LEFT: self.handle_room_left,
EventType.CHAT_MESSAGE_SEND: self.handle_chat_message_send,
EventType.PRESENCE_UPDATE: self.handle_presence_update,
EventType.RTC_OFFER: self.handle_rtc_signal,
EventType.RTC_ANSWER: self.handle_rtc_signal,
EventType.RTC_ICE: self.handle_rtc_signal,
EventType.P2P_SESSION_REQUEST: self.handle_p2p_session_request,
}
handler = handlers.get(event_type)
if handler:
try:
await handler(event_data, peer_id, websocket)
except Exception as e:
logger.error(f"Error handling {event_type}: {str(e)}")
await self.send_error(websocket, "HANDLER_ERROR", str(e))
else:
logger.warning(f"Unknown event type: {event_type}")
await self.send_error(websocket, "UNKNOWN_EVENT", f"Unknown event type: {event_type}")
async def handle_system_hello(self, event_data: dict, peer_id: str, websocket: WebSocket):
"""
Gérer l'événement system.hello.
Le client/agent s'identifie et reçoit son peer_id en retour.
"""
payload = event_data.get("payload", {})
# Envoyer system.welcome
welcome_event = WebSocketEvent(
type=EventType.SYSTEM_WELCOME,
from_="server",
to=peer_id,
payload=SystemWelcomePayload(
peer_id=peer_id,
user_id=manager.get_user_id(peer_id)
).dict()
)
await websocket.send_json(welcome_event.dict())
logger.info(f"Sent system.welcome to {peer_id}")
async def handle_room_join(self, event_data: dict, peer_id: str, websocket: WebSocket):
"""
Gérer l'événement room.join.
Un peer demande à rejoindre une room.
"""
payload = event_data.get("payload", {})
room_id_str = payload.get("room_id")
if not room_id_str:
await self.send_error(websocket, "MISSING_ROOM_ID", "room_id is required")
return
# Vérifier que la room existe
room = self.db.query(Room).filter(Room.room_id == room_id_str).first()
if not room:
await self.send_error(websocket, "ROOM_NOT_FOUND", "Room not found")
return
# Vérifier que l'utilisateur est membre
user_id = manager.get_user_id(peer_id)
user = self.db.query(User).filter(User.user_id == user_id).first()
if not user:
await self.send_error(websocket, "USER_NOT_FOUND", "User not found")
return
membership = self.db.query(RoomMember).filter(
RoomMember.room_id == room.id,
RoomMember.user_id == user.id
).first()
if not membership:
await self.send_error(websocket, "ACCESS_DENIED", "Not a member of this room")
return
# Ajouter le peer à la room
manager.join_room(peer_id, room_id_str)
# Mettre à jour le statut de présence
membership.presence_status = PresenceStatus.ONLINE
self.db.commit()
# Envoyer room.joined au peer
joined_event = WebSocketEvent(
type=EventType.ROOM_JOINED,
from_="server",
to=peer_id,
payload=RoomJoinedPayload(
peer_id=peer_id,
user_id=user.user_id,
username=user.username,
role=membership.role.value,
room_id=room_id_str
).dict()
)
await websocket.send_json(joined_event.dict())
# Broadcast aux autres membres
await manager.broadcast_to_room(joined_event.dict(), room_id_str, exclude=peer_id)
logger.info(f"Peer {peer_id} joined room {room_id_str}")
async def handle_room_left(self, event_data: dict, peer_id: str, websocket: WebSocket):
"""Gérer l'événement room.left."""
payload = event_data.get("payload", {})
room_id = payload.get("room_id")
if room_id:
manager.leave_room(peer_id, room_id)
logger.info(f"Peer {peer_id} left room {room_id}")
async def handle_chat_message_send(self, event_data: dict, peer_id: str, websocket: WebSocket):
"""
Gérer l'envoi d'un message de chat.
Persister le message et le broadcast à tous les membres de la room.
"""
payload = event_data.get("payload", {})
room_id_str = payload.get("room_id")
content = payload.get("content")
if not room_id_str or not content:
await self.send_error(websocket, "INVALID_MESSAGE", "room_id and content are required")
return
# Vérifier la room et le membership
room = self.db.query(Room).filter(Room.room_id == room_id_str).first()
if not room:
await self.send_error(websocket, "ROOM_NOT_FOUND", "Room not found")
return
user_id = manager.get_user_id(peer_id)
user = self.db.query(User).filter(User.user_id == user_id).first()
if not user:
await self.send_error(websocket, "USER_NOT_FOUND", "User not found")
return
# Créer le message
message_id = str(uuid.uuid4())
new_message = Message(
message_id=message_id,
room_id=room.id,
user_id=user.id,
content=content
)
self.db.add(new_message)
self.db.commit()
self.db.refresh(new_message)
# Créer l'événement chat.message.created
created_event = WebSocketEvent(
type=EventType.CHAT_MESSAGE_CREATED,
from_=peer_id,
to=room_id_str,
payload=ChatMessageCreatedPayload(
message_id=message_id,
room_id=room_id_str,
from_user_id=user.user_id,
from_username=user.username,
content=content,
created_at=new_message.created_at.isoformat()
).dict()
)
# Broadcast à tous les membres de la room
await manager.broadcast_to_room(created_event.dict(), room_id_str)
# Envoyer notifications Gotify aux membres absents
await self._send_chat_notifications(room, user, content, room_id_str, peer_id)
logger.info(f"Message created in room {room_id_str} by {user.username}")
async def handle_presence_update(self, event_data: dict, peer_id: str, websocket: WebSocket):
"""Gérer la mise à jour de présence."""
# TODO: Implémenter la mise à jour de présence
pass
async def handle_p2p_session_request(self, event_data: dict, peer_id: str, websocket: WebSocket):
"""
Gérer une requête de session P2P.
Un peer demande à établir une session P2P QUIC avec un autre peer.
Le serveur génère un session_id et un session_token, puis notifie
les deux peers via l'événement p2p.session.created.
"""
payload = event_data.get("payload", {})
target_peer_id = payload.get("target_peer_id")
kind = payload.get("kind") # 'file', 'folder', 'terminal'
room_id = payload.get("room_id")
if not target_peer_id or not kind or not room_id:
await self.send_error(
websocket,
"INVALID_P2P_REQUEST",
"target_peer_id, kind, and room_id are required"
)
return
# Vérifier que les deux peers sont membres de la room
user_id = manager.get_user_id(peer_id)
user = self.db.query(User).filter(User.user_id == user_id).first()
if not user:
await self.send_error(websocket, "USER_NOT_FOUND", "User not found")
return
room = self.db.query(Room).filter(Room.room_id == room_id).first()
if not room:
await self.send_error(websocket, "ROOM_NOT_FOUND", "Room not found")
return
membership = self.db.query(RoomMember).filter(
RoomMember.room_id == room.id,
RoomMember.user_id == user.id
).first()
if not membership:
await self.send_error(websocket, "ACCESS_DENIED", "Not a member of this room")
return
# Générer session_id et session_token
from ..auth.security import create_capability_token
from datetime import timedelta
session_id = str(uuid.uuid4())
# Déterminer les capabilities en fonction du kind
capabilities_map = {
"file": ["share:file"],
"folder": ["share:folder"],
"terminal": ["terminal:view"]
}
capabilities = capabilities_map.get(kind, [])
session_token = create_capability_token(
subject=user_id,
room_id=room_id,
capabilities=capabilities,
expires_delta=timedelta(seconds=180),
session_id=session_id,
target_peer_id=target_peer_id,
kind=kind
)
# Créer la session en base de données
from ..db.models import P2PSession, P2PSessionKind
kind_enum_map = {
"file": P2PSessionKind.FILE,
"folder": P2PSessionKind.FOLDER,
"terminal": P2PSessionKind.TERMINAL
}
if kind not in kind_enum_map:
await self.send_error(websocket, "INVALID_KIND", f"Invalid kind: {kind}")
return
expires_at = datetime.utcnow() + timedelta(seconds=180)
new_session = P2PSession(
session_id=session_id,
kind=kind_enum_map[kind],
session_token=session_token,
room_id=room.id,
expires_at=expires_at
)
self.db.add(new_session)
self.db.commit()
# Créer l'événement p2p.session.created
from .events import WebSocketEvent
session_created_event = WebSocketEvent(
type=EventType.P2P_SESSION_CREATED,
from_="server",
to=room_id,
payload={
"session_id": session_id,
"session_token": session_token,
"kind": kind,
"initiator_peer_id": peer_id,
"target_peer_id": target_peer_id,
"expires_at": expires_at.isoformat()
}
)
# Envoyer à l'initiateur
await websocket.send_json(session_created_event.dict())
# Envoyer au peer cible
await manager.send_personal_message(session_created_event.dict(), target_peer_id)
logger.info(f"P2P session {session_id} created: {peer_id} -> {target_peer_id} ({kind})")
async def handle_rtc_signal(self, event_data: dict, peer_id: str, websocket: WebSocket):
"""
Gérer les événements de signalisation WebRTC (offer, answer, ice).
Relay les messages SDP et ICE candidates entre peers.
"""
payload = event_data.get("payload", {})
target_peer_id = payload.get("target_peer_id")
if not target_peer_id:
await self.send_error(websocket, "MISSING_TARGET", "target_peer_id is required")
return
# TODO: Valider le capability token
# Ajouter des informations sur l'émetteur pour les offers
user_id = manager.get_user_id(peer_id)
if user_id and event_data.get("type") == EventType.RTC_OFFER:
user = self.db.query(User).filter(User.user_id == user_id).first()
if user:
event_data["payload"]["from_username"] = user.username
# Envoyer notification Gotify si le destinataire n'est pas connecté
target_user_id = manager.get_user_id(target_peer_id)
if target_user_id:
target_is_online = manager.is_connected(target_peer_id)
if not target_is_online:
# Trouver la room pour le nom
room_id = payload.get("room_id")
if room_id:
room = self.db.query(Room).filter(Room.room_id == room_id).first()
if room:
await gotify_client.send_call_notification(
from_username=user.username,
room_name=room.name,
room_id=room_id,
call_type="audio/vidéo"
)
# Relay le message au peer cible
event_data["from"] = peer_id
event_data["payload"]["from_peer_id"] = peer_id
await manager.send_personal_message(event_data, target_peer_id)
logger.debug(f"Relayed {event_data.get('type')} from {peer_id} to {target_peer_id}")
async def send_error(self, websocket: WebSocket, code: str, message: str):
"""
Envoyer un événement d'erreur au client.
Args:
websocket: Connexion WebSocket
code: Code d'erreur
message: Message d'erreur
"""
error_event = WebSocketEvent(
type=EventType.ERROR,
from_="server",
to="client",
payload=ErrorPayload(
code=code,
message=message
).dict()
)
await websocket.send_json(error_event.dict())
logger.warning(f"Sent error: {code} - {message}")
async def _send_chat_notifications(
self,
room: Room,
sender: User,
message_content: str,
room_id_str: str,
sender_peer_id: str
):
"""
Envoyer des notifications Gotify aux membres absents.
Args:
room: Room où le message a été envoyé
sender: Utilisateur qui a envoyé le message
message_content: Contenu du message
room_id_str: ID de la room (UUID string)
sender_peer_id: Peer ID de l'expéditeur
"""
# Récupérer tous les membres de la room
members = self.db.query(RoomMember).filter(
RoomMember.room_id == room.id
).all()
for member in members:
# Ne pas notifier l'expéditeur
if member.user_id == sender.id:
continue
# Vérifier si le membre est connecté
user = self.db.query(User).filter(User.id == member.user_id).first()
if not user:
continue
# Vérifier si l'utilisateur a un peer_id actif dans cette room
is_online = manager.is_user_in_room(user.user_id, room_id_str)
# Envoyer notification Gotify uniquement si l'utilisateur est absent
if not is_online:
await gotify_client.send_chat_notification(
from_username=sender.username,
room_name=room.name,
message=message_content,
room_id=room_id_str,
)
logger.debug(f"Notification Gotify envoyée à {user.username} pour message dans {room.name}")

View File

@@ -0,0 +1,198 @@
# Created by: Claude
# Date: 2026-01-02
# Purpose: Gestionnaire de connexions WebSocket
# Refs: server/CLAUDE.md, docs/protocol_events_v_2.md
from fastapi import WebSocket
from typing import Dict, Set
import logging
logger = logging.getLogger(__name__)
class ConnectionManager:
"""
Gestionnaire des connexions WebSocket.
Maintient un mapping entre les peer_id/device_id et leurs WebSocket.
Permet d'envoyer des messages à des peers spécifiques ou à tous les membres d'une room.
"""
def __init__(self):
# Connexions actives: peer_id/device_id -> WebSocket
self.active_connections: Dict[str, WebSocket] = {}
# Mapping peer -> user_id pour l'authentification
self.peer_to_user: Dict[str, str] = {}
# Mapping room_id -> Set[peer_id] pour broadcast dans les rooms
self.room_members: Dict[str, Set[str]] = {}
async def connect(self, peer_id: str, user_id: str, websocket: WebSocket):
"""
Enregistrer une nouvelle connexion WebSocket.
Args:
peer_id: Identifiant du peer (généré côté serveur)
user_id: ID de l'utilisateur authentifié
websocket: Connexion WebSocket
"""
await websocket.accept()
self.active_connections[peer_id] = websocket
self.peer_to_user[peer_id] = user_id
logger.info(f"WebSocket connected: peer_id={peer_id}, user_id={user_id}")
def disconnect(self, peer_id: str):
"""
Déconnecter un peer et nettoyer les mappings.
Args:
peer_id: Identifiant du peer à déconnecter
"""
if peer_id in self.active_connections:
del self.active_connections[peer_id]
logger.info(f"WebSocket disconnected: peer_id={peer_id}")
if peer_id in self.peer_to_user:
del self.peer_to_user[peer_id]
# Retirer de toutes les rooms
for room_id in list(self.room_members.keys()):
if peer_id in self.room_members[room_id]:
self.room_members[room_id].remove(peer_id)
if not self.room_members[room_id]:
del self.room_members[room_id]
def join_room(self, peer_id: str, room_id: str):
"""
Ajouter un peer à une room.
Args:
peer_id: Identifiant du peer
room_id: ID de la room
"""
if room_id not in self.room_members:
self.room_members[room_id] = set()
self.room_members[room_id].add(peer_id)
logger.info(f"Peer {peer_id} joined room {room_id}")
def leave_room(self, peer_id: str, room_id: str):
"""
Retirer un peer d'une room.
Args:
peer_id: Identifiant du peer
room_id: ID de la room
"""
if room_id in self.room_members and peer_id in self.room_members[room_id]:
self.room_members[room_id].remove(peer_id)
if not self.room_members[room_id]:
del self.room_members[room_id]
logger.info(f"Peer {peer_id} left room {room_id}")
async def send_personal_message(self, message: dict, peer_id: str):
"""
Envoyer un message à un peer spécifique.
Args:
message: Dictionnaire du message à envoyer
peer_id: Identifiant du peer destinataire
"""
if peer_id in self.active_connections:
websocket = self.active_connections[peer_id]
await websocket.send_json(message)
logger.debug(f"Sent message to {peer_id}: {message.get('type')}")
async def broadcast_to_room(self, message: dict, room_id: str, exclude: str = None):
"""
Envoyer un message à tous les membres d'une room.
Args:
message: Dictionnaire du message à envoyer
room_id: ID de la room
exclude: Peer ID à exclure (optionnel, pour ne pas envoyer à l'émetteur)
"""
if room_id not in self.room_members:
return
for peer_id in self.room_members[room_id]:
if exclude and peer_id == exclude:
continue
await self.send_personal_message(message, peer_id)
logger.debug(f"Broadcasted to room {room_id}: {message.get('type')}")
async def broadcast_to_all(self, message: dict):
"""
Envoyer un message à tous les peers connectés.
Args:
message: Dictionnaire du message à envoyer
"""
for peer_id in list(self.active_connections.keys()):
await self.send_personal_message(message, peer_id)
logger.debug(f"Broadcasted to all: {message.get('type')}")
def get_room_members(self, room_id: str) -> Set[str]:
"""
Obtenir la liste des peer_id dans une room.
Args:
room_id: ID de la room
Returns:
Set des peer_id dans la room
"""
return self.room_members.get(room_id, set()).copy()
def is_connected(self, peer_id: str) -> bool:
"""
Vérifier si un peer est connecté.
Args:
peer_id: Identifiant du peer
Returns:
True si le peer est connecté
"""
return peer_id in self.active_connections
def get_user_id(self, peer_id: str) -> str:
"""
Obtenir l'user_id associé à un peer_id.
Args:
peer_id: Identifiant du peer
Returns:
user_id ou None si non trouvé
"""
return self.peer_to_user.get(peer_id)
def is_user_in_room(self, user_id: str, room_id: str) -> bool:
"""
Vérifier si un utilisateur est actuellement actif dans une room.
Args:
user_id: ID de l'utilisateur
room_id: ID de la room
Returns:
True si l'utilisateur a au moins un peer connecté dans la room
"""
if room_id not in self.room_members:
return False
# Parcourir tous les peers de la room
for peer_id in self.room_members[room_id]:
if self.get_user_id(peer_id) == user_id:
return True
return False
# Instance globale du manager
manager = ConnectionManager()

285
server/test_api.py Executable file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python3
# Created by: Claude
# Date: 2026-01-02
# Purpose: Script de test rapide pour l'API Mesh
# Refs: server/CLAUDE.md
"""
Script de test pour vérifier le fonctionnement de base de l'API Mesh.
Usage:
python test_api.py
Le serveur doit être lancé avant d'exécuter ce script.
"""
import requests
import json
from typing import Optional
# Configuration
BASE_URL = "http://localhost:8000"
class Colors:
"""Codes ANSI pour les couleurs."""
GREEN = '\033[92m'
RED = '\033[91m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
END = '\033[0m'
def print_success(message: str):
"""Afficher un message de succès."""
print(f"{Colors.GREEN}{message}{Colors.END}")
def print_error(message: str):
"""Afficher un message d'erreur."""
print(f"{Colors.RED}{message}{Colors.END}")
def print_info(message: str):
"""Afficher un message d'information."""
print(f"{Colors.BLUE} {message}{Colors.END}")
def print_section(title: str):
"""Afficher un titre de section."""
print(f"\n{Colors.YELLOW}{'='*60}{Colors.END}")
print(f"{Colors.YELLOW}{title}{Colors.END}")
print(f"{Colors.YELLOW}{'='*60}{Colors.END}\n")
def test_health():
"""Tester le endpoint de santé."""
print_section("Test Health Check")
try:
response = requests.get(f"{BASE_URL}/health")
response.raise_for_status()
data = response.json()
if data.get("status") == "healthy":
print_success("Health check passed")
return True
else:
print_error(f"Health check failed: {data}")
return False
except Exception as e:
print_error(f"Health check error: {str(e)}")
return False
def test_register(username: str, password: str) -> Optional[str]:
"""Tester l'enregistrement d'un utilisateur."""
print_section(f"Test Register - {username}")
try:
response = requests.post(
f"{BASE_URL}/api/auth/register",
json={
"username": username,
"password": password,
"email": f"{username}@example.com"
}
)
if response.status_code == 201:
data = response.json()
print_success(f"User registered: {data['username']}")
print_info(f"User ID: {data['user_id']}")
print_info(f"Token: {data['access_token'][:20]}...")
return data['access_token']
elif response.status_code == 400:
print_info("User already exists (expected if running multiple times)")
# Essayer de se connecter à la place
return test_login(username, password)
else:
print_error(f"Registration failed: {response.status_code} - {response.text}")
return None
except Exception as e:
print_error(f"Registration error: {str(e)}")
return None
def test_login(username: str, password: str) -> Optional[str]:
"""Tester la connexion d'un utilisateur."""
print_section(f"Test Login - {username}")
try:
response = requests.post(
f"{BASE_URL}/api/auth/login",
json={
"username": username,
"password": password
}
)
response.raise_for_status()
data = response.json()
print_success(f"User logged in: {data['username']}")
print_info(f"Token: {data['access_token'][:20]}...")
return data['access_token']
except Exception as e:
print_error(f"Login error: {str(e)}")
return None
def test_get_me(token: str):
"""Tester la récupération des infos utilisateur."""
print_section("Test Get User Info")
try:
response = requests.get(
f"{BASE_URL}/api/auth/me",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
data = response.json()
print_success("User info retrieved")
print_info(f"Username: {data['username']}")
print_info(f"User ID: {data['user_id']}")
except Exception as e:
print_error(f"Get user info error: {str(e)}")
def test_create_room(token: str, room_name: str) -> Optional[str]:
"""Tester la création d'une room."""
print_section(f"Test Create Room - {room_name}")
try:
response = requests.post(
f"{BASE_URL}/api/rooms/",
json={"name": room_name},
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
data = response.json()
print_success(f"Room created: {data['name']}")
print_info(f"Room ID: {data['room_id']}")
return data['room_id']
except Exception as e:
print_error(f"Create room error: {str(e)}")
return None
def test_list_rooms(token: str):
"""Tester la liste des rooms."""
print_section("Test List Rooms")
try:
response = requests.get(
f"{BASE_URL}/api/rooms/",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
data = response.json()
print_success(f"Found {len(data)} room(s)")
for room in data:
print_info(f" - {room['name']} ({room['room_id']})")
except Exception as e:
print_error(f"List rooms error: {str(e)}")
def test_get_room(token: str, room_id: str):
"""Tester la récupération d'une room."""
print_section("Test Get Room Details")
try:
response = requests.get(
f"{BASE_URL}/api/rooms/{room_id}",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
data = response.json()
print_success("Room details retrieved")
print_info(f"Name: {data['name']}")
print_info(f"Members: {data['member_count']}")
except Exception as e:
print_error(f"Get room error: {str(e)}")
def test_request_capability(token: str, room_id: str):
"""Tester la demande de capability token."""
print_section("Test Request Capability Token")
try:
response = requests.post(
f"{BASE_URL}/api/auth/capability",
json={
"room_id": room_id,
"capabilities": ["call", "share:file"]
},
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
data = response.json()
print_success("Capability token obtained")
print_info(f"Token: {data['cap_token'][:20]}...")
print_info(f"Expires in: {data['expires_in']}s")
except Exception as e:
print_error(f"Request capability error: {str(e)}")
def main():
"""Fonction principale du test."""
print(f"{Colors.BLUE}")
print("╔════════════════════════════════════════════════════════════╗")
print("║ MESH SERVER API TEST SUITE ║")
print("╚════════════════════════════════════════════════════════════╝")
print(f"{Colors.END}")
# Test de santé
if not test_health():
print_error("\n❌ Le serveur ne répond pas. Assurez-vous qu'il est démarré.")
return
# Enregistrer deux utilisateurs
user1_token = test_register("alice", "password123")
user2_token = test_register("bob", "password456")
if not user1_token or not user2_token:
print_error("\n❌ Impossible de créer les utilisateurs de test")
return
# Tester les infos utilisateur
test_get_me(user1_token)
# Créer une room
room_id = test_create_room(user1_token, "Test Room")
if room_id:
# Lister les rooms
test_list_rooms(user1_token)
# Détails de la room
test_get_room(user1_token, room_id)
# Demander un capability token
test_request_capability(user1_token, room_id)
print(f"\n{Colors.GREEN}")
print("╔════════════════════════════════════════════════════════════╗")
print("║ ✓ TESTS TERMINÉS ║")
print("╚════════════════════════════════════════════════════════════╝")
print(f"{Colors.END}")
print_info("Pour tester le WebSocket, utilisez le client web ou un outil comme wscat")
print_info("Exemple: wscat -c 'ws://localhost:8000/ws?token=YOUR_TOKEN'")
if __name__ == "__main__":
main()

208
server/test_gotify.py Normal file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
# Created by: Claude
# Date: 2026-01-03
# Purpose: Script de test pour les notifications Gotify
# Refs: server/CLAUDE.md
import asyncio
import httpx
from datetime import datetime
# Configuration
API_URL = "http://localhost:8000"
GOTIFY_URL = "http://10.0.0.5:8185"
GOTIFY_TOKEN = "AvKcy9o-yvVhyKd"
# Utilisateurs de test
USER1 = {
"email": "alice@test.com",
"username": "alice",
"password": "password123"
}
USER2 = {
"email": "bob@test.com",
"username": "bob",
"password": "password123"
}
async def test_gotify_direct():
"""Tester l'envoi direct à Gotify."""
print("\n" + "="*60)
print("TEST 1: Envoi direct à Gotify")
print("="*60)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
payload = {
"title": "🧪 Test Mesh",
"message": "Ceci est un test de notification depuis Mesh",
"priority": 5,
}
response = await client.post(
f"{GOTIFY_URL}/message",
params={"token": GOTIFY_TOKEN},
json=payload,
)
response.raise_for_status()
print(f"✅ Notification envoyée avec succès à Gotify")
print(f" Response: {response.json()}")
return True
except Exception as e:
print(f"❌ Erreur: {e}")
return False
async def register_user(user_data):
"""Créer un utilisateur de test."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{API_URL}/api/auth/register",
json=user_data
)
if response.status_code == 200:
data = response.json()
print(f"✅ Utilisateur {user_data['username']} créé")
return data['access_token']
elif response.status_code == 400:
# Utilisateur existe déjà, essayer de login
response = await client.post(
f"{API_URL}/api/auth/login",
data={
"username": user_data["email"],
"password": user_data["password"]
}
)
if response.status_code == 200:
data = response.json()
print(f" Utilisateur {user_data['username']} existe déjà (login)")
return data['access_token']
response.raise_for_status()
except Exception as e:
print(f"❌ Erreur création/login {user_data['username']}: {e}")
return None
async def create_room(token, room_name):
"""Créer une room de test."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{API_URL}/api/rooms",
headers={"Authorization": f"Bearer {token}"},
json={"name": room_name}
)
response.raise_for_status()
data = response.json()
print(f"✅ Room '{room_name}' créée: {data['room_id']}")
return data['room_id']
except Exception as e:
print(f"❌ Erreur création room: {e}")
return None
async def send_message(token, room_id, message):
"""Envoyer un message dans une room (via API REST)."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{API_URL}/api/rooms/{room_id}/messages",
headers={"Authorization": f"Bearer {token}"},
json={"content": message}
)
if response.status_code == 404:
print(f"⚠️ Endpoint POST /api/rooms/{room_id}/messages n'existe pas")
print(f" (Normal: envoi de messages via WebSocket uniquement)")
return False
response.raise_for_status()
print(f"✅ Message envoyé: {message[:50]}...")
return True
except Exception as e:
print(f"⚠️ Pas d'API REST pour messages: {e}")
return False
async def test_chat_notification():
"""Tester notification de chat (sans WebSocket)."""
print("\n" + "="*60)
print("TEST 2: Notification de chat (setup)")
print("="*60)
# Créer/login utilisateurs
alice_token = await register_user(USER1)
bob_token = await register_user(USER2)
if not alice_token or not bob_token:
print("❌ Impossible de créer les utilisateurs")
return False
# Créer une room
room_id = await create_room(alice_token, "Test Gotify Chat")
if not room_id:
print("❌ Impossible de créer la room")
return False
print("\n📝 NOTE:")
print(" Les notifications de chat nécessitent une connexion WebSocket.")
print(" Pour tester complètement:")
print(" 1. Bob doit se déconnecter de la room")
print(" 2. Alice envoie un message via WebSocket")
print(" 3. Bob devrait recevoir une notification Gotify")
print("")
print(" Utilisez le client web pour tester end-to-end.")
return True
async def main():
"""Exécuter tous les tests."""
print("\n" + "="*60)
print("🧪 TESTS NOTIFICATIONS GOTIFY - MESH")
print("="*60)
print(f"API URL: {API_URL}")
print(f"Gotify URL: {GOTIFY_URL}")
print(f"Timestamp: {datetime.now().isoformat()}")
# Test 1: Envoi direct
success1 = await test_gotify_direct()
# Test 2: Setup pour chat
success2 = await test_chat_notification()
# Résumé
print("\n" + "="*60)
print("RÉSUMÉ DES TESTS")
print("="*60)
print(f"Test 1 - Envoi direct Gotify: {'✅ PASS' if success1 else '❌ FAIL'}")
print(f"Test 2 - Setup chat: {'✅ PASS' if success2 else '❌ FAIL'}")
print("")
if success1:
print("✅ Gotify est correctement configuré et accessible")
print("✅ Les notifications peuvent être envoyées")
print("")
print("📱 Vérifiez votre application Gotify pour voir la notification")
else:
print("❌ Problème de configuration Gotify")
print(" Vérifiez:")
print(" - GOTIFY_URL est correct dans .env")
print(" - GOTIFY_TOKEN est valide")
print(" - Le serveur Gotify est accessible")
if __name__ == "__main__":
asyncio.run(main())

247
server/test_p2p_api.py Executable file
View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# Created by: Claude
# Date: 2026-01-02
# Purpose: Script de test pour les endpoints P2P
# Refs: server/CLAUDE.md
"""
Script de test pour vérifier les endpoints P2P du serveur Mesh.
"""
import requests
import json
from typing import Optional
# Configuration
BASE_URL = "http://localhost:8000"
class Colors:
"""Codes ANSI pour les couleurs."""
GREEN = '\033[92m'
RED = '\033[91m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
END = '\033[0m'
def print_success(message: str):
"""Afficher un message de succès."""
print(f"{Colors.GREEN}{message}{Colors.END}")
def print_error(message: str):
"""Afficher un message d'erreur."""
print(f"{Colors.RED}{message}{Colors.END}")
def print_info(message: str):
"""Afficher un message d'information."""
print(f"{Colors.BLUE} {message}{Colors.END}")
def print_section(title: str):
"""Afficher un titre de section."""
print(f"\n{Colors.YELLOW}{'='*60}{Colors.END}")
print(f"{Colors.YELLOW}{title}{Colors.END}")
print(f"{Colors.YELLOW}{'='*60}{Colors.END}\n")
def register_user(username: str, password: str) -> Optional[str]:
"""Enregistrer un utilisateur et retourner le token."""
try:
response = requests.post(
f"{BASE_URL}/api/auth/register",
json={
"username": username,
"password": password,
"email": f"{username}@example.com"
}
)
if response.status_code == 201:
return response.json()['access_token']
elif response.status_code == 400:
# User exists, login instead
response = requests.post(
f"{BASE_URL}/api/auth/login",
json={"username": username, "password": password}
)
if response.status_code == 200:
return response.json()['access_token']
except Exception as e:
print_error(f"Registration error: {str(e)}")
return None
def create_room(token: str, room_name: str) -> Optional[str]:
"""Créer une room et retourner le room_id."""
try:
response = requests.post(
f"{BASE_URL}/api/rooms/",
json={"name": room_name},
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
return response.json()['room_id']
except Exception as e:
print_error(f"Create room error: {str(e)}")
return None
def test_create_p2p_session(token: str, room_id: str):
"""Tester la création d'une session P2P."""
print_section("Test Create P2P Session")
try:
response = requests.post(
f"{BASE_URL}/api/p2p/session",
json={
"room_id": room_id,
"target_peer_id": "peer_target_123",
"kind": "file",
"capabilities": ["share:file"]
},
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
data = response.json()
print_success("P2P session created")
print_info(f"Session ID: {data['session_id']}")
print_info(f"Session Token: {data['session_token'][:30]}...")
print_info(f"Kind: {data['kind']}")
print_info(f"Expires at: {data['expires_at']}")
return data['session_id']
except Exception as e:
print_error(f"Create P2P session error: {str(e)}")
if hasattr(e, 'response') and e.response is not None:
print_error(f"Response: {e.response.text}")
return None
def test_list_p2p_sessions(token: str):
"""Tester la liste des sessions P2P."""
print_section("Test List P2P Sessions")
try:
response = requests.get(
f"{BASE_URL}/api/p2p/sessions",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
data = response.json()
sessions = data.get('sessions', [])
print_success(f"Found {len(sessions)} active session(s)")
for session in sessions:
print_info(f" - {session['session_id']} ({session['kind']})")
except Exception as e:
print_error(f"List P2P sessions error: {str(e)}")
def test_close_p2p_session(token: str, session_id: str):
"""Tester la fermeture d'une session P2P."""
print_section("Test Close P2P Session")
try:
response = requests.delete(
f"{BASE_URL}/api/p2p/session/{session_id}",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
data = response.json()
print_success(data['message'])
except Exception as e:
print_error(f"Close P2P session error: {str(e)}")
def test_invalid_kind(token: str, room_id: str):
"""Tester avec un kind invalide."""
print_section("Test Invalid Session Kind")
try:
response = requests.post(
f"{BASE_URL}/api/p2p/session",
json={
"room_id": room_id,
"target_peer_id": "peer_target_123",
"kind": "invalid_kind",
"capabilities": ["share:file"]
},
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 400:
print_success("Invalid kind correctly rejected")
print_info(f"Error: {response.json()['detail']}")
else:
print_error(f"Expected 400, got {response.status_code}")
except Exception as e:
print_error(f"Invalid kind test error: {str(e)}")
def main():
"""Fonction principale du test."""
print(f"{Colors.BLUE}")
print("╔════════════════════════════════════════════════════════════╗")
print("║ MESH P2P API TEST SUITE ║")
print("╚════════════════════════════════════════════════════════════╝")
print(f"{Colors.END}")
# Enregistrer un utilisateur
print_section("Setup: Register User & Create Room")
token = register_user("alice_p2p", "password123")
if not token:
print_error("\n❌ Impossible de créer l'utilisateur de test")
return
print_success("User registered/logged in")
# Créer une room
room_id = create_room(token, "P2P Test Room")
if not room_id:
print_error("\n❌ Impossible de créer la room de test")
return
print_success(f"Room created: {room_id}")
# Tester la création de session P2P
session_id = test_create_p2p_session(token, room_id)
if session_id:
# Lister les sessions actives
test_list_p2p_sessions(token)
# Fermer la session
test_close_p2p_session(token, session_id)
# Vérifier que la session a été fermée
test_list_p2p_sessions(token)
# Tester avec un kind invalide
test_invalid_kind(token, room_id)
print(f"\n{Colors.GREEN}")
print("╔════════════════════════════════════════════════════════════╗")
print("║ ✓ TESTS P2P TERMINÉS ║")
print("╚════════════════════════════════════════════════════════════╝")
print(f"{Colors.END}")
if __name__ == "__main__":
main()