Skip to content

#31 — Allocation VLAN

PLANIFIÉ

Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/services/vlan_manager.pyDépendances: #1 rgz-api, #30 kea-dhcp


Description

Allocation automatique de VLAN lors de l'onboarding revendeur. Chaque revendeur V1 reçoit un VLAN unique entre 100 et 499. Le calcul du subnet est automatique : 10.[vlan].0.0/24 avec gateway 10.[vlan].0.1. Pour les revendeurs multi-sites (V2/V3), un seul VLAN est partagé entre tous les sites du même revendeur. Cette approche simplifie la gestion de la QoS et du DBA qui opèrent par VLAN.

Le NAS-ID (identifiant réseau RADIUS) suit la convention : access_[slug] en mono-site, access_[slug]_s[N] en multi-site. Le slug est le nom du revendeur en minuscules, sans accents, max 20 caractères. Cette allocation est stockée dans la table reseller_sites et synchronisée avec Kea DHCP pour la génération dynamique des pools d'adressage.

Le service expose des endpoints pour lire les allocations existantes, simuler une allocation avant confirmation, et supporter la migration de VLAN en cas de besoin. Les validations incluent : vérification que le VLAN demandé est libre, calcul du prochain VLAN disponible si auto-allocation, vérification du format du slug, et génération automatique du subnet.

Architecture Interne

1. Inscription revendeur via #56 onboarding
   └─> POST /api/v1/network/vlans/allocate
       └─> VlanManager.allocate_vlan(reseller_id, classif, slug, sites=[])
           ├─> Requête DB: SELECT COUNT(*) FROM reseller_sites WHERE reseller_id = ?
           ├─> Calcul: vlan_id = 100 + (hash(reseller_id) % 400) [recherche du prochain libre]
           ├─> INSERT reseller_sites(reseller_id, site_number, nas_id, ssid, vlan_id, subnet, gateway)
           ├─> Redis: SET rgz:vlan:{reseller_id} {vlan_id, subnet, gateway} TTL=3600s
           └─> Webhook → #30 Kea DHCP pour créer les pools DHCP

2. Multi-site (V2/V3):
   └─> Même VLAN, NAS-ID différents par site
       ├─> Site 1: access_slug_s1 → VLAN 150, Subnet 10.150.0.0/24
       ├─> Site 2: access_slug_s2 → VLAN 150 (partagé), Subnet 10.150.0.0/24
       ├─> IPs: 10.150.0.2 (AP Site 1), 10.150.0.3 (AP Site 2), etc.
       └─> Broadcast domain unique pour IPv4 (VLAN trunk vers tous les sites)

3. DBA recalcul (every 5min via #26):
   └─> RADIUS → rgz-api → SELECT bytes FROM radius_sessions WHERE nas_id = ?
       └─> Applique MIR par VLAN via CoA (Change-of-Authorization)
       └─> Impacts: #27 HTB QoS, #28 DSCP marking

4. Validation Subnet:
   └─> Vérification CIDR 10.[100-499].0.0/24
   └─> Gateway: n.0.1
   └─> Range DHCP: n.0.10 - n.0.254

Configuration

Variables d'environnement

env
VLAN_MIN=100                          # VLAN minimum alloué
VLAN_MAX=499                          # VLAN maximum alloué
SUBNET_PREFIX_IPV4=10                 # Préfixe réseau (toujours 10.vlan.0.0/24)
KEAVERN_API=http://rgz-kea:8000      # API Kea pour synchro pools DHCP
KEAVERN_SECRET=kea_sync_secret        # Secret HMAC pour webhook Kea
VLAN_AUTO_ALLOCATION=true             # Activation allocation auto
VLAN_SLUG_MAX_LENGTH=20               # Longueur max du slug

Model SQLAlchemy

python
# app/models/reseller.py
class ResellerSites(Base):
    __tablename__ = "reseller_sites"

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
    reseller_id = Column(UUID(as_uuid=True), ForeignKey("resellers.id"), nullable=False)
    site_number = Column(Integer, nullable=False)  # 1-indexed, V2/V3 uniquement
    nas_id = Column(String(50), unique=True, nullable=False)  # access_slug[_sN]
    ssid = Column(String(32), nullable=False)  # "ACCESS [ResellerName] [Site]" ou "ACCESS [Name]"
    vlan_id = Column(Integer, nullable=False,
                     CheckConstraint("vlan_id BETWEEN 100 AND 499"))
    subnet_cidr = Column(String(18), nullable=False)  # "10.150.0.0/24"
    gateway_ip = Column(String(15), nullable=False)  # "10.150.0.1"
    latitude = Column(Float)  # Pour #68 site survey
    longitude = Column(Float)
    city = Column(String(50))
    quarter = Column(String(50))
    ap_ip = Column(String(15), nullable=True)  # IP d'accès SSH au CPE

    created_at = Column(DateTime, default=utcnow, nullable=False)
    updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)

    __table_args__ = (
        UniqueConstraint("reseller_id", "site_number", name="uq_reseller_site_number"),
        UniqueConstraint("nas_id", name="uq_nas_id"),
    )

Endpoints API

MéthodeRouteRéponseAuth
POST/api/v1/network/vlans/allocate201 {vlan_id, subnet_cidr, gateway_ip, nas_id}Revendeur/Admin
GET/api/v1/network/vlans/{reseller_id}200 {items: [{vlan_id, nas_id, site_number, ...}], total}Revendeur/Admin
GET/api/v1/network/vlans200 {items: [...], total, page}Admin
GET/api/v1/network/vlans/simulate?slug=X&classif=V1&sites=1200 {vlan_id, subnet_cidr}Admin
DELETE/api/v1/network/vlans/{reseller_id}204Admin

Schémas Pydantic

python
# app/schemas/network.py

class VlanAllocateRequest(BaseModel):
    reseller_id: UUID
    classif: Literal["V1", "V2", "V3"]  # Classification revendeur
    slug: str  # Slug revendeur: min 3, max 20, ^[a-z0-9_]+$
    sites: List[SiteInput] = []  # V2/V3 uniquement

    class SiteInput(BaseModel):
        site_number: int  # 1-indexed
        ssid: str  # "ACCESS [ResellerName] [SiteName]"
        latitude: Optional[float] = None
        longitude: Optional[float] = None
        city: Optional[str] = None
        ap_ip: Optional[str] = None  # Pour SSH CPE

class VlanAllocateResponse(BaseModel):
    vlan_id: int
    subnet_cidr: str  # "10.150.0.0/24"
    gateway_ip: str
    nas_id: str
    sites: List[SiteAllocation]  # Multi-site

class SiteAllocation(BaseModel):
    site_number: int
    nas_id: str
    ap_ip: Optional[str]
    ssid: str

Commandes Utiles

bash
# Tester allocation VLAN
curl -X POST http://localhost:8000/api/v1/network/vlans/allocate \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "reseller_id": "550e8400-e29b-41d4-a716-446655440000",
    "classif": "V1",
    "slug": "test_reseller",
    "sites": []
  }'

# Vérifier allocation existante
curl -X GET http://localhost:8000/api/v1/network/vlans/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer $TOKEN"

# Simuler allocation V2 (3 sites)
curl -X GET 'http://localhost:8000/api/v1/network/vlans/simulate?slug=tech_connect&classif=V2&sites=3' \
  -H "Authorization: Bearer $TOKEN"

# Requête DB pour voir allocations
psql -h rgz-db -U rgz_admin -d rgz_db -c \
  "SELECT reseller_id, vlan_id, nas_id, site_number FROM reseller_sites ORDER BY vlan_id;"

Implémentation TODO

  • [ ] Implémenter VlanManager.allocate_vlan() avec allocation auto (hash modulo)
  • [ ] Implémenter VlanManager.get_next_free_vlan() (parcours 100-499)
  • [ ] Validation slug: regex ^[a-z0-9_]{3,20}$ (pas d'accents)
  • [ ] Endpoint POST allocate avec transactionnel DB + Redis cache
  • [ ] Endpoint GET {reseller_id} pour afficher VLAN + sites
  • [ ] Endpoint GET (admin) pour afficher tous les VLAN avec taux d'utilisation
  • [ ] Simulation d'allocation avant confirmation
  • [ ] Webhook vers Kea DHCP pour sync pools après insertion DB
  • [ ] Validation CIDR: toujours 10.[100-499].0.0/24
  • [ ] Suppression en cascade si revendeur supprimé
  • [ ] Tests: allocation auto, validation slug, multi-site, webhook Kea
  • [ ] Intégration avec #56 onboarding (appel allocate_vlan à l'étape 2)

Dernière mise à jour: 2026-02-21

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone