L'iPhone n'est joignable depuis nulle part - CGNAT operateur en 4G, NAT de la box sur le WiFi du studio, UPnP indisponible. Le serveur RTSP de l'application camera est donc inutilisable de l'exterieur. Seule une connexion sortante traverse les deux NAT : c'est le telephone qui pousse. - MediaMTX en conteneur : SRT/RTMP en entree, WebRTC (WHEP) et HLS en sortie. La video ne traverse jamais la JVM - 800 kbps continus dans Spring Boot couteraient un thread par spectateur sans rien apporter. - SRT privilegie sur RTMP : RTMP est du TCP, un paquet perdu sur un lien mobile bloque tout le flux. SRT retransmet selectivement en UDP. - Lecture publique (choix assume), publication authentifiee : sans ca n'importe qui pousserait sa propre video sur le flux "biblio". - Console : client WHEP maison avec repli HLS automatique, et mise en pause quand l'onglet passe en arriere-plan. Correction d'un bug trouve en deployant : le @ExceptionHandler(Exception) avalait les exceptions que Spring sait deja traduire, si bien que toute URL inconnue renvoyait 500 au lieu de 404 - ce qui noyait les vraies pannes au milieu des 500 provoques par les crawlers. Test de regression ajoute. 35 tests au vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
423 lines
18 KiB
Markdown
423 lines
18 KiB
Markdown
# iot-alarm-system
|
|
|
|
Système d'alarme événementiel pour capteurs IoT.
|
|
**Java 21 · Spring Boot 3 · Apache Kafka · Architecture hexagonale · CQRS · DDD**
|
|
|
|
🔗 **Démonstration en ligne : https://video-seurveillance.belkacemi.me**
|
|
|
|
---
|
|
|
|
## Le contexte
|
|
|
|
Mon studio a été cambriolé.
|
|
|
|
Les offres de vidéosurveillance du marché se ressemblent toutes : une caméra à
|
|
80 €, puis un abonnement mensuel pour avoir le droit d'accéder à ses propres
|
|
images. J'avais un vieil iPhone dans un tiroir et une bibliothèque bien remplie.
|
|
L'iPhone est désormais calé entre deux livres, objectif vers la porte d'entrée.
|
|
|
|
Ce dépôt est le backend qui écoute ce capteur : il ingère les détections de
|
|
mouvement, décide lesquelles constituent une intrusion, et pousse l'alerte en
|
|
temps réel sur une console de supervision.
|
|
|
|
Le problème est volontairement simple. Ce qui est travaillé ici, c'est la
|
|
**structure** : un domaine métier isolé du framework, un bus d'événements, une
|
|
séparation lecture/écriture, et des règles d'architecture vérifiées par les
|
|
tests plutôt que par la discipline.
|
|
|
|
---
|
|
|
|
## Le flux
|
|
|
|
### Deux flux, deux chemins
|
|
|
|
Le système transporte deux choses de nature très différente, et elles ne
|
|
suivent volontairement pas le même chemin :
|
|
|
|
```
|
|
┌─────────────────────┐
|
|
│ iPhone (capteur) │
|
|
└──────────┬──────────┘
|
|
événements │ vidéo
|
|
(quelques octets) │ (800 kbps continu)
|
|
┌──────────┴──────────┐
|
|
▼ ▼
|
|
┌───────────────┐ ┌────────────────┐
|
|
│ Spring Boot │ │ MediaMTX │
|
|
│ Kafka · JPA │ │ SRT → WebRTC │
|
|
│ domaine │ │ transport pur │
|
|
└───────┬───────┘ └────────┬───────┘
|
|
└──────────┬───────────┘
|
|
▼
|
|
Console web (une seule page)
|
|
```
|
|
|
|
**La vidéo ne traverse jamais la JVM.** Faire transiter 800 kbps continus par
|
|
Spring Boot n'apporterait rien et coûterait un thread par spectateur. Le média
|
|
est un problème de transport, résolu par un service dédié de 83 Mo ; la JVM
|
|
garde le domaine métier. Les deux se rejoignent seulement dans le navigateur.
|
|
|
|
Corollaire pratique : MediaMTX peut tomber sans que l'alarme cesse de
|
|
fonctionner, et inversement.
|
|
|
|
### Le flux d'événements en détail
|
|
|
|
```
|
|
┌──────────────────┐
|
|
│ iPhone 6s │ Détection de mouvement
|
|
│ "iphone-biblio" │ {"deviceId":"iphone-biblio","type":"MOTION_DETECTED"}
|
|
└────────┬─────────┘
|
|
│ POST /api/v1/sensor-events
|
|
▼
|
|
┌────────────────────────────┐
|
|
│ SensorIngestController │ ADAPTER ENTRANT
|
|
│ → 202 Accepted │ Répond avant tout traitement métier :
|
|
└────────┬───────────────────┘ le capteur n'attend pas la base.
|
|
│
|
|
▼
|
|
┌────────────────────────────┐
|
|
│ SensorEventKafkaPublisher │ ADAPTER SORTANT
|
|
└────────┬───────────────────┘ Clé de partition = deviceId → ordre garanti
|
|
│ par capteur.
|
|
▼
|
|
╔═══════════════════════╗
|
|
║ KAFKA sensor-events ║ 3 partitions · acks=all · idempotent
|
|
╚═══════════┬═══════════╝ (poison pill → sensor-events-dlq)
|
|
│
|
|
▼
|
|
┌────────────────────────────┐
|
|
│ SensorEventKafkaConsumer │ ADAPTER ENTRANT
|
|
└────────┬───────────────────┘ Désérialise, délègue, instrumente. Zéro règle.
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────┐
|
|
│ AlarmSystem.evaluate() ◄── LE CŒUR DU SYSTÈME│
|
|
│ │
|
|
│ 1. alarme désarmée ? → Ignored(ALARM_DISARMED)
|
|
│ 2. heartbeat ? → Ignored(NOT_INTRUSION_RELEVANT)
|
|
│ 3. délai de sortie en cours ? → Ignored(WITHIN_EXIT_DELAY)
|
|
│ 4. confiance < seuil ? → Ignored(CONFIDENCE_TOO_LOW)
|
|
│ 5. alerte récente ? → Ignored(WITHIN_COOLDOWN)
|
|
│ │
|
|
│ sinon → TRIGGERED + Alert + IntrusionDetected │
|
|
└────────┬───────────────────────────────────────────────┘
|
|
│
|
|
┌─────┴─────────────────────────┐
|
|
▼ ▼
|
|
┌──────────────────┐ ┌──────────────────────────┐
|
|
│ PostgreSQL │ │ WebSocket STOMP │ après commit
|
|
│ (write model) │ │ /topic/alerts │ uniquement
|
|
└──────────────────┘ └────────────┬─────────────┘
|
|
▼
|
|
┌──────────────────────────┐
|
|
│ Console : fond rouge, │
|
|
│ "INTRUSION DÉTECTÉE" │
|
|
└──────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## L'architecture
|
|
|
|
### Hexagonale (ports & adapters)
|
|
|
|
```
|
|
domain/ Le métier. Zéro import de framework.
|
|
model/ AlarmSystem (agrégat racine), Alert, SensorEvent,
|
|
AlarmPolicy, AlarmDecision (sealed)
|
|
event/ DomainEvent (sealed) : IntrusionDetected, AlarmArmed…
|
|
port/in/ Ports primaires — ce que le système sait faire
|
|
port/out/ Ports secondaires — ce dont il a besoin (SPI)
|
|
|
|
application/ Orchestration. Charge, appelle, persiste, notifie.
|
|
command/ Côté écriture du CQRS
|
|
query/ Côté lecture — son propre port, son propre modèle
|
|
|
|
infrastructure/ Les adapters. C'est ici, et seulement ici, qu'on trouve
|
|
les mots « Kafka », « JPA », « WebSocket ».
|
|
|
|
presentation/ REST + WebSocket. Traduit HTTP en commandes.
|
|
```
|
|
|
|
Les dépendances pointent **vers l'intérieur**. Le domaine ne connaît ni Spring,
|
|
ni Kafka, ni PostgreSQL — il déclare des interfaces (`SensorEventPublisher`,
|
|
`AlertRepository`) que l'infrastructure implémente.
|
|
|
|
Conséquence mesurable : les 13 tests de `AlarmSystemTest` couvrent l'intégralité
|
|
de la logique de détection et s'exécutent en **48 millisecondes**, sans
|
|
conteneur, sans base, sans `Thread.sleep`.
|
|
|
|
### Ces règles sont testées, pas documentées
|
|
|
|
Un schéma dans un README dérive en trois sprints. Celles-ci cassent le build :
|
|
|
|
```java
|
|
@Test
|
|
void domainHasNoFrameworkDependency() {
|
|
noClasses().that().resideInAPackage("..domain..")
|
|
.should().dependOnClassesThat().resideInAnyPackage(
|
|
"org.springframework..", "jakarta.persistence..",
|
|
"org.apache.kafka..", "io.micrometer..")
|
|
.check(classes);
|
|
}
|
|
```
|
|
|
|
7 règles ArchUnit s'exécutent à chaque `mvn test` — **et à chaque `docker build`**.
|
|
Une violation de l'architecture hexagonale ne franchit pas l'étape de packaging.
|
|
|
|
### CQRS
|
|
|
|
La séparation n'est pas cosmétique : les deux côtés ne partagent aucun modèle.
|
|
|
|
| | Écriture | Lecture |
|
|
|---|---|---|
|
|
| Entrée | `ArmAlarmCommand`, `IngestSensorEventCommand` | `AlarmQueryService` |
|
|
| Chemin | passe par l'agrégat, applique les invariants | requête directe, ne charge jamais l'agrégat |
|
|
| Modèle | `Alert` (entité, comportement) | `AlertView` (record, libellés pré-calculés) |
|
|
| Port | `domain.port.out` | `application.query.port` |
|
|
| Transaction | `@Transactional` | `@Transactional(readOnly = true)` |
|
|
|
|
Compter les alertes non acquittées se fait par un `COUNT(*)`, pas en
|
|
réhydratant un agrégat dont on n'appellerait aucune méthode. Brancher un cache
|
|
Redis ou un index Elasticsearch sur la lecture ne demanderait qu'une nouvelle
|
|
implémentation de `AlarmReadModelPort`.
|
|
|
|
### DDD
|
|
|
|
- **Agrégat racine** — `AlarmSystem` est le seul point d'entrée pour modifier
|
|
l'état. Aucun service ne décide à sa place.
|
|
- **Value Objects** — `DeviceId` valide son format à la construction ;
|
|
au-delà de la frontière, aucune `String` non validée ne circule.
|
|
- **Entité** — `Alert` a une identité (`AlertId`, générée par le domaine et non
|
|
par la base) et un cycle de vie (acquittement à usage unique).
|
|
- **Interfaces scellées** — `AlarmDecision` est `Triggered | Ignored(reason)`.
|
|
Le `switch` qui la consomme est vérifié exhaustivement par le compilateur :
|
|
ajouter un troisième cas casse la compilation au lieu de tomber dans un `else`.
|
|
- **Un rejet dit pourquoi** — `IgnoreReason` alimente à la fois les logs et un
|
|
tag Micrometer. « Pourquoi l'alarme n'a pas sonné cette nuit ? » se répond par
|
|
une requête PromQL, pas par une lecture de logs.
|
|
|
|
---
|
|
|
|
## Le flux vidéo
|
|
|
|
### Le téléphone pousse, on ne l'interroge pas
|
|
|
|
C'est la contrainte qui a dicté toute cette partie. Un iPhone n'a **aucune
|
|
adresse joignable depuis Internet** : en 4G il est derrière le CGNAT de
|
|
l'opérateur, sur le WiFi du studio derrière le NAT de la box, et l'application
|
|
signale `UPnP unavailable`. Le serveur RTSP intégré à l'application caméra est
|
|
donc inutilisable de l'extérieur, quelle que soit la configuration.
|
|
|
|
La seule direction qui traverse ces deux NAT est la sortante. Le téléphone
|
|
**pousse** son flux vers le serveur :
|
|
|
|
```
|
|
iPhone ──SRT (UDP 8890)──▶ MediaMTX ──┬──▶ WebRTC < 1 s
|
|
└─RTMP (TCP 1935)──▶ └──▶ HLS ~ 6 s
|
|
```
|
|
|
|
**SRT plutôt que RTMP** quand le choix est possible : RTMP est du TCP, et sur
|
|
un lien mobile un paquet perdu déclenche une retransmission qui bloque tout le
|
|
reste. SRT transporte en UDP avec sa propre retransmission sélective — c'est
|
|
exactement le protocole conçu pour ce cas.
|
|
|
|
**WebRTC plutôt que HLS** côté lecture : voir quelqu'un six secondes après son
|
|
passage n'a pas le même intérêt. La console tente WebRTC (WHEP) d'abord et
|
|
bascule sur HLS si l'UDP est filtré — ce qui arrive précisément sur les réseaux
|
|
d'entreprise.
|
|
|
|
### Ce qui est protégé, ce qui ne l'est pas
|
|
|
|
| | |
|
|
|---|---|
|
|
| **Lecture** | publique — le flux est une vitrine, c'est assumé |
|
|
| **Publication** | authentifiée — sans ça, n'importe qui sur Internet pousserait sa propre vidéo sur le flux `biblio` |
|
|
|
|
### Brancher un vrai téléphone
|
|
|
|
Application : **IP Camera Lite** (iOS), menu *Live Push*.
|
|
|
|
```
|
|
SRT srt://<serveur>:8890?streamid=publish:biblio:iphone:<mot-de-passe>
|
|
RTMP rtmp://<serveur>:1935/biblio?user=iphone&pass=<mot-de-passe>
|
|
```
|
|
|
|
Le mot de passe est généré au déploiement dans le `.env` du serveur
|
|
(`STREAM_PUBLISH_PASSWORD`) et n'existe nulle part dans ce dépôt.
|
|
|
|
Pour valider la chaîne sans téléphone, une mire suffit :
|
|
|
|
```bash
|
|
docker run --rm --network iot-alarm-internal jrottenberg/ffmpeg:6.1-alpine \
|
|
-re -f lavfi -i testsrc=size=640x360:rate=25 \
|
|
-c:v libx264 -preset ultrafast -tune zerolatency -b:v 800k -pix_fmt yuv420p \
|
|
-f flv "rtmp://mediamtx:1935/biblio?user=iphone&pass=$PASS"
|
|
```
|
|
|
|
### Le piège du reverse proxy
|
|
|
|
MediaMTX répond d'abord une redirection vers un chemin **absolu**
|
|
(`/biblio/index.m3u8?cookieCheck=1`). Derrière un `handle_path /hls/*` qui
|
|
retire le préfixe, le navigateur suivait cette redirection jusqu'à
|
|
l'application Spring Boot et recevait du JSON là où il attendait une playlist.
|
|
Le `header_down Location` du [snippet Caddy](deploy/Caddyfile.snippet) réécrit
|
|
le préfixe. Symptôme trompeur : `curl -I` renvoyait un 404 sans rapport, le
|
|
serveur HLS ne gérant simplement pas la méthode HEAD.
|
|
|
|
---
|
|
|
|
## Les décisions non évidentes
|
|
|
|
Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce
|
|
que c'est précisément ce qui distingue un projet de démonstration d'un système
|
|
qu'on accepte de laisser tourner.
|
|
|
|
**`202 Accepted` et non `201 Created`** — à cet instant, rien n'a été décidé.
|
|
L'événement est sur le bus ; le traitement suivra. Le code de statut dit la
|
|
vérité sur ce qui s'est passé.
|
|
|
|
**Notification WebSocket après commit** — pousser l'alerte dans la transaction
|
|
signifierait afficher « INTRUSION » sur une écriture qui peut encore partir en
|
|
rollback. `AfterCommit` diffère l'effet de bord jusqu'au commit effectif.
|
|
|
|
**Entité JPA ≠ entité du domaine** — annoter directement `Alert` aurait imposé
|
|
un constructeur sans argument, des setters et des champs mutables : exactement
|
|
ce que l'encapsulation cherche à empêcher. Le prix est un mapper ; le bénéfice
|
|
est un domaine testable sans base.
|
|
|
|
**`SensorEventMessage` ≠ `SensorEvent`** — le format Kafka est un contrat public
|
|
partagé avec un producteur qu'on ne maîtrise pas (l'app iPhone). Sérialiser
|
|
l'objet du domaine ferait qu'un renommage de champ casse le capteur en
|
|
production.
|
|
|
|
**Anti-rafale dans l'agrégat** — sans ce filtre, un intrus qui traverse la pièce
|
|
génère quarante alertes en dix secondes. C'est une règle métier, elle est donc
|
|
dans le domaine, pas dans le consumer.
|
|
|
|
**Délai de sortie** — la différence entre un système utilisable et un système
|
|
qu'on finit par laisser désarmé en permanence.
|
|
|
|
**`ErrorHandlingDeserializer` + DLQ** — sans lui, *un seul* message malformé met
|
|
le consumer dans une boucle de replay infinie et bloque tous les événements
|
|
suivants, y compris une vraie intrusion.
|
|
|
|
**`Clock` injectée** — `Instant.now()` en dur rendrait le délai de sortie et
|
|
l'anti-rafale intestables autrement qu'avec des `Thread.sleep`.
|
|
|
|
**KRaft, pas Zookeeper** — Zookeeper est déprécié depuis Kafka 3.5 et supprimé
|
|
en 4.0. Le conserver ajouterait un conteneur, ~400 Mo de RAM et une pièce à
|
|
maintenir, pour une fonction que le broker assure désormais lui-même.
|
|
|
|
**`ddl-auto: validate`** — le schéma appartient à Flyway. L'application refuse
|
|
de démarrer si une entité et sa table ont divergé, plutôt que de tomber en
|
|
marche sur la première requête.
|
|
|
|
---
|
|
|
|
## Démarrage
|
|
|
|
### Tout en conteneurs
|
|
|
|
```bash
|
|
grep -v '^DB_PASSWORD=' .env.example > .env
|
|
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" >> .env && chmod 600 .env
|
|
|
|
docker compose up -d --build
|
|
docker compose logs -f app
|
|
```
|
|
|
|
Console sur http://localhost:8080 — le simulateur de capteur produit du trafic
|
|
dès le démarrage.
|
|
|
|
Pour un frontal Nginx en plus (http://localhost:8081) :
|
|
`docker compose --profile standalone up -d`
|
|
|
|
### En développement
|
|
|
|
```bash
|
|
docker compose up -d kafka # le broker seul
|
|
mvn spring-boot:run -Dspring-boot.run.profiles=local # H2 en mémoire
|
|
```
|
|
|
|
### Tests
|
|
|
|
```bash
|
|
mvn test # unitaires + ArchUnit — aucune infra requise
|
|
mvn verify -Pintegration # Testcontainers (Kafka + PostgreSQL réels)
|
|
```
|
|
|
|
---
|
|
|
|
## API
|
|
|
|
| Méthode | Route | Rôle |
|
|
|---|---|---|
|
|
| `POST` | `/api/v1/sensor-events` | Ingestion capteur → Kafka (202) |
|
|
| `POST` | `/api/v1/alarm/arm` | **Commande** — armer |
|
|
| `POST` | `/api/v1/alarm/disarm` | **Commande** — désarmer |
|
|
| `POST` | `/api/v1/alerts/{id}/acknowledge` | **Commande** — acquitter |
|
|
| `GET` | `/api/v1/alarm/status` | **Requête** — état courant |
|
|
| `GET` | `/api/v1/alerts?limit=50` | **Requête** — historique |
|
|
| `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) |
|
|
| `POST` | `/webrtc/biblio/whep` | Négociation WebRTC (servi par MediaMTX) |
|
|
| `GET` | `/hls/biblio/index.m3u8` | Repli HLS (servi par MediaMTX) |
|
|
|
|
Documentation OpenAPI : `/swagger-ui.html`
|
|
|
|
Déclencher une intrusion à la main :
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8080/api/v1/alarm/arm
|
|
sleep 31 # laisser passer le délai de sortie
|
|
|
|
curl -X POST http://localhost:8080/api/v1/sensor-events \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"deviceId":"iphone-biblio","type":"MOTION_DETECTED","confidence":95}'
|
|
```
|
|
|
|
Les erreurs suivent la RFC 7807 (`ProblemDetail`). Une règle métier violée
|
|
renvoie **409** et non 400 : la requête est bien formée, c'est l'état qui s'y
|
|
oppose.
|
|
|
|
---
|
|
|
|
## Production
|
|
|
|
Déployé sur un serveur Hetzner (Ubuntu, 2 vCPU, 4 Go) mutualisé avec d'autres
|
|
services.
|
|
|
|
- **TLS** — Caddy, déjà en place sur la machine et détenteur des ports 80/443,
|
|
obtient et renouvelle les certificats Let's Encrypt automatiquement.
|
|
Y démarrer un second frontal les aurait mis en conflit et coupé les services
|
|
existants. Le bloc à ajouter est dans [`deploy/Caddyfile.snippet`](deploy/Caddyfile.snippet).
|
|
- **Isolation réseau** — Kafka et PostgreSQL vivent sur un réseau Docker privé
|
|
sans aucun port publié sur l'hôte. Seule l'application rejoint le réseau
|
|
partagé du reverse proxy.
|
|
- **Empreinte mémoire** — limites de conteneur explicites (app 512 Mo, Kafka
|
|
800 Mo, PostgreSQL 256 Mo). La JVM utilise `MaxRAMPercentage` plutôt que
|
|
`-Xmx` : le réglage suit la limite du cgroup sans être défini à deux endroits.
|
|
`SerialGC` sur 2 vCPU partagés, où G1 coûte plus en threads qu'il ne rapporte.
|
|
- **Supervision** — `/actuator/prometheus` exposé au Prometheus de la machine,
|
|
bloqué côté public.
|
|
- **Conteneur non-root**, healthchecks sur les trois services, arrêt gracieux.
|
|
|
|
---
|
|
|
|
## Stack
|
|
|
|
| | |
|
|
|---|---|
|
|
| Langage | Java 21 (records, sealed interfaces, pattern matching, virtual threads) |
|
|
| Framework | Spring Boot 3.4 |
|
|
| Messagerie | Apache Kafka 3.9 (KRaft) |
|
|
| Persistance | PostgreSQL 16 · Spring Data JPA · Flyway |
|
|
| Temps réel | WebSocket / STOMP / SockJS |
|
|
| Vidéo | MediaMTX · SRT / RTMP en entrée · WebRTC (WHEP) / HLS en sortie |
|
|
| Build | Maven · Docker multi-stage |
|
|
| Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers |
|
|
| Observabilité | Actuator · Micrometer / Prometheus |
|
|
|
|
---
|
|
|
|
*Adam Belkacemi — le studio est toujours debout.*
|