Flux video temps reel : l'iPhone pousse, MediaMTX restitue

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>
This commit is contained in:
Adam Belkacemi 2026-09-12 18:08:34 +02:00
parent 94e893af97
commit d70892dce0
12 changed files with 827 additions and 13 deletions

View file

@ -7,22 +7,19 @@
# les services existants. Caddy obtient et renouvelle les certificats
# Let's Encrypt tout seul - il n'y a pas de Certbot a installer.
#
# Les deux orthographes sont servies : "seurveillance" est celle du sous-domaine
# demande, "surveillance" couvre la faute de frappe dans les deux sens.
# Caddy demande un certificat pour chaque nom qui resout vers ce serveur.
# Les deux orthographes sont servies : "seurveillance" est le sous-domaine
# effectivement cree, "surveillance" couvre la faute de frappe dans les deux
# sens. Caddy demande un certificat pour chaque nom qui resout vers ce serveur.
#
# Trois destinations derriere un seul domaine :
# /webrtc/* -> MediaMTX, signalisation WebRTC (flux video temps reel)
# /hls/* -> MediaMTX, repli HLS
# le reste -> l'application Spring Boot (console, API, websocket STOMP)
#
# Le proxy WebSocket n'a rien a declarer : Caddy propage l'upgrade nativement.
# ---------------------------------------------------------------------------
video-seurveillance.belkacemi.me, video-surveillance.belkacemi.me {
reverse_proxy iot-alarm-app:8080
# L'actuator n'a aucune raison d'etre public : health et metriques
# renseignent un attaquant sur les versions et l'etat interne.
@actuator path /actuator/*
respond @actuator 404
encode gzip zstd
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
@ -32,6 +29,43 @@ video-seurveillance.belkacemi.me, video-surveillance.belkacemi.me {
-Server
}
# L'actuator n'a aucune raison d'etre public : health et metriques
# renseignent un attaquant sur les versions et l'etat interne.
@actuator path /actuator/*
respond @actuator 404
# --- Flux video : WebRTC (latence < 1 s) --------------------------------
# flush_interval -1 desactive la bufferisation de la reponse. Sans ca, la
# negociation WHEP et les segments basse latence seraient retenus par le
# proxy le temps de remplir un tampon - ce qui annule tout l'interet.
handle_path /webrtc/* {
reverse_proxy iot-alarm-mediamtx:8889 {
flush_interval -1
}
}
# --- Flux video : HLS (repli, ~6 s) -------------------------------------
# Pas de compression ici : les segments video sont deja compresses, gziper
# par-dessus ne gagne rien et consomme du CPU sur un serveur a 2 vCPU.
handle_path /hls/* {
reverse_proxy iot-alarm-mediamtx:8888 {
flush_interval -1
# MediaMTX repond d'abord une redirection vers un chemin ABSOLU
# (/biblio/index.m3u8?cookieCheck=1), qui ignore le prefixe /hls
# retire par handle_path. Sans cette reecriture, le navigateur
# suit la redirection vers l'application Spring Boot au lieu du
# serveur media - et recoit du JSON la ou il attend une playlist.
header_down Location "^/(.*)$" "/hls/${1}"
}
}
# --- Application Spring Boot (console, API, WebSocket STOMP) ------------
handle {
encode gzip zstd
reverse_proxy iot-alarm-app:8080
}
log {
output file /var/log/caddy/iot-alarm.log
format json

View file

@ -24,11 +24,25 @@ rsync -az --delete \
--exclude '.DS_Store' \
"${LOCAL_DIR}/" "${HOST}:${REMOTE_DIR}/"
echo "==> Rendu de la configuration MediaMTX"
# Le mot de passe de publication vit dans le .env du serveur et n'existe nulle
# part dans le depot. Le fichier rendu est gitignore.
ssh "${HOST}" "cd ${REMOTE_DIR} && \
PASS=\$(grep '^STREAM_PUBLISH_PASSWORD=' .env | cut -d= -f2-) && \
PUBLIC_IP=\$(curl -s --max-time 10 https://api.ipify.org) && \
sed -e \"s|__PUBLISH_PASSWORD__|\${PASS}|\" \
-e \"s|__PUBLIC_IP__|\${PUBLIC_IP}|\" \
deploy/mediamtx/mediamtx.yml.template > deploy/mediamtx/mediamtx.yml && \
chmod 600 deploy/mediamtx/mediamtx.yml"
echo "==> Build de l'image (tests unitaires + ArchUnit inclus)"
ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose build app"
echo "==> Redemarrage"
ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose up -d"
# `up -d` ne recree pas un conteneur dont seule la configuration montee a
# change : MediaMTX garderait l'ancienne en memoire.
ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose restart mediamtx"
echo "==> Attente du healthcheck"
for i in $(seq 1 30); do

View file

@ -0,0 +1,106 @@
###############################################################################
# MediaMTX - serveur media du systeme d'alarme.
#
# Role : recevoir le flux pousse par l'iPhone (SRT ou RTMP) et le re-publier
# dans des formats lisibles par un navigateur (WebRTC, HLS).
#
# Pourquoi un service separe et pas du code Java : transporter de la video est
# un probleme resolu, temps-reel et gourmand en I/O. Le faire transiter par la
# JVM n'apporterait rien et couterait cher. L'application Spring Boot garde le
# domaine d'alarme ; le media passe a cote.
#
# Pourquoi le telephone POUSSE au lieu d'etre interroge : en 4G comme derriere
# la box du studio, l'iPhone n'a pas d'adresse joignable depuis Internet
# (CGNAT operateur, NAT domestique, UPnP indisponible). Seule une connexion
# sortante depuis le telephone fonctionne dans les deux cas.
#
# CE FICHIER EST GENERE depuis mediamtx.yml.template - ne pas l'editer a la main.
###############################################################################
logLevel: info
logDestinations: [stdout]
readTimeout: 10s
writeTimeout: 10s
# API interne, utilisee pour la sonde de sante. Non publiee sur l'hote.
api: yes
apiAddress: :9997
###############################################################################
# Authentification
#
# Lecture publique : choix assume, le flux est une vitrine.
# Publication authentifiee : sans ca, n'importe qui sur Internet pourrait
# pousser sa propre video sur "biblio" et remplacer l'image du studio.
###############################################################################
authMethod: internal
authInternalUsers:
- user: any
pass:
ips: []
permissions:
- action: read
- action: playback
- user: iphone
pass: __PUBLISH_PASSWORD__
ips: []
permissions:
- action: publish
path: biblio
###############################################################################
# Entrees : ce que l'iPhone peut utiliser pour pousser
###############################################################################
# SRT - a privilegier. UDP avec retransmission : encaisse la perte de paquets
# d'un lien mobile la ou RTMP (TCP) se met a bufferiser puis decroche.
srt: yes
srtAddress: :8890
# RTMP - repli. Plus universellement supporte par les applications mobiles.
rtmp: yes
rtmpAddress: :1935
rtmpEncryption: "no"
# MoQ (Media over QUIC) : experimental, aucun navigateur ne le consomme
# aujourd'hui et la console ne l'utilise pas. Desactive.
moq: no
# RTSP desactive : l'iPhone n'est pas joignable en entrant, ce protocole
# n'aurait aucun usage ici et ouvrirait une surface d'attaque de plus.
rtsp: no
###############################################################################
# Sorties : ce que le navigateur consomme
###############################################################################
# WebRTC - chemin principal, latence inferieure a la seconde.
webrtc: yes
webrtcAddress: :8889
webrtcAllowOrigins: ['*']
webrtcLocalUDPAddress: :8189
# Le conteneur ne voit qu'une IP privee ; sans cette declaration, les
# candidats ICE annonces seraient inutilisables depuis l'exterieur.
webrtcIPsFromInterfaces: no
webrtcAdditionalHosts: [__PUBLIC_IP__]
# HLS - repli universel, ~6 s de latence. Couvre les reseaux d'entreprise
# qui filtrent l'UDP et donc WebRTC.
hls: yes
hlsAddress: :8888
hlsAllowOrigins: ['*']
hlsVariant: lowLatency
hlsSegmentCount: 7
hlsSegmentDuration: 1s
hlsPartDuration: 200ms
hlsAlwaysRemux: no
###############################################################################
# Flux
###############################################################################
paths:
# Le capteur du studio. Nom volontairement aligne sur le deviceId utilise
# par le domaine d'alarme (iphone-biblio).
biblio: