Partie 1 : Globe 3D — Carte des connexions en temps réel

Partie 1 : Globe 3D — Carte des connexions en temps réel

Mars 2026

Le besoin

J'avais déjà un dashboard Grafana Geomap pour visualiser les connexions entrantes sur le cluster. Ça fonctionne bien, mais c'est... Grafana. C'est un outil de monitoring, pas une vitrine.

L'idée : créer une page web publique directement sur home-fonta.fr, avec un globe terrestre 3D et des arcs animés style "cyber threat map" (comme les cartes Kaspersky ou Norse Attack Map). En gros, transformer les logs nginx en quelque chose de visuellement sympa, accessible à n'importe quel visiteur sans compte Grafana.

Les données existent déjà dans Loki — autant les réutiliser.

Carte3d.jpeg

Architecture

La chaîne de données existante

Je ne repars pas de zéro. Toute la chaîne de collecte est déjà en place depuis la phase monitoring + Loki :

Visiteur externe → Nginx Ingress (logs JSON)
                          │
                          ▼
                    Promtail (DaemonSet)
                    ├── Parse JSON (remote_addr)
                    └── Enrichissement GeoIP (DB-IP MMDB)
                        ├── geoip_location_latitude
                        ├── geoip_location_longitude
                        ├── geoip_country_name
                        └── geoip_city_name
                          │
                          ▼
                        Loki (stockage)
                          │
                          ▼
              ┌───────────┴───────────┐
              │                       │
        Grafana Geomap         Flask API (NOUVEAU)
        (monitoring interne)       │
                                   ▼
                             Globe.gl (WebGL)
                             (page publique)

La nouveauté c'est la branche droite : Flask interroge Loki directement via son API HTTP interne (loki.loki.svc.cluster.local:3100), transforme les résultats en JSON, et le navigateur du visiteur dessine le globe 3D.

Point clé : le rendu est côté client

Le globe 3D utilise WebGL (via Globe.gl / three.js). Tout le rendu graphique se fait dans le navigateur du visiteur, pas sur les Raspberry Pi. Le serveur Flask ne fait que :

  1. Servir les fichiers HTML/CSS/JS (via Nginx, cache 7 jours)
  2. Répondre à l'API /api/map-data (une requête Loki, quelques ko de JSON)

Zéro impact GPU sur le cluster.

Les technologies utilisées

Technologie Rôle Pourquoi ce choix
Globe.gl Globe 3D WebGL Spécialisé pour les globes avec arcs, basé sur three.js, open-source
three.js Moteur 3D (sous Globe.gl) Standard du WebGL, performant
Flask Backend API Déjà en place, léger, parfait pour un endpoint JSON
Loki Source de données Déjà en place, contient tous les logs nginx avec GeoIP
requests (Python) Client HTTP Pour que Flask interroge l'API Loki

Implémentation

1. L'API backend (app.py)

Deux nouvelles routes ajoutées au Flask existant :

@app.route('/map')
def attack_map():
    return render_template('map.html')

@app.route('/api/map-data')
def map_data():
    # Interroge Loki, retourne du JSON

La route /api/map-data accepte un paramètre range (5m, 15m, 1h, 6h, 24h) qui contrôle la fenêtre temporelle. Par sécurité, seules ces valeurs sont acceptées — on ne veut pas qu'un visiteur injecte du LogQL arbitraire.

La requête LogQL envoyée à Loki :

sum by (geoip_location_latitude, geoip_location_longitude,
        geoip_country_name, geoip_city_name)
(count_over_time(
    {namespace="ingress-nginx", geoip_location_latitude=~".+"}
    [1h]
))

C'est la même requête que le dashboard Grafana Geomap ! Elle :

  1. Sélectionne les logs nginx qui ont une géolocalisation (exclut les IPs privées)
  2. Compte le nombre de logs par position sur la période
  3. Regroupe par coordonnées + pays + ville

Le Flask transforme la réponse Loki en JSON propre :

{
  "arcs": [
    {"lat": 48.85, "lon": 2.35, "country": "France", "city": "Paris", "count": 42},
    {"lat": 40.71, "lon": -74.01, "country": "United States", "city": "New York", "count": 15}
  ],
  "total_ips": 23,
  "total_hits": 187,
  "top_countries": [
    {"country": "France", "hits": 89},
    {"country": "United States", "hits": 34}
  ],
  "server": {"lat": 43.6, "lon": 1.44}
}

Le champ server contient les coordonnées de notre serveur (Toulouse). C'est la destination de tous les arcs.

2. Le globe 3D (map.js)

Globe.gl s'initialise en quelques lignes, mais chaque option compte :

const globe = Globe()
  // Texture de la Terre (photo satellite de nuit — NASA)
  .globeImageUrl("//unpkg.com/three-globe/example/img/earth-night.jpg")
  // Relief 3D (montagnes, fosses océaniques)
  .bumpImageUrl("//unpkg.com/three-globe/example/img/earth-topology.png")
  // Fond étoilé
  .backgroundImageUrl("//unpkg.com/three-globe/example/img/night-sky.png")
  // Halo bleu autour du globe (atmosphère terrestre)
  .atmosphereColor("rgba(100, 180, 255, 0.4)")
  .atmosphereAltitude(0.2)

Les arcs animés

Chaque arc est une courbe 3D entre une source (l'IP géolocalisée) et la destination (mon serveur). Les propriétés clés :

// Couleurs : dégradé du cyan (source) vers l'orange (destination)
.arcColor(d => ["rgba(0, 255, 255, 0.9)", "rgba(255, 100, 50, 0.6)"])
// Épaisseur proportionnelle au nombre de hits (échelle log)
.arcStroke(d => Math.min(0.5 + Math.log2(d.count + 1) * 0.3, 3))
// Effet de pointillés animés (comme des données qui voyagent)
.arcDashLength(0.6)
.arcDashGap(0.3)
.arcDashAnimateTime(2000)  // 2 secondes pour traverser le globe

Pourquoi Math.log2 ? Sans échelle logarithmique, un pays avec 10 000 hits aurait un arc 1000x plus épais qu'un pays avec 10 hits — illisible. Le logarithme "écrase" les grandes valeurs : log2(10) = 3.3, log2(10000) = 13.3. L'écart visuel est bien plus raisonnable.

Carte3d_full.jpeg

Les points lumineux

À chaque source, un point cyan lumineux dont la taille dépend du nombre de hits :

.pointColor(() => "rgba(0, 255, 255, 0.8)")
.pointRadius(d => Math.min(0.3 + Math.log2(d.count + 1) * 0.15, 2))

Carte3d_europe.jpeg

3. Le panneau de statistiques

En bas à gauche, un panneau semi-transparent (backdrop-filter: blur) :

#stats-panel {
  background: rgba(10, 10, 30, 0.85);
  backdrop-filter: blur(12px);
  border: 1px solid rgba(0, 255, 255, 0.2);
}

Carte3d_panneau.jpeg

Le panneau contient :

  • Sélecteur de période : boutons 5m / 15m / 1h / 6h / 24h
  • Compteurs : IPs uniques et nombre total de requêtes
  • Top 5 pays : classés par nombre de hits
  • Feed des dernières connexions : les sources les plus actives

Les données se rafraîchissent automatiquement toutes les 10 secondes via polling :

fetchData();                           // Premier chargement immédiat
setInterval(fetchData, REFRESH_INTERVAL); // Puis toutes les 10s

4. Le mode plein écran

Un bouton en haut à droite permet de passer en fullscreen via l'API Fullscreen du navigateur :

document.getElementById("fullscreen-btn").addEventListener("click", () => {
  if (!document.fullscreenElement) {
    document.documentElement.requestFullscreen();
  } else {
    document.exitFullscreen();
  }
});

Carte3d_full_screen.webp

Fichiers créés / modifiés

Nouveaux fichiers

Fichier Taille Rôle
templates/map.html ~2 ko Page HTML (conteneur globe + panneau stats + menu)
static/map.js ~5 ko Logique Globe.gl + polling API + gestion UI
static/map.css ~4 ko Styles dark theme + glassmorphism + responsive

Fichiers modifiés

Fichier Modification
app.py +2 routes (/map, /api/map-data) + imports + config Loki
requirements.txt +requests==2.32.3
templates/menu.html +lien "Carte" dans la sidebar
tests/test_app.py +2 tests (test_map_page, test_map_api)

Déploiement

Aucune modification d'infrastructure nécessaire. Le pipeline CI/CD existant gère tout :

git push (repo home-fonta)
    │
    ▼
GitHub Actions CI (flake8 + pytest)
    │ 8 tests passed ✓
    ▼
GitHub Actions Docker Build
    │ Multi-arch (amd64 + arm64)
    │ Push sur Docker Hub
    ▼
GitHub Actions Update Helm
    │ Met à jour le tag image dans ansible-k3s
    ▼
ArgoCD détecte le changement
    │ Auto-sync
    ▼
Nouveau pod déployé sur le cluster K3s

Zéro intervention manuelle. Le push déclenche toute la chaîne, toujours aussi incroyable à regarder faire.

Comparaison avec le dashboard Grafana

Aspect Grafana Geomap Globe 3D
Accès Compte Grafana requis Public, accessible à tous
Animations Marqueurs statiques Arcs animés avec traînée
Données Via datasource Loki (Grafana) Via API Flask → Loki
Refresh Configurable (1min par défaut) 10 secondes
Rendu Côté serveur (Grafana) Côté client (WebGL)
Impact cluster Charge Grafana Quasi nul
Fullscreen Oui (natif Grafana) Oui (API Fullscreen)
Stats Pas sur le même panneau Panneau intégré

Carte3d_full_screen.webp
Carte3d.jpeg

Ce qu'on voit en pratique

En situation réelle, le globe affiche typiquement :

  • Des arcs depuis la France (connexions légitimes — moi)
  • Des arcs depuis les USA (bots, crawlers Google/Bing)
  • Des arcs depuis la Chine/Russie (scans automatisés)
  • Parfois des arcs depuis des pays inattendus (Brésil, Indonésie...)

Carte3d_asie.jpeg

Le sélecteur de période permet de passer rapidement de "les 5 dernières minutes" à "les dernières 24 heures" pour voir l'évolution. Sur 24h, le globe est généralement couvert d'arcs — sur 5 minutes, on voit les connexions en quasi temps réel.

Sécurité

Quelques précautions prises :

  1. Validation du paramètre range : seules les valeurs 5m, 15m, 1h, 6h, 12h, 24h sont acceptées. Impossible d'injecter du LogQL.

  2. Pas d'exposition de données sensibles : l'API retourne des coordonnées GPS (précision ville), des noms de pays/villes et des compteurs. Pas d'IPs, pas de user-agents, pas d'URLs visitées.

  3. Loki non exposé : l'API Loki n'est accessible que depuis l'intérieur du cluster (service ClusterIP). Le visiteur ne peut pas interroger Loki directement.

  4. Graceful degradation : si Loki est down, l'API retourne un JSON vide. Le globe s'affiche normalement, juste sans arcs.

Pièges rencontrés

Piège 1 : import requests vs flask.request

Python a un package requests (pour faire des requêtes HTTP) et Flask a un objet request (la requête HTTP du visiteur). Si on fait from flask import request et import requests, pas de conflit. Mais pour la lisibilité, j'ai renommé : import requests as http_client.

Piège 2 : Promtail CPU throttled

Pendant le développement, j'ai découvert que le Promtail sur rpi4-master était bloqué à 499m/500m CPU — complètement throttlé. Les push vers Loki échouaient tous. J'ai augmenté la limite à 750m, ce qui a réglé le problème. Sans Promtail fonctionnel, pas de logs dans Loki, et donc pas de données pour le globe.

Piège 3 : Globe.gl et les versions CDN

Globe.gl évolue régulièrement. J'ai pin la version 2.35.1 dans le script tag pour éviter les breaking changes. Les textures (earth-night.jpg, etc.) sont aussi hébergées sur unpkg — si ce CDN tombe, le globe s'affiche sans texture mais reste fonctionnel.

Accès

La carte est accessible sur :

https://home-fonta.fr/map

Ou via le menu latéral du site (lien "Carte").

Récap

Élément Détail
URL home-fonta.fr/map
Backend Flask + requests → Loki API
Frontend Globe.gl (three.js WebGL)
Données Logs nginx enrichis GeoIP via Promtail
Refresh 10 secondes (polling)
Périodes 5m, 15m, 1h, 6h, 24h
Stats IPs uniques, total requêtes, top 5 pays, feed connexions
Fullscreen Oui (bouton + API Fullscreen)
Impact cluster Quasi nul (rendu WebGL côté client)
Déploiement Automatique via CI/CD + ArgoCD

La prochaine partie sera sur la même chose mais avec la carte 2D.