Skip to content

#32 — Générateur nftables

PLANIFIÉ

Priorité: 🔴 CRITIQUE · Type: TYPE E · Conteneur: rgz-gateway · Code: scripts/gateway/nftables_generator.pyDépendances: #7 rgz-gateway


Description

Génération dynamique des règles nftables par revendeur et VLAN. Le générateur construit une politique deny-all par défaut, puis alloue des règles spécifiques pour chaque revendeur. Chaque revendeur obtient une table dédiée rgz_vlan_NNN isolant son trafic, avec SNAT vers la gateway, whitelist DNS/NTP et routage vers le portail captif pour l'authentification.

Le script Python interroge la base de données PostgreSQL, lit les allocations VLAN depuis la table reseller_sites, et génère les règles nftables correspondantes. Les règles sont appliquées atomiquement via subprocess.run(['nft', ...]) avec gestion des erreurs et fallback. Une surveillance continue (inotifywait ou polling) recharge les règles si la DB change, permettant l'ajout/suppression de revendeurs sans redémarrage du conteneur.

Conformément à LL#40 et LL#41, le script ne supprime JAMAIS le ruleset entier avec nft flush table. Au lieu de cela, il supprime les tables custom une par une via nft delete table inet rgz_X 2>/dev/null || true, puis les recrée. L'entrypoint.sh du conteneur nettoie les tables au démarrage pour éviter les orphelins.

Architecture Interne

1. Démarrage rgz-gateway:
   └─> entrypoint.sh:
       ├─> nft delete table inet rgz_filter 2>/dev/null || true  [nettoyage]
       ├─> nft delete table inet rgz_vlan_* 2>/dev/null || true  [orphelins]
       ├─> python3 scripts/gateway/nftables_generator.py --init
       │   ├─> SELECT reseller_sites + vlan_id
       │   ├─> Générer nftables.nft complet
       │   └─> nft -f nftables.nft
       └─> python3 scripts/gateway/watch_config.sh  [monitoring continu]

2. Génération une table régulatrice (rgz_filter):
   ├─> Basée sur deny-all FORWARD
   ├─> Chaînes:
   │   ├─ ingress_vlan: VLAN → revendeur, DROP si VLAN unknown
   │   ├─ egress_vlan: revendeur → sortie, SNAT vers gateway
   │   ├─ whitelist: DNS 53/udp, NTP 123/udp, ICMP (ping)
   │   └─ dns_sinkhole: redirect vers portail avant auth
   └─> Policies:
       ├─ Trafic entre VLANs du même revendeur: ACCEPT (multi-site)
       ├─ Trafic entre VLANs différents: DROP (isolation)
       ├─ Sortie SNAT: sourc → 10.0.0.1 (IP gateway)
       └─ Entrée inverse: ACCEPT si état ESTABLISHED

3. Génération une table par VLAN (rgz_vlan_NNN):
   └─> Pour chaque reseller_site:
       ├─> table inet rgz_vlan_150
       ├─> chain forward_150
       │   ├─ ACCEPT vers DNS (whitelist Unbound sinkhole)
       │   ├─ ACCEPT vers NTP
       │   ├─ DROP verso ext services si pas auth (avant captif)
       │   └─ ACCEPT si direction ESTABLISHED
       └─> Logging: log prefix "vlan-150:" group 5 (netlink)

4. Réactions aux changements DB:
   └─> watch_config.sh (inotifywait ou polling 60s):
       ├─> Vérifier nouvelle table dans DB
       ├─> Si NOUVEAU: créer rgz_vlan_NNN, appliquer règles
       ├─> Si SUPPRESSION: nft delete table inet rgz_vlan_NNN
       └─> Log: syslog → ELK (#40)

5. DBA recalcul (#26):
   └─> CoA RADIUS → rgz-api → UPDATE rate_limit WHERE nas_id = ?
   └─> watch_config.sh applique limit via tc (HTB + fq_codel en #27)

Configuration

Variables d'environnement

env
DB_HOST=rgz-db                        # PostgreSQL hostname
DB_PORT=5432
DB_NAME=rgz_db
DB_USER=rgz_gateway                   # Service account (SELECT only)
DB_PASSWORD=...                       # From .env
GATEWAY_IP=10.0.0.1                  # IP sortie SNAT
WATCH_INTERVAL=60                     # Polling interval (secondes)
NFT_DRYRUN=false                      # true = test sans appliquer
NETLINK_GROUP=5                       # Pour logging nft → netlink

Structure de génération

python
# scripts/gateway/nftables_generator.py

def generate_nftables() -> str:
    """Génère config nftables complète"""
    rules = []

    # 1. Table régulatrice
    rules.append("""
    table inet rgz_filter {
        chain prerouting { type filter hook prerouting priority -300; policy accept; }
        chain forward { type filter hook forward priority 0; policy drop; }
        chain output { type filter hook output priority 0; policy accept; }

        chain ingress_vlan 
        chain egress_vlan 
        chain whitelist 
    }
    """)

    # 2. Tables par VLAN
    sites = db.session.query(ResellerSites).all()
    for site in sites:
        vlan_id = site.vlan_id
        rules.append(f"""
        table inet rgz_vlan_{vlan_id} {{
            chain forward_{vlan_id} {{
                type filter hook forward priority 0;
                policy drop;

                # Whitelist DNS
                ip daddr 10.0.0.9 udp dport 53 accept
                ip daddr 10.0.0.9 tcp dport 53 accept

                # Whitelist NTP
                udp dport 123 accept

                # ESTABLISHED/RELATED
                ct state established,related accept

                # Logging
                log prefix "vlan-{vlan_id}:" group {NETLINK_GROUP}
            }}
        }}
        """)

    return "\n".join(rules)

def apply_nftables(config: str) -> bool:
    """Applique config via nft"""
    try:
        result = subprocess.run(
            ["nft", "-f", "-"],
            input=config.encode(),
            capture_output=True,
            timeout=10
        )
        if result.returncode == 0:
            logger.info("nftables appliqué")
            return True
        else:
            logger.error(f"nft error: {result.stderr.decode()}")
            return False
    except Exception as e:
        logger.error(f"Exception applying nft: {e}")
        return False

def cleanup_stale_tables():
    """Supprime tables orphelins"""
    try:
        result = subprocess.run(
            ["nft", "list", "tables"],
            capture_output=True,
            timeout=5
        )
        tables = result.stdout.decode().split("\n")
        for table in tables:
            if "rgz_vlan_" in table and not is_active(table):
                subprocess.run(["nft", "delete", "table", "inet", table],
                             capture_output=True, timeout=5)
    except Exception as e:
        logger.warning(f"cleanup_stale_tables: {e}")

Endpoints API (indirects)

MéthodeRouteRéponseAuth
GET/api/v1/gateway/nftables/status200 {loaded, tables_count, last_reload}Admin
POST/api/v1/gateway/nftables/reload202 {status: queued}Admin
GET/api/v1/gateway/nftables/logs?vlan=150200 {items: [...], total}Admin

(Les endpoints sont sur rgz-api, le générateur tourne dans rgz-gateway.)

Commandes Utiles

bash
# Générer et appliquer configuration (manuel)
python3 /scripts/gateway/nftables_generator.py --apply

# Mode dry-run (affiche sans appliquer)
python3 /scripts/gateway/nftables_generator.py --dry-run

# Vérifier tables chargées
nft list tables

# Lister règles d'une table spécifique
nft list table inet rgz_vlan_150

# Démarrer watchdog (monitoring continu)
python3 /scripts/gateway/watch_config.sh

# Voir les logs netlink nftables
tcpdump -i nflog:5 -A  # Group 5

# Supprimer une table (orphelin)
nft delete table inet rgz_vlan_150

# Vérifier la syntaxe nft avant appliquer
nft -c -f /tmp/nftables.nft

Implémentation TODO

  • [ ] Implémenter generate_nftables() avec table rgz_filter + rgz_vlan_NNN
  • [ ] Connexion PostgreSQL dans Python (psycopg2, échappement SQL)
  • [ ] Fonction apply_nftables(config) via subprocess.run(['nft', '-f', '-'])
  • [ ] Fonction cleanup_stale_tables() pour suppression sélective (LL#40, LL#41)
  • [ ] Entrypoint.sh: nettoyage au démarrage avant appliquer
  • [ ] Watchdog inotifywait ou polling DB toutes les 60s
  • [ ] Logging → syslog → ELK (integration #40)
  • [ ] Synchronisation avec #26 DBA et #27 HTB QoS
  • [ ] Tests: création table, suppression, reload après changement DB
  • [ ] Tests LL#40/LL#41: vérifier pas de nft flush, seulement nft delete table
  • [ ] Intégration #31 VLAN: lecture reseller_sites, génération par vlan_id
  • [ ] Isolement multi-site: même VLAN = trafic ACCEPT, VLAN différents = DROP

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

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