#!/bin/bash
# ═══════════════════════════════════════════════════════════════════════════════
# check_adsb.sh — Vérification complète du stack ADS-B Linux-25
# Version 3.5 — JCA 2026 — sortie terminal + HTML
# v3.3 : correction UID logs (70→999, image postgis/postgis:16-3.5 Debian)
# ajout section 5c : extensions PostGIS, droits volumes, postgresql.auto.conf
# vérification partitions RANGE simples (sans HASH résiduel)
# v3.4 : ajout section 5d : partition semaine courante/suivante présente, continuité
# v3.5 : ajout section 9b : pare-feu (ufw/firewalld), tests d'accès réel local + LAN
# (aucun trou) entre bornes de partitions adsb.positions
# ajout section 6b : watchdog readsb — présence crontab, fraîcheur du state
# file (<10 min), RestartCount Docker (readsb/adsb2pg), détection flapping
# (redémarrages répétés en 24h), vérif dérive NTP (impacte ts/dedup)
# v3.5 : section 9 — ciblage précis du dongle RTL-SDR (0bda:2838) pour les
# resets/déconnexions USB, au lieu de tout le bus (faux positif corrigé
# le 05/07/2026 : un récepteur Logitech débranché était compté à tort)
# v3.6 : généralisation multi-récepteurs (2e dongle + antenne 7dBi, ajouté le
# 07/07/2026) — les sections dongle, données, ports, adsb2pg, autogain
# et RestartCount parcourent maintenant le tableau RECEIVERS ci-dessous
# au lieu de ne vérifier que le premier récepteur en dur
# v3.7 : watchdog_readsb.sh généralisé lui aussi (v3, son propre tableau
# RECEIVERS interne) — fraîcheur du state file et détection de flapping
# passées par récepteur (fichiers/comptages suffixés par serial),
# vérification de couverture par test du serial plutôt qu'un nom de
# conteneur en dur (qui ne matchait plus depuis la généralisation)
# ═══════════════════════════════════════════════════════════════════════════════
HTML_OUT="${1:-/tmp/status_adsb.html}" # chemin HTML (modifiable en arg)
# ── Configuration des récepteurs ───────────────────────────────────────────────
# v4.0 (31/07/2026) : RECEIVERS n'est plus une table statique. Elle est dérivée
# des dongles RÉELLEMENT énumérés sur le bus USB, mappés dans l'ordre sur le
# catalogue de slots ci-dessous. Conséquence : le script ne signale plus comme
# « absent » un slot légitimement non déployé, et ne peut plus confirmer la
# config à elle-même (l'ancien test grep-dans-les-logs validait la valeur
# configurée, pas le matériel — d'où le faux « SN:00000012 détecté »).
#
# Catalogue statique — slot:port_tar1090:ctn_readsb:ctn_tar1090:ctn_adsb2pg:profile
# Les numéros de série ne figurent PAS ici : ils sont affectés à la détection.
SLOTS=(
"1:8090:adsb-readsb:adsb-tar1090:adsb-adsb2pg:rx1"
"2:8091:adsb-readsb2:adsb-tar10902:adsb-adsb2pg2:rx2"
"3:8092:adsb-readsb3:adsb-tar10903:adsb-adsb2pg3:rx3"
)
MAX_RECEIVERS=${#SLOTS[@]}
DETECT_LIB="/data/adsb/scripts/detect_dongles.sh"
if [ -r "$DETECT_LIB" ]; then
# shellcheck source=/dev/null
source "$DETECT_LIB"
else
detect_serials() {
rtl_test -t 2>&1 \
| sed -n 's/^[[:space:]]*[0-9]\+:.*SN:[[:space:]]*\([0-9A-Za-z]\+\).*/\1/p'
}
fi
mapfile -t PRESENT_SERIALS < <(detect_serials)
# RECEIVERS conserve le format historique "serial:ctn_readsb:port:ctn_adsb2pg"
# pour rester compatible avec toutes les boucles existantes du script.
RECEIVERS=()
RX_ALL_CONTAINERS=() # tous les conteneurs liés à un récepteur actif
RX_PORTS=() # ports à vérifier en section 4
declare -A PORT_LABELS=() PORT_CONTAINERS=()
for _i in "${!PRESENT_SERIALS[@]}"; do
_n=$((_i + 1))
[ "$_n" -gt "$MAX_RECEIVERS" ] && break
IFS=':' read -r _slot _port _ctn_rs _ctn_t1090 _ctn_pg _profile <<< "${SLOTS[$_i]}"
_sn="${PRESENT_SERIALS[$_i]}"
RECEIVERS+=("${_sn}:${_ctn_rs}:${_port}:${_ctn_pg}")
RX_ALL_CONTAINERS+=("$_ctn_rs" "$_ctn_t1090" "$_ctn_pg")
# Ports réseau du slot : tar1090 HTTP, Beast, RAW, SBS
_beast=$((30005 + (_slot - 1) * 10))
_raw=$((30002 + (_slot - 1) * 10))
_sbs=$((30003 + (_slot - 1) * 10))
_sfx=""; [ "$_slot" -gt 1 ] && _sfx=" #${_slot}"
RX_PORTS+=("$_port" "$_beast" "$_raw" "$_sbs")
PORT_LABELS["$_port"]="tar1090 nginx${_sfx}"; PORT_CONTAINERS["$_port"]="$_ctn_t1090"
PORT_LABELS["$_beast"]="Beast TCP${_sfx}"; PORT_CONTAINERS["$_beast"]="$_ctn_rs"
PORT_LABELS["$_raw"]="RAW TCP${_sfx}"; PORT_CONTAINERS["$_raw"]="$_ctn_rs"
PORT_LABELS["$_sbs"]="SBS TCP${_sfx}"; PORT_CONTAINERS["$_sbs"]="$_ctn_rs"
done
# Services toujours présents, indépendants du nombre de dongles
PORT_LABELS["8080"]="webapp PHP"; PORT_CONTAINERS["8080"]="adsb-webapp"
PORT_LABELS["5432"]="PostgreSQL"; PORT_CONTAINERS["5432"]="wordpress-postgres"
BASE_CONTAINERS=(adsb-webapp wordpress-postgres)
BASE_PORTS=(8080 5432)
# ── Couleurs terminal ──────────────────────────────────────────────────────────
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BLUE='\033[1;34m'; BOLD='\033[1m'; NC='\033[0m'
ERRORS=0; WARNS=0
NOW=$(date '+%Y-%m-%dT%H:%M:%S')
NOW_DISP=$(date '+%a %d/%m/%Y %H:%M:%S')
IP=$(ip -4 addr show wlp58s0 2>/dev/null | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+' | head -1)
[ -z "$IP" ] && IP=$(hostname -I | awk '{print $1}')
# ── Buffer HTML ────────────────────────────────────────────────────────────────
HTML=""
HTML_ROWS="" # lignes de détail
# ── Fonctions terminal ─────────────────────────────────────────────────────────
ok() { echo -e " ${GREEN}✓${NC} $1"; }
fail() { echo -e " ${RED}✗${NC} $1"; ERRORS=$((ERRORS+1)); }
warn() { echo -e " ${YELLOW}⚠${NC} $1"; WARNS=$((WARNS+1)); }
info() { echo -e " ${CYAN}→${NC} $1"; }
hdr() { echo -e "\n${BLUE}${BOLD}═══ $1 ═══${NC}"; }
# ── Fonctions HTML ─────────────────────────────────────────────────────────────
h_section() { HTML_ROWS+="
| $1 |
"; }
h_ok() { HTML_ROWS+="| ✓ | $1 | OK |
"; }
h_fail() { HTML_ROWS+="| ✗ | $1 | ERREUR |
"; }
h_warn() { HTML_ROWS+="| ⚠ | $1 | ATTENTION |
"; }
h_info() { HTML_ROWS+="| → | $1 |
"; }
# ── Wrapper : terminal + HTML simultanément ───────────────────────────────────
OK() { ok "$1"; h_ok "$1"; }
FAIL() { fail "$1"; h_fail "$1"; }
WARN() { warn "$1"; h_warn "$1"; }
INFO() { info "$1"; h_info "$1"; }
HDR() { hdr "$1"; h_section "▸ $1"; }
# ══════════════════════════════════════════════════════════════════════════════
# VÉRIFICATIONS
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Stack ADS-B Linux-25 — Vérification complète ║${NC}"
echo -e "${BOLD}║ $NOW_DISP ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}"
INFO "IP : ${IP:-inconnue} — $NOW_DISP"
# ── 1. CONTENEURS ─────────────────────────────────────────────────────────────
HDR "1. Conteneurs Docker"
if [ ${#PRESENT_SERIALS[@]} -eq 0 ]; then
FAIL "Aucun dongle RTL-SDR détecté sur le bus USB — aucun récepteur ne peut être vérifié"
else
INFO "${#PRESENT_SERIALS[@]} dongle(s) détecté(s) : ${PRESENT_SERIALS[*]} — slots rx1..rx${#RECEIVERS[@]} attendus"
fi
for ctn in "${RX_ALL_CONTAINERS[@]}" "${BASE_CONTAINERS[@]}"; do
STATUS=$(docker inspect --format '{{.State.Status}}' "$ctn" 2>/dev/null)
HEALTH=$(docker inspect --format '{{.State.Health.Status}}' "$ctn" 2>/dev/null)
UPTIME=$(docker inspect --format '{{.State.StartedAt}}' "$ctn" 2>/dev/null | \
xargs -I{} date -d {} '+%d/%m %H:%M' 2>/dev/null)
LABEL="$ctn"
[ -n "$HEALTH" ] && [ "$HEALTH" != "" ] && LABEL="$ctn [$HEALTH]"
if [ "$STATUS" = "running" ]; then
[ "$HEALTH" = "unhealthy" ] && WARN "$LABEL — démarré $UPTIME" \
|| OK "$LABEL — démarré $UPTIME"
else
FAIL "$ctn — STATUS=${STATUS:-absent}"
fi
done
# ── 2. DONGLE(S) RTL-SDR ───────────────────────────────────────────────────────
HDR "2. Dongle(s) RTL-SDR"
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
# Le serial provient de l'énumération USB (detect_serials), pas des logs du
# conteneur : ceux-ci ne font qu'écho à la valeur configurée et validaient
# donc la config à elle-même.
GAIN=$(docker logs "$RX_CTN" 2>&1 | grep "tuner gain set to" | tail -1 | grep -oP '[\d.]+(?= dB)')
OK "$RX_CTN — SN:$RX_SERIAL présent sur le bus USB — gain ${GAIN:-?} dB"
# Le conteneur ouvre-t-il bien CE dongle ? (détecte le vol de device
# entre deux readsb, et le fallback silencieux sur l'index 0)
CFG_SERIAL=$(docker inspect --format \
'{{range .Config.Env}}{{println .}}{{end}}' "$RX_CTN" 2>/dev/null \
| sed -n 's/^READSB_RTLSDR_DEVICE=//p')
if [ -n "$CFG_SERIAL" ] && [ "$CFG_SERIAL" != "$RX_SERIAL" ]; then
FAIL "$RX_CTN — configuré sur SN:$CFG_SERIAL mais le dongle du slot est SN:$RX_SERIAL — risque de fallback sur le device index 0 et de vol de dongle"
fi
if docker logs "$RX_CTN" 2>&1 | grep -qiE "usb_claim_interface error|Failed to open rtlsdr|No supported devices found"; then
FAIL "$RX_CTN — échec d'ouverture du dongle dans les logs (device déjà claim, ou serial introuvable) — c'est la cause d'un compteur messages à 0"
fi
done
ls /dev/adsb_dongle 2>/dev/null && OK "Symlink /dev/adsb_dongle présent" \
|| WARN "Symlink /dev/adsb_dongle absent"
# Vérifier le node USB réel pointé par le symlink (utile pour le 1er récepteur
# uniquement — un seul symlink udev par convention, voir 99-adsb-docker.rules)
DONGLE_TARGET=$(readlink -f /dev/adsb_dongle 2>/dev/null)
if [ -n "$DONGLE_TARGET" ]; then
DONGLE_PERMS=$(ls -la "$DONGLE_TARGET" 2>/dev/null | awk '{print $1, $3, $4}')
INFO "Symlink → $DONGLE_TARGET ($DONGLE_PERMS)"
else
WARN "Impossible de résoudre /dev/adsb_dongle"
fi
# Vérifier que tous les numéros de série sont bien distincts (évite la
# récidive de l'incident du 06/07/2026 : deux dongles avec le même serial
# provoquant un crash-loop "Multiple RTL-SDR devices present")
DUP_SERIALS=$(printf '%s\n' "${PRESENT_SERIALS[@]}" | sort | uniq -d)
if [ -z "$DUP_SERIALS" ]; then
OK "Tous les numéros de série RTL-SDR détectés sont distincts (${#RECEIVERS[@]} récepteur(s))"
else
FAIL "Numéro(s) de série en doublon sur le bus USB : $DUP_SERIALS — l'affectation slot↔dongle est indéterminée, reprogrammer un dongle avec rtl_eeprom -s"
fi
# ── 3. DONNÉES ADS-B ──────────────────────────────────────────────────────────
HDR "3. Données ADS-B"
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
AC_JSON=$(curl -s --max-time 5 "http://127.0.0.1:${RX_PORT}/data/aircraft.json" 2>/dev/null)
if [ -n "$AC_JSON" ]; then
MSGS=$(echo "$AC_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('messages',0))" 2>/dev/null)
AVIONS=$(echo "$AC_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('aircraft',[])))" 2>/dev/null)
WITH_POS=$(echo "$AC_JSON" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(sum(1 for a in d.get('aircraft',[]) if a.get('lat') and a.get('lon')))" 2>/dev/null)
[ "${MSGS:-0}" -gt 0 ] 2>/dev/null \
&& OK "$RX_CTN (:${RX_PORT}) actif — $(printf '%s' "$MSGS" | sed ':a;s/\B[0-9]\{3\}\>/,&/;ta') messages décodés" \
|| WARN "$RX_CTN (:${RX_PORT}) répond — 0 message (démarrage récent ?)"
OK "$RX_CTN — $AVIONS avion(s) en vue dont $WITH_POS avec position GPS"
else
FAIL "$RX_CTN ne répond pas sur :${RX_PORT}/data/aircraft.json"
fi
# Détection dongle figé : compteur "messages" comparé au relevé précédent
# (indépendant du watchdog cron — utile pour un diagnostic immédiat manuel)
MSGS_STATE_FILE="/tmp/check_adsb_last_messages_${RX_SERIAL}"
if [ -n "$MSGS" ]; then
PREV_MSGS=$(cat "$MSGS_STATE_FILE" 2>/dev/null || echo -1)
PREV_TIME=$(stat -c %Y "$MSGS_STATE_FILE" 2>/dev/null || echo 0)
ELAPSED=$(( $(date +%s) - PREV_TIME ))
if [ "$PREV_MSGS" != "-1" ] && [ "$MSGS" = "$PREV_MSGS" ] && [ "$ELAPSED" -gt 120 ]; then
# aircraft.json est servi par tar1090, pas par readsb : un compteur
# figé peut venir du récepteur OU du lien tar1090→readsb. On
# interroge directement le port SBS pour trancher (incident 31/07 :
# readsb décodait, tar1090 servait 0 message, faute d'alias réseau).
RX_SBS=$((30003 + (RX_PORT - 8090) * 10))
SBS_BYTES=$(timeout 6 nc 127.0.0.1 "$RX_SBS" 2>/dev/null | head -c 400 | wc -c)
if [ "${SBS_BYTES:-0}" -ge 50 ]; then
FAIL "$RX_CTN — aircraft.json figé à $MSGS depuis ${ELAPSED}s MAIS le port SBS $RX_SBS émet ($SBS_BYTES octets) — readsb est sain, le maillon en cause est tar1090 (alias réseau BEASTHOST perdu ?)"
else
FAIL "$RX_CTN — compteur messages figé à $MSGS depuis ${ELAPSED}s et port SBS $RX_SBS silencieux — vérifier gain, antenne et connecteur avant de redémarrer $RX_CTN"
fi
elif [ "$PREV_MSGS" != "-1" ]; then
OK "$RX_CTN — compteur messages en progression ($PREV_MSGS → $MSGS depuis ${ELAPSED}s)"
else
INFO "$RX_CTN — premier relevé du compteur messages ($MSGS)"
fi
echo "$MSGS" > "$MSGS_STATE_FILE"
fi
done
VOL_PATH=$(docker inspect adsb_readsb-run 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['Mountpoint'])" 2>/dev/null)
if [ -n "$VOL_PATH" ] && [ -f "$VOL_PATH/aircraft.pb" ]; then
AGE=$(( $(date +%s) - $(stat -c %Y "$VOL_PATH/aircraft.pb") ))
[ "$AGE" -lt 10 ] && OK "Volume readsb-run : aircraft.pb frais (${AGE}s)" \
|| WARN "Volume readsb-run : aircraft.pb âgé de ${AGE}s"
else
WARN "Volume readsb-run : aircraft.pb introuvable (mode host normal)"
fi
# ── 4. PORTS RÉSEAU ───────────────────────────────────────────────────────────
HDR "4. Ports réseau"
# Fonction : résoudre quel conteneur écoute sur un port
port_owner() {
local port=$1
# Chercher via docker ps les ports exposés
docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | \
grep ":${port}->" | awk '{print $1}' | head -1
}
# Fonction : résoudre l'IP d'écoute d'un port
port_addr() {
local port=$1
ss -tlnp 2>/dev/null | grep ":${port}[[:space:]]" | \
awk '{print $4}' | head -1
}
# PORT_LABELS / PORT_CONTAINERS sont désormais construits en tête de script,
# à partir des slots réellement actifs (voir « Configuration des récepteurs »).
for port in "${BASE_PORTS[@]}" "${RX_PORTS[@]}"; do
ADDR=$(port_addr "$port")
CTN="${PORT_CONTAINERS[$port]}"
if [ -n "$ADDR" ]; then
OK "Port $port en écoute sur $ADDR — ${PORT_LABELS[$port]} ($CTN)"
else
FAIL "Port $port absent — ${PORT_LABELS[$port]} ($CTN)"
fi
done
# IPs des conteneurs sur leurs réseaux
echo ""
READSB_IP=$(docker inspect adsb-readsb 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
nets=d['NetworkSettings']['Networks']
for n,v in nets.items():
if v['IPAddress']: print(f\"{n}:{v['IPAddress']}\")" 2>/dev/null)
TAR1090_IP=$(docker inspect adsb-tar1090 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
nets=d['NetworkSettings']['Networks']
parts=[]
for n,v in nets.items():
ip=v.get('IPAddress','')
if ip: parts.append(f'{n}:{ip}')
print(' | '.join(parts) if parts else 'host')" 2>/dev/null)
ADSB2PG_NET=$(docker inspect adsb-adsb2pg 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
m=d['HostConfig'].get('NetworkMode','')
print(m)" 2>/dev/null)
WEBAPP_NET=$(docker inspect adsb-webapp 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
m=d['HostConfig'].get('NetworkMode','')
print(m)" 2>/dev/null)
[ -n "$READSB_IP" ] \
&& OK "adsb-readsb : $READSB_IP" \
|| FAIL "adsb-readsb : aucune IP réseau"
INFO "adsb-tar1090 : ${TAR1090_IP:-host}"
INFO "adsb-adsb2pg : mode=${ADSB2PG_NET:-?}"
INFO "adsb-webapp : mode=${WEBAPP_NET:-?}"
# Test Beast TCP tar1090 → readsb
FAIL_CNT=$(docker logs adsb-tar1090 2>&1 | grep "Beast TCP input.*failed" | \
tail -1 | grep -oP '\d+(?= times)' || echo 0)
[ "${FAIL_CNT:-0}" -gt 50 ] 2>/dev/null \
&& FAIL "Beast TCP tar1090→readsb : $FAIL_CNT échecs" \
|| OK "Connexion Beast tar1090→readsb OK"
# ── 4b. TESTS RÉSEAU SUPPLÉMENTAIRES ──────────────────────────────────────────
HDR "4b. Connectivité réseau inter-conteneurs"
# Test HTTP tar1090 depuis l'hôte
HTTP_TAR=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:8090/" 2>/dev/null)
[ "$HTTP_TAR" = "200" ] \
&& OK "HTTP tar1090 (127.0.0.1:8090) : HTTP $HTTP_TAR" \
|| FAIL "HTTP tar1090 (127.0.0.1:8090) : HTTP ${HTTP_TAR:-000}"
# Test aircraft.json depuis tar1090
HTTP_AC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:8090/data/aircraft.json" 2>/dev/null)
[ "$HTTP_AC" = "200" ] \
&& OK "aircraft.json tar1090 (127.0.0.1:8090) : HTTP $HTTP_AC" \
|| FAIL "aircraft.json tar1090 (127.0.0.1:8090) : HTTP ${HTTP_AC:-000}"
# Test API readsb (port 30053 si présent, sinon skip)
HTTP_API=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://127.0.0.1:30053/data/aircraft.json" 2>/dev/null)
if [ "$HTTP_API" = "200" ]; then
OK "API readsb (127.0.0.1:30053/data/aircraft.json) : HTTP $HTTP_API"
elif [ "$HTTP_API" = "000" ]; then
INFO "API readsb port 30053 : non exposé (normal en mode /data/adsb)"
else
WARN "API readsb (127.0.0.1:30053) : HTTP ${HTTP_API}"
fi
# Test Beast TCP depuis l'hôte vers readsb
nc -z -w3 127.0.0.1 30005 2>/dev/null \
&& OK "Beast TCP hôte→readsb (127.0.0.1:30005) : connexion OK" \
|| FAIL "Beast TCP hôte→readsb (127.0.0.1:30005) : timeout"
# Test RAW TCP depuis l'hôte
nc -z -w3 127.0.0.1 30002 2>/dev/null \
&& OK "RAW TCP hôte→readsb (127.0.0.1:30002) : connexion OK" \
|| FAIL "RAW TCP hôte→readsb (127.0.0.1:30002) : timeout"
# Test SBS TCP depuis l'hôte
nc -z -w3 127.0.0.1 30003 2>/dev/null \
&& OK "SBS TCP hôte→readsb (127.0.0.1:30003) : connexion OK" \
|| FAIL "SBS TCP hôte→readsb (127.0.0.1:30003) : timeout"
# Test PostgreSQL depuis l'hôte
nc -z -w3 127.0.0.1 5432 2>/dev/null \
&& OK "PostgreSQL hôte→wordpress-postgres (127.0.0.1:5432) : connexion OK" \
|| FAIL "PostgreSQL hôte→wordpress-postgres (127.0.0.1:5432) : timeout"
# Test adsb2pg → tar1090 (network_mode host, URL configurée)
DUMP_URL=$(docker inspect adsb-adsb2pg 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
envs={e.split('=',1)[0]:e.split('=',1)[1] for e in d['Config']['Env'] if '=' in e}
print(envs.get('DUMP1090_URL',''))" 2>/dev/null)
if [ -n "$DUMP_URL" ]; then
HTTP_DUMP=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$DUMP_URL" 2>/dev/null)
[ "$HTTP_DUMP" = "200" ] \
&& OK "adsb2pg DUMP1090_URL ($DUMP_URL) : HTTP $HTTP_DUMP" \
|| FAIL "adsb2pg DUMP1090_URL ($DUMP_URL) : HTTP ${HTTP_DUMP:-000}"
else
WARN "DUMP1090_URL non trouvée dans adsb-adsb2pg"
fi
# Test accès webapp depuis LAN (IP réelle)
HTTP_LAN=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://${IP}:8080/" 2>/dev/null)
[ "$HTTP_LAN" = "200" ] \
&& OK "Webapp LAN (http://${IP}:8080) : HTTP $HTTP_LAN" \
|| WARN "Webapp LAN (http://${IP}:8080) : HTTP ${HTTP_LAN:-000}"
# Test accès tar1090 depuis LAN
HTTP_TAR_LAN=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://${IP}:8090/" 2>/dev/null)
[ "$HTTP_TAR_LAN" = "200" ] \
&& OK "tar1090 LAN (http://${IP}:8090) : HTTP $HTTP_TAR_LAN" \
|| WARN "tar1090 LAN (http://${IP}:8090) : HTTP ${HTTP_TAR_LAN:-000}"
# Vérifier bridge-nf-call-iptables (doit être 0 pour éviter le blocage ICC)
BNF=$(cat /proc/sys/net/bridge/bridge-nf-call-iptables 2>/dev/null)
[ "${BNF:-1}" = "0" ] \
&& OK "bridge-nf-call-iptables=0 (trafic inter-conteneurs non filtré)" \
|| WARN "bridge-nf-call-iptables=${BNF} (risque de blocage ICC Docker)"
# Réseau adsb-net : vérifier les conteneurs attachés
ADSB_NET_CTNS=$(docker network inspect adsb-net 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
ctns=d.get('Containers',{})
parts=[f\"{v['Name']}={v['IPv4Address']}\" for v in ctns.values()]
print(' | '.join(parts))" 2>/dev/null)
[ -n "$ADSB_NET_CTNS" ] \
&& INFO "adsb-net membres : $ADSB_NET_CTNS" \
|| WARN "adsb-net : aucun conteneur attaché"
# ── 5. POSTGRESQL ─────────────────────────────────────────────────────────────
HDR "5. PostgreSQL — schéma adsb"
PG="docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c"
PG_VER=$($PG "SELECT version();" 2>/dev/null | head -1 | xargs)
[ -n "$PG_VER" ] && OK "PostgreSQL accessible — ${PG_VER:0:55}" || FAIL "PostgreSQL inaccessible"
PART_POS=$($PG "SELECT COUNT(*) FROM pg_class c
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='adsb' AND c.relname LIKE 'positions_2%' AND c.relkind='r';" 2>/dev/null | xargs)
# Depuis v4.1 : plus de sous-partitionnement HASH, chaque semaine est une table simple (relkind='r')
# Attendu : entre 2 et 6 selon la durée de collecte (une partition par semaine)
if [ "${PART_POS:-0}" -ge 1 ] && [ "${PART_POS:-0}" -le 10 ] 2>/dev/null; then
OK "$PART_POS partition(s) RANGE adsb.positions (schema v4.1 — sans HASH)"
elif [ "${PART_POS:-0}" -gt 10 ] 2>/dev/null; then
WARN "$PART_POS partitions adsb.positions — nombre élevé, vérifier qu'il n'y a pas de sous-partitions HASH résiduelles"
else
WARN "Aucune partition adsb.positions — adsb2pg ne peut pas insérer"
fi
# Vérifier qu'il ne reste pas de sous-partitions HASH résiduelles (relkind='p' = table partitionnée)
HASH_RESIDUAL=$($PG "SELECT COUNT(*) FROM pg_class c
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='adsb' AND c.relname LIKE 'positions_2%' AND c.relkind='p';" 2>/dev/null | xargs)
[ "${HASH_RESIDUAL:-0}" -gt 0 ] 2>/dev/null \
&& FAIL "$HASH_RESIDUAL partition(s) adsb.positions toujours de type 'p' (partitionnée) — sous-partitionnement HASH résiduel, migration incomplète" \
|| OK "Aucune sous-partition HASH résiduelle sur adsb.positions"
# ── 5d. CONTINUITÉ DU PARTITIONNEMENT (nouveau v3.4) ──────────────────────────
# But : détecter à l'avance une absence de partition semaine courante/suivante
# (cause racine possible d'un échec d'insertion silencieux côté adsb2pg) et tout
# trou entre les bornes de partitions successives (perte de données silencieuse
# si une ligne tombe dans un intervalle non couvert).
CURRENT_WEEK_TAG=$(date +%G_w%V)
NEXT_WEEK_TAG=$(date -d '+7 days' +%G_w%V)
# La recherche se fait sur les BORNES, pas sur le nom : create_week_partition
# a longtemps nommé les partitions avec EXTRACT(YEAR) au lieu d'ISOYEAR, donc
# le nom peut diverger de la semaine ISO réellement couverte (ex. constaté :
# positions_2028_w52 couvre en fait 2027-12-27 → 2028-01-03).
# relkind n'est plus filtré sur 'r' : une partition encore sous-partitionnée en
# HASH est de type 'p' et reste parfaitement insérable — l'ancien filtre
# produisait un « ABSENTE » alarmant sur une partition présente et fonctionnelle.
part_for_week() { # $1 = décalage en jours (0 = semaine courante)
$PG "SELECT c.relname
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'adsb.positions'::regclass
AND pg_get_expr(c.relpartbound, c.oid)
LIKE '%FROM (''' || (date_trunc('week', now()) + interval '$1 days')::date || '%'
LIMIT 1;" 2>/dev/null | xargs
}
part_kind() { # $1 = nom de partition
$PG "SELECT relkind FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'adsb' AND c.relname = '$1';" 2>/dev/null | xargs
}
CUR_NAME=$(part_for_week 0)
if [ -n "$CUR_NAME" ]; then
CUR_KIND=$(part_kind "$CUR_NAME")
if [ "$CUR_KIND" = "p" ]; then
CUR_SUB=$($PG "SELECT count(*) FROM pg_inherits
WHERE inhparent='adsb.${CUR_NAME}'::regclass;" 2>/dev/null | xargs)
if [ "${CUR_SUB:-0}" -gt 0 ]; then
OK "Partition semaine courante présente : $CUR_NAME (encore sous-partitionnée HASH, ${CUR_SUB} enfants — insérable)"
else
FAIL "Partition semaine courante $CUR_NAME est de type 'p' SANS sous-partition — toute insertion échouera (no partition of relation found)"
fi
else
OK "Partition semaine courante présente : $CUR_NAME (RANGE simple)"
fi
[ "$CUR_NAME" != "positions_${CURRENT_WEEK_TAG}" ] && \
WARN "Nom de partition incohérent avec la semaine ISO : $CUR_NAME au lieu de positions_${CURRENT_WEEK_TAG} — create_week_partition utilise EXTRACT(YEAR) au lieu d'EXTRACT(ISOYEAR)"
else
FAIL "Aucune partition ne couvre la semaine courante — adsb2pg ne peut plus insérer !"
fi
NEXT_NAME=$(part_for_week 7)
if [ -n "$NEXT_NAME" ]; then
OK "Partition semaine suivante déjà créée : $NEXT_NAME"
else
WARN "Aucune partition ne couvre la semaine suivante (positions_${NEXT_WEEK_TAG}) — à créer avant le rollover (SELECT adsb.create_week_partition(...))"
fi
GAP_COUNT=$($PG "
WITH bounds AS (
SELECT c.relname,
(regexp_match(pg_get_expr(c.relpartbound, c.oid), 'FROM \(''([^'']+)''\) TO \(''([^'']+)''\)'))[1]::timestamptz AS lo,
(regexp_match(pg_get_expr(c.relpartbound, c.oid), 'FROM \(''([^'']+)''\) TO \(''([^'']+)''\)'))[2]::timestamptz AS hi
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'adsb.positions'::regclass AND c.relkind='r'
), ordered AS (
SELECT lo, hi, LEAD(lo) OVER (ORDER BY lo) AS next_lo
FROM bounds
)
SELECT COUNT(*) FROM ordered WHERE next_lo IS NOT NULL AND next_lo <> hi;
" 2>/dev/null | xargs)
[ "${GAP_COUNT:-0}" -eq 0 ] 2>/dev/null \
&& OK "Continuité des partitions positions vérifiée (aucun trou entre semaines)" \
|| FAIL "${GAP_COUNT:-?} trou(s) détecté(s) entre bornes de partitions positions — risque de perte silencieuse de données"
PART_HIS=$($PG "SELECT COUNT(*) FROM pg_class c
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='adsb' AND c.relname LIKE 'aircraft_history_%' AND c.relkind='r';" 2>/dev/null | xargs)
OK "${PART_HIS:-0} sous-partitions adsb.aircraft_history"
# Vérifier que chaque récepteur configuré (RECEIVERS) est bien enregistré et
# actif dans adsb.receivers — sinon ses positions s'insèrent quand même mais
# avec un receiver_id incohérent ou l'auto-enregistrement générique d'adsb2pg
# (nom/coordonnées vides) plutôt que les vraies métadonnées.
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
RX_ROW=$($PG "SELECT active, name FROM adsb.receivers WHERE serial='${RX_SERIAL}';" 2>/dev/null)
if [ -z "$(echo "$RX_ROW" | xargs)" ]; then
FAIL "Récepteur $RX_SERIAL absent de adsb.receivers — positions orphelines côté FK"
else
RX_ACTIVE=$(echo "$RX_ROW" | awk -F'|' '{print $1}' | xargs)
RX_NAME=$(echo "$RX_ROW" | awk -F'|' '{print $2}' | xargs)
[ "$RX_ACTIVE" = "t" ] \
&& OK "Récepteur $RX_SERIAL enregistré et actif — \"$RX_NAME\"" \
|| WARN "Récepteur $RX_SERIAL enregistré mais INACTIF (active=false) — \"$RX_NAME\""
fi
done
POS_5MIN=$($PG "SELECT COUNT(*) FROM adsb.positions
WHERE ts >= NOW() - INTERVAL '5 minutes';" 2>/dev/null | xargs)
[ "${POS_5MIN:-0}" -gt 0 ] 2>/dev/null \
&& OK "$POS_5MIN positions insérées dans les 5 dernières minutes" \
|| WARN "Aucune position dans les 5 dernières minutes"
PCT_DESC=$($PG "SELECT ROUND(COUNT(aircraft_desc)*100.0/NULLIF(COUNT(*),0),1)
FROM adsb.positions WHERE ts >= NOW() - INTERVAL '30 minutes'
AND lat IS NOT NULL;" 2>/dev/null | xargs)
python3 -c "exit(0 if float('${PCT_DESC:-0}') >= 80 else 1)" 2>/dev/null \
&& OK "aircraft_desc rempli à ${PCT_DESC}% sur 30 min" \
|| WARN "aircraft_desc rempli à ${PCT_DESC:-0}% (< 80%)"
AV_TODAY=$($PG "SELECT COUNT(DISTINCT icao) FROM adsb.aircraft_history
WHERE session_start >= CURRENT_DATE;" 2>/dev/null | xargs)
SESS_TODAY=$($PG "SELECT COUNT(*) FROM adsb.aircraft_history
WHERE session_start >= CURRENT_DATE;" 2>/dev/null | xargs)
INFO "Aujourd'hui : ${AV_TODAY:-0} avions distincts, ${SESS_TODAY:-0} sessions"
DB_SIZE=$($PG "SELECT pg_size_pretty(SUM(pg_total_relation_size(c.oid)))
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='adsb';" 2>/dev/null | xargs)
INFO "Taille totale schéma adsb : ${DB_SIZE:-?}"
# Connexions actives PostgreSQL
PG_CONNS=$($PG "SELECT COUNT(*) FROM pg_stat_activity WHERE state='active';" 2>/dev/null | xargs)
INFO "Connexions PostgreSQL actives : ${PG_CONNS:-?}"
# ── 5b. VOLUMES POSTGRESQL ────────────────────────────────────────────────────
HDR "5b. Volumes PostgreSQL (conf & logs)"
PG_CONF_VOL="pg-conf-std"
PG_LOGS_VOL="pg-logs-std"
# ── Existence des volumes ──
for VOL in "$PG_CONF_VOL" "$PG_LOGS_VOL"; do
VOL_INFO=$(docker volume inspect "$VOL" 2>/dev/null)
if [ -n "$VOL_INFO" ]; then
VOL_MP=$(echo "$VOL_INFO" | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
print(d.get('Mountpoint','?'))" 2>/dev/null)
OK "Volume Docker '$VOL' existe — Mountpoint : $VOL_MP"
else
FAIL "Volume Docker '$VOL' absent — conf/logs PostgreSQL non accessibles depuis l'hôte"
fi
done
# ── Mountpoints ──
MP_CONF=$(docker volume inspect "$PG_CONF_VOL" --format '{{.Mountpoint}}' 2>/dev/null)
MP_LOGS=$(docker volume inspect "$PG_LOGS_VOL" --format '{{.Mountpoint}}' 2>/dev/null)
# ── Liens symboliques pratiques ──
LINK_CONF="/data/adsb/postgres/conf"
LINK_LOGS="/data/adsb/postgres/logs"
for LINK in "$LINK_CONF" "$LINK_LOGS"; do
if [ -L "$LINK" ]; then
LINK_TARGET=$(readlink -f "$LINK" 2>/dev/null)
OK "Lien symbolique $LINK → $LINK_TARGET"
elif [ -d "$LINK" ]; then
INFO "$LINK existe (répertoire, pas un lien symbolique)"
else
WARN "Lien symbolique $LINK absent — créer avec : ln -sfn \$MOUNTPOINT $LINK"
fi
done
# ── Fichier de configuration ──
if [ -n "$MP_CONF" ]; then
CONF_FILE="$MP_CONF/postgresql.conf"
if [ -f "$CONF_FILE" ]; then
CONF_PERMS=$(stat -c "%a %U:%G" "$CONF_FILE" 2>/dev/null)
CONF_SIZE=$(wc -c < "$CONF_FILE" 2>/dev/null)
CONF_AGE=$(( $(date +%s) - $(stat -c %Y "$CONF_FILE" 2>/dev/null || echo 0) ))
OK "postgresql.conf présent ($CONF_SIZE octets, modifié il y a ${CONF_AGE}s, droits $CONF_PERMS)"
# Vérifier lisibilité par l'utilisateur courant
if [ -r "$CONF_FILE" ]; then
OK "postgresql.conf lisible depuis l'hôte"
else
WARN "postgresql.conf non lisible depuis l'hôte (droits insuffisants)"
fi
# Vérifier paramètres clés dans le fichier
MAX_LOCKS=$(grep -E "^max_locks_per_transaction\s*=" "$CONF_FILE" 2>/dev/null | awk -F= '{print $2}' | tr -d ' ')
LOG_DIR=$(grep -E "^log_directory\s*=" "$CONF_FILE" 2>/dev/null | awk -F= '{print $2}' | tr -d " '")
LOG_COL=$(grep -E "^logging_collector\s*=" "$CONF_FILE" 2>/dev/null | awk -F= '{print $2}' | tr -d ' ')
[ -n "$MAX_LOCKS" ] && INFO "max_locks_per_transaction = $MAX_LOCKS (fichier conf)" \
|| INFO "max_locks_per_transaction non défini dans le fichier (via command ?)"
[ -n "$LOG_DIR" ] && INFO "log_directory = $LOG_DIR" \
|| INFO "log_directory non défini dans le fichier"
[ "$LOG_COL" = "on" ] && OK "logging_collector = on" \
|| WARN "logging_collector non défini ou off dans le fichier conf"
else
FAIL "postgresql.conf absent dans le volume $PG_CONF_VOL ($MP_CONF/)"
fi
# Droits du répertoire conf
CONF_DIR_OWNER=$(stat -c "%U:%G (%a)" "$MP_CONF" 2>/dev/null)
INFO "Répertoire conf : $MP_CONF — droits $CONF_DIR_OWNER"
else
WARN "Mountpoint du volume $PG_CONF_VOL non résolu"
fi
# ── Répertoire de logs ──
if [ -n "$MP_LOGS" ]; then
# Droits du répertoire logs
# UID attendu : 999 = postgres dans l'image postgis/postgis:16-3.5 (Debian)
# (était 70 sous postgres:16-alpine, remplacée en v4.1 le 30/06/2026)
LOGS_OWNER_UID=$(stat -c "%u" "$MP_LOGS" 2>/dev/null)
LOGS_OWNER_NAME=$(stat -c "%U:%G (%a)" "$MP_LOGS" 2>/dev/null)
if [ "${LOGS_OWNER_UID:-0}" = "999" ]; then
OK "Répertoire logs : propriétaire uid=999 (postgres Debian) — $LOGS_OWNER_NAME"
else
FAIL "Répertoire logs : propriétaire uid=${LOGS_OWNER_UID:-?} (attendu 999/postgres Debian) — PostgreSQL ne peut pas écrire les logs (corriger : docker exec -u root wordpress-postgres chown -R 999:999 /var/log/postgresql)"
fi
# Lisibilité depuis l'hôte (root peut lire même si mode 750)
if [ -r "$MP_LOGS" ]; then
OK "Répertoire logs lisible depuis l'hôte (root)"
else
WARN "Répertoire logs non lisible depuis l'hôte"
fi
# Fichiers de logs présents
LOG_TODAY="$MP_LOGS/postgresql-$(date +%Y-%m-%d).log"
LOG_COUNT=$(ls "$MP_LOGS"/postgresql-*.log 2>/dev/null | wc -l)
if [ "$LOG_COUNT" -gt 0 ]; then
OK "$LOG_COUNT fichier(s) de log PostgreSQL présent(s) dans le volume"
if [ -f "$LOG_TODAY" ]; then
LOG_SIZE=$(wc -c < "$LOG_TODAY" 2>/dev/null)
LOG_AGE=$(( $(date +%s) - $(stat -c %Y "$LOG_TODAY" 2>/dev/null || echo 0) ))
[ "$LOG_AGE" -lt 300 ] \
&& OK "Log du jour actif : postgresql-$(date +%Y-%m-%d).log (${LOG_SIZE} octets, mis à jour il y a ${LOG_AGE}s)" \
|| WARN "Log du jour présent mais inactif depuis ${LOG_AGE}s (PostgreSQL ne loggue plus ?)"
else
WARN "Pas de fichier log pour aujourd'hui ($(date +%Y-%m-%d)) — logging_collector actif ?"
fi
# Dernière ligne de log
LAST_LOG_LINE=$(tail -1 "$LOG_TODAY" 2>/dev/null | cut -c1-120)
[ -n "$LAST_LOG_LINE" ] && INFO "Dernière ligne log : $LAST_LOG_LINE"
else
WARN "Aucun fichier de log PostgreSQL dans le volume $PG_LOGS_VOL — logging_collector activé ?"
fi
# Fichiers CSV
CSV_COUNT=$(ls "$MP_LOGS"/postgresql-*.csv 2>/dev/null | wc -l)
[ "$CSV_COUNT" -gt 0 ] && INFO "$CSV_COUNT fichier(s) CSV de log présent(s)"
INFO "Accès direct aux logs : tail -f $MP_LOGS/postgresql-\$(date +%Y-%m-%d).log"
else
WARN "Mountpoint du volume $PG_LOGS_VOL non résolu"
fi
# ── Vérification croisée : PostgreSQL utilise bien ces volumes ──
PG_VOL_CHECK=$(docker inspect wordpress-postgres 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
mounts=d.get('Mounts',[])
names=[m.get('Name','') for m in mounts]
print(' | '.join(names))" 2>/dev/null)
INFO "Volumes montés dans wordpress-postgres : ${PG_VOL_CHECK:-aucun}"
# Vérifier que pg-conf-std et pg-logs-std sont bien montés
for VOL in "$PG_CONF_VOL" "$PG_LOGS_VOL"; do
if echo "$PG_VOL_CHECK" | grep -q "$VOL"; then
OK "Volume '$VOL' monté dans wordpress-postgres"
else
FAIL "Volume '$VOL' NON monté dans wordpress-postgres — redémarrer le conteneur"
fi
done
# Vérifier config_file via PostgreSQL (si accessible)
PG_CONFIG_FILE=$($PG "SHOW config_file;" 2>/dev/null | xargs)
PG_LOG_DIR=$($PG "SHOW log_directory;" 2>/dev/null | xargs)
PG_MAX_LOCKS=$($PG "SHOW max_locks_per_transaction;" 2>/dev/null | xargs)
PG_LOG_COL=$($PG "SHOW logging_collector;" 2>/dev/null | xargs)
[ -n "$PG_CONFIG_FILE" ] \
&& OK "PostgreSQL config_file actif : $PG_CONFIG_FILE" \
|| WARN "Impossible de lire config_file depuis PostgreSQL"
[ -n "$PG_LOG_DIR" ] && INFO "PostgreSQL log_directory : $PG_LOG_DIR"
[ -n "$PG_MAX_LOCKS" ] \
&& OK "max_locks_per_transaction (runtime) : $PG_MAX_LOCKS" \
|| WARN "max_locks_per_transaction non lisible"
[ "${PG_LOG_COL}" = "on" ] \
&& OK "logging_collector = on (runtime)" \
|| WARN "logging_collector = ${PG_LOG_COL:-?} (runtime) — logs fichier peut-être inactifs"
# ── 5c. POSTGRESQL — EXTENSIONS ET PARAMÈTRES CRITIQUES ──────────────────────
HDR "5c. PostgreSQL — extensions et paramètres critiques"
# ── Extensions installées ──
# postgis : requis depuis v4.1 (image postgis/postgis:16-3.5, colonne geom sur adsb.positions)
PG_EXT_POSTGIS=$($PG "SELECT installed_version FROM pg_available_extensions
WHERE name='postgis';" 2>/dev/null | xargs)
if [ -n "$PG_EXT_POSTGIS" ] && [ "$PG_EXT_POSTGIS" != "" ]; then
OK "Extension postgis installée — version $PG_EXT_POSTGIS"
else
FAIL "Extension postgis absente — requis depuis v4.1 (image postgis/postgis:16-3.5)"
fi
# Vérifier que la colonne geom est bien présente sur la table parente adsb.positions
GEOM_COL=$($PG "SELECT column_name FROM information_schema.columns
WHERE table_schema='adsb' AND table_name='positions' AND column_name='geom';" 2>/dev/null | xargs)
[ "$GEOM_COL" = "geom" ] \
&& OK "Colonne geom présente sur adsb.positions (GENERATED ALWAYS, héritage partitions)" \
|| FAIL "Colonne geom absente sur adsb.positions — PostGIS non appliqué (ALTER TABLE manquant ?)"
# Index GiST : vérifier sa présence sur la partition active (semaine courante)
# Index GiST : vérifier sa présence sur la partition qui couvre RÉELLEMENT
# maintenant. L'ancien « ORDER BY c.relname DESC LIMIT 1 » retournait la
# dernière partition par ordre alphabétique — soit la plus lointaine dans le
# futur (positions_2028_w52), jamais celle en cours d'écriture.
ACTIVE_PART=$($PG "SELECT c.relname
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'adsb.positions'::regclass
AND pg_get_expr(c.relpartbound, c.oid)
LIKE '%FROM (''' || date_trunc('week', now())::date || '%'
LIMIT 1;" 2>/dev/null | xargs)
if [ -n "$ACTIVE_PART" ]; then
GIST_IDX=$($PG "SELECT indexname FROM pg_indexes
WHERE schemaname='adsb' AND tablename='${ACTIVE_PART}'
AND indexname LIKE '%geom_gix%';" 2>/dev/null | xargs)
[ -n "$GIST_IDX" ] \
&& OK "Index GiST sur geom présent sur la partition active ($ACTIVE_PART) — requêtes bbox optimisées" \
|| WARN "Index GiST sur geom absent sur $ACTIVE_PART — créer : CREATE INDEX ${ACTIVE_PART}_geom_gix ON adsb.${ACTIVE_PART} USING GIST (geom);"
INFO "Partition active : $ACTIVE_PART"
else
WARN "Aucune partition active trouvée pour adsb.positions"
fi
# ── Vérification des droits sur les volumes (UID 999, image Debian postgis/postgis) ──
# Depuis le passage de postgres:16-alpine (UID 70) à postgis/postgis:16-3.5 (UID 999)
# le 30/06/2026, l'ownership doit être 999:999 sur conf, logs ET data.
PG_DATA_VOL="postgres_data"
PG_DATA_MP=$(docker volume inspect "$PG_DATA_VOL" --format '{{.Mountpoint}}' 2>/dev/null)
for VOL_CHECK in "pg-conf-std:$MP_CONF" "pg-logs-std:$MP_LOGS" "postgres_data:$PG_DATA_MP"; do
VOL_NAME="${VOL_CHECK%%:*}"
VOL_PATH="${VOL_CHECK##*:}"
if [ -n "$VOL_PATH" ] && [ -d "$VOL_PATH" ]; then
VOL_UID=$(stat -c "%u" "$VOL_PATH" 2>/dev/null)
VOL_OWNER=$(stat -c "%U:%G (%a)" "$VOL_PATH" 2>/dev/null)
if [ "${VOL_UID}" = "999" ] || [ "${VOL_UID}" = "0" ]; then
# uid=0 (root) est acceptable pour pg-conf-std (fichier conf lisible par tous)
OK "Volume $VOL_NAME : propriétaire $VOL_OWNER — OK pour image Debian (uid=${VOL_UID})"
else
FAIL "Volume $VOL_NAME : uid=${VOL_UID} (attendu 999 pour postgis/postgis:16-3.5) — lancer fix_permissions.sh si problème de démarrage"
fi
else
INFO "Volume $VOL_NAME : mountpoint non résolu ou absent"
fi
done
# ── postgresql.auto.conf — paramètres effectifs (prime sur postgresql.conf) ──
# Ce fichier est géré par ALTER SYSTEM et est chargé EN DERNIER par PostgreSQL.
# Toute modification via ALTER SYSTEM SET écrase postgresql.conf silencieusement.
PG_AUTO_CONF_PATH="$PG_DATA_MP/postgresql.auto.conf"
if [ -f "$PG_AUTO_CONF_PATH" ] && [ -r "$PG_AUTO_CONF_PATH" ]; then
OK "postgresql.auto.conf accessible ($PG_AUTO_CONF_PATH)"
INFO "Contenu (paramètres ALTER SYSTEM, prioritaires sur postgresql.conf) :"
while IFS= read -r line; do
# Ignorer commentaires et lignes vides
[[ "$line" =~ ^#.*$ ]] && continue
[[ -z "$line" ]] && continue
INFO " auto.conf → $line"
done < "$PG_AUTO_CONF_PATH"
else
INFO "postgresql.auto.conf non lisible depuis l'hôte (normal si volume postgres_data non accessible)"
fi
# ── Vérification des paramètres effectifs critiques (runtime via pg_settings) ──
PG_SHARED_BUF=$($PG "SELECT pg_size_pretty(current_setting('shared_buffers')::bigint * 8192);" 2>/dev/null | xargs)
PG_WORK_MEM=$($PG "SELECT pg_size_pretty(current_setting('work_mem')::bigint * 1024);" 2>/dev/null | xargs)
PG_RANDOM_PC=$($PG "SHOW random_page_cost;" 2>/dev/null | xargs)
PG_IO_CONC=$($PG "SHOW effective_io_concurrency;" 2>/dev/null | xargs)
PG_EFF_CACHE=$($PG "SELECT pg_size_pretty(current_setting('effective_cache_size')::bigint * 8192);" 2>/dev/null | xargs)
[ -n "$PG_SHARED_BUF" ] && INFO "shared_buffers (runtime) : $PG_SHARED_BUF (attendu ~3200 MB)"
[ -n "$PG_WORK_MEM" ] && INFO "work_mem (runtime) : $PG_WORK_MEM (attendu 32 MB — risque OOM si > 64 MB)"
[ -n "$PG_EFF_CACHE" ] && INFO "effective_cache_size (runtime) : $PG_EFF_CACHE"
# Alarme si work_mem anormalement haut (vestige d'un tuning d'urgence)
PG_WORK_MEM_KB=$($PG "SELECT current_setting('work_mem')::bigint;" 2>/dev/null | xargs)
if [ -n "$PG_WORK_MEM_KB" ] && [ "$PG_WORK_MEM_KB" -gt 65536 ] 2>/dev/null; then
WARN "work_mem = ${PG_WORK_MEM} — trop élevé pour ce hardware (i7-7567U/16Go), risque d'OOM (recommandé : 32MB)"
else
OK "work_mem dans les limites acceptables (${PG_WORK_MEM:-?})"
fi
[ -n "$PG_RANDOM_PC" ] && INFO "random_page_cost (runtime) : $PG_RANDOM_PC (attendu 1.1 pour SSD)"
[ -n "$PG_IO_CONC" ] && INFO "effective_io_concurrency (runtime) : $PG_IO_CONC (attendu 150 pour SSD)"
# ── 6. ADSB2PG ────────────────────────────────────────────────────────────────
HDR "6. adsb2pg — collecteur(s)"
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
LAST_INS=$(docker logs "$RX_PG" 2>&1 | grep "positions insérées" | tail -1)
if [ -n "$LAST_INS" ]; then
OK "$RX_PG actif (récepteur $RX_SERIAL)"
INFO "$LAST_INS"
LOG_TIME=$(echo "$LAST_INS" | grep -oP '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')
AGE_LOG=$(( $(date +%s) - $(date -d "${LOG_TIME:-0}" +%s 2>/dev/null || echo $(date +%s)) ))
[ "$AGE_LOG" -lt 30 ] \
&& OK "$RX_PG — dernière insertion il y a ${AGE_LOG}s" \
|| WARN "$RX_PG — dernière insertion il y a ${AGE_LOG}s (> 30s — cycle bloqué ?)"
else
WARN "$RX_PG — aucun log d'insertion trouvé"
fi
LAST_ERR=$(docker logs "$RX_PG" 2>&1 | grep -i "error\|erreur\|WARNING" | tail -1)
[ -n "$LAST_ERR" ] && INFO "$RX_PG — dernier warning/erreur : ${LAST_ERR:0:100}"
done
# ── 6b. WATCHDOG READSB — résilience (nouveau v3.4) ───────────────────────────
# Vérifie que le garde-fou mis en place suite à l'incident du dongle figé
# silencieusement (03/07/2026) est bien actif et fonctionne réellement.
HDR "6b. Watchdog readsb — résilience"
WATCHDOG_SCRIPT="/data/adsb/scripts/watchdog_readsb.sh"
WATCHDOG_LOG="/var/log/readsb_watchdog.log"
# Présence du script lui-même
if [ -x "$WATCHDOG_SCRIPT" ]; then
OK "Script watchdog présent et exécutable ($WATCHDOG_SCRIPT)"
else
FAIL "Script watchdog absent ou non exécutable ($WATCHDOG_SCRIPT)"
fi
# Présence dans la crontab (root, puisque docker restart nécessite les droits root)
CRON_ACTIVE=$(crontab -l 2>/dev/null | grep "watchdog_readsb.sh" | grep -v '^[[:space:]]*#')
CRON_COMMENTED=$(crontab -l 2>/dev/null | grep "watchdog_readsb.sh" | grep '^[[:space:]]*#')
if [ -n "$CRON_ACTIVE" ]; then
OK "Watchdog actif dans la crontab — $CRON_ACTIVE"
elif [ -n "$CRON_COMMENTED" ]; then
WARN "Watchdog présent mais COMMENTÉ dans la crontab — désarmé volontairement ? aucune protection automatique tant qu'il n'est pas réactivé : $CRON_COMMENTED"
else
FAIL "Watchdog ABSENT de la crontab — aucune protection automatique contre un dongle figé"
fi
# Fraîcheur du state file PAR RÉCEPTEUR (watchdog v3 : un fichier suffixé par
# numéro de série, /tmp/readsb_last_msg_count_). Preuve que le cron
# s'exécute réellement pour CE récepteur précis, pas juste que le script tourne
# globalement — un récepteur ajouté au tableau RECEIVERS après coup sans
# relancer le cron une première fois n'aurait pas encore ce fichier.
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
WATCHDOG_STATE="/tmp/readsb_last_msg_count_${RX_SERIAL}"
if [ -f "$WATCHDOG_STATE" ]; then
STATE_AGE=$(( $(date +%s) - $(stat -c %Y "$WATCHDOG_STATE" 2>/dev/null || echo 0) ))
if [ "$STATE_AGE" -lt 600 ]; then
OK "Watchdog actif pour $RX_CTN — dernière exécution il y a ${STATE_AGE}s (< 10 min)"
else
FAIL "Watchdog inactif pour $RX_CTN — dernière exécution il y a ${STATE_AGE}s (> 10 min, cron probablement en échec)"
fi
else
WARN "State file watchdog absent pour $RX_CTN ($WATCHDOG_STATE) — jamais exécuté pour ce récepteur"
fi
done
# Détection de flapping PAR RÉCEPTEUR : redémarrages répétés sur les dernières
# 24h (signe d'un problème matériel USB propre à CE dongle plutôt qu'un
# blocage isolé) — chaque récepteur est compté séparément pour ne pas noyer
# un flapping ciblé dans la moyenne globale.
if [ -f "$WATCHDOG_LOG" ]; then
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
RESTARTS_24H=$(awk -v d="$(date -d '24 hours ago' '+%Y-%m-%d %H:%M:%S')" \
'$0 >= d' "$WATCHDOG_LOG" 2>/dev/null | grep -c "redémarrage $RX_CTN")
if [ "${RESTARTS_24H:-0}" -eq 0 ]; then
OK "$RX_CTN — aucun redémarrage watchdog déclenché sur les dernières 24h"
elif [ "${RESTARTS_24H:-0}" -le 2 ]; then
WARN "$RX_CTN — $RESTARTS_24H redémarrage(s) watchdog sur les dernières 24h"
else
FAIL "$RX_CTN — $RESTARTS_24H redémarrages watchdog sur les dernières 24h — flapping probable, vérifier le dongle/câble/hub USB"
fi
done
LAST_WD_LINE=$(tail -1 "$WATCHDOG_LOG" 2>/dev/null)
[ -n "$LAST_WD_LINE" ] && INFO "Dernière ligne log watchdog : $LAST_WD_LINE"
else
WARN "Fichier log watchdog absent ($WATCHDOG_LOG)"
fi
# RestartCount Docker : détecte un crash-loop même hors périmètre du watchdog
# (ex: readsb qui crashe vraiment au lieu de juste se figer)
for ctn in "${RX_ALL_CONTAINERS[@]}"; do
RCOUNT=$(docker inspect --format '{{.RestartCount}}' "$ctn" 2>/dev/null)
if [ -n "$RCOUNT" ]; then
[ "${RCOUNT:-0}" -eq 0 ] \
&& OK "$ctn : RestartCount=$RCOUNT (aucun crash Docker)" \
|| WARN "$ctn : RestartCount=$RCOUNT — le conteneur a redémarré (crash ou OOM), vérifier docker logs $ctn"
fi
done
# Couverture watchdog par récepteur : le script watchdog_readsb.sh ne surveille
# QUE le conteneur dont le nom est en dur dans son propre code (docker restart
# adsb-readsb) — un 2e récepteur ajouté sans adapter ce script n'a AUCUNE
# protection contre un gel silencieux, contrairement à ce que la section
# ci-dessus pourrait laisser penser (elle ne teste que l'existence du cron,
# pas sa couverture réelle de tous les récepteurs).
# Couverture watchdog par récepteur : depuis watchdog_readsb.sh v3, la
# surveillance est pilotée par son propre tableau RECEIVERS interne (plus de
# nom de conteneur en dur) — on vérifie donc que le numéro de série de chaque
# récepteur apparaît bien dans ce tableau, pas un nom de conteneur littéral.
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
if [ -f "$WATCHDOG_SCRIPT" ] && grep -q "detect_dongles.sh" "$WATCHDOG_SCRIPT" 2>/dev/null; then
OK "$RX_CTN protégé par le watchdog (v4 — récepteurs dérivés de detect_dongles.sh)"
elif [ -f "$WATCHDOG_SCRIPT" ] && grep -qE "\"${RX_SERIAL}:${RX_CTN}:" "$WATCHDOG_SCRIPT" 2>/dev/null; then
WARN "$RX_CTN protégé par un watchdog en v3 (tableau RECEIVERS statique) — migrer en v4 pour suivre les dongles réellement présents"
else
WARN "$RX_CTN NON protégé par le watchdog — absent du tableau RECEIVERS de watchdog_readsb.sh, un gel silencieux ne serait jamais corrigé automatiquement"
fi
done
# Dérive NTP : un décalage d'horloge impacte directement le routage vers les
# bonnes partitions (colonne ts) et la clé de dédoublonnage icao+ts+receiver_id
NTP_SYNC=$(timedatectl show -p NTPSynchronized --value 2>/dev/null)
if [ "$NTP_SYNC" = "yes" ]; then
OK "Horloge système synchronisée NTP"
elif [ -n "$NTP_SYNC" ]; then
FAIL "Horloge système NON synchronisée NTP — risque de dérive impactant ts/partitionnement/dédoublonnage"
else
INFO "Statut NTP non déterminable (timedatectl indisponible)"
fi
# ── 7. WEBAPP ─────────────────────────────────────────────────────────────────
HDR "7. Webapp — interface utilisateur"
for action in live kpi trajectories; do
RESP=$(curl -s --max-time 5 \
"http://127.0.0.1:8080/api.php?action=${action}&period=24&granularity=60" 2>/dev/null)
if echo "$RESP" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
ERR=$(echo "$RESP" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('error',''))" 2>/dev/null)
[ -n "$ERR" ] && WARN "api.php?action=${action} : $ERR" \
|| OK "api.php?action=${action} : JSON valide"
else
FAIL "api.php?action=${action} : réponse non-JSON"
fi
done
TAB_CNT=$(docker exec adsb-webapp grep -c 'href="#tab' /var/www/html/index.php 2>/dev/null)
OK "${TAB_CNT:-?} onglets dans index.php"
[ "${TAB_CNT:-0}" -lt 5 ] 2>/dev/null && WARN "Moins de 5 onglets — index.php peut être obsolète"
JS_SZ=$(docker exec adsb-webapp wc -c /var/www/html/js/app.js 2>/dev/null | awk '{print $1}')
INFO "app.js : ${JS_SZ:-?} octets"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://${IP}:8080/" 2>/dev/null)
[ "$HTTP_CODE" = "200" ] \
&& OK "Accès LAN http://${IP}:8080 : HTTP $HTTP_CODE" \
|| WARN "Accès LAN http://${IP}:8080 : HTTP ${HTTP_CODE:-?}"
# ── 8. AUTOGAIN ───────────────────────────────────────────────────────────────
HDR "8. Autogain"
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
CUR_GAIN=$(docker logs "$RX_CTN" 2>&1 | grep "tuner gain set to" | tail -1 | \
grep -oP '[\d.]+(?= dB)')
AG_STATE=$(docker logs "$RX_CTN" 2>&1 | grep "autogain" | \
grep -oP "(?<=state ')[\w]+" | tail -1)
[ -n "$CUR_GAIN" ] \
&& OK "$RX_CTN — Gain : ${CUR_GAIN} dB — état autogain : ${AG_STATE:-inconnu}" \
|| WARN "$RX_CTN — gain courant inconnu"
INSUF=$(docker logs "$RX_CTN" 2>&1 | grep -c "Insufficient messages")
[ "$INSUF" -gt 3 ] \
&& WARN "$RX_CTN — $INSUF cycles avec messages insuffisants (antenne/portée ?)" \
|| INFO "$RX_CTN — $INSUF cycle(s) avec messages insuffisants (autogain en cours)"
done
# ── 9. INFRASTRUCTURE HÔTE ────────────────────────────────────────────────────
HDR "9. Infrastructure hôte"
# Docker version et mode
DOCKER_VER=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
DOCKER_PKG=$(snap list docker 2>/dev/null | awk 'NR==2{print "snap rev."$3}' || echo "package")
INFO "Docker ${DOCKER_VER:-?} ($DOCKER_PKG)"
# bridge-nf-call-iptables (rappel condensé)
BNF=$(cat /proc/sys/net/bridge/bridge-nf-call-iptables 2>/dev/null)
[ "${BNF:-1}" = "0" ] \
&& OK "bridge-nf-call-iptables=0 — ICC Docker OK" \
|| WARN "bridge-nf-call-iptables=${BNF} — peut bloquer la communication inter-conteneurs"
# ip_forward
IPF=$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)
[ "${IPF}" = "1" ] \
&& OK "ip_forward=1 — routage inter-réseaux actif" \
|| FAIL "ip_forward=${IPF:-0} — routage désactivé"
# Espace disque /data et /opt
for mnt in /data /opt /var/snap/docker; do
if mountpoint -q "$mnt" 2>/dev/null || [ -d "$mnt" ]; then
USAGE=$(df -h "$mnt" 2>/dev/null | awk 'NR==2{print $5" utilisé ("$3"/"$2")"}')
PCT=$(df "$mnt" 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5); print $5}')
[ "${PCT:-0}" -gt 85 ] 2>/dev/null \
&& WARN "Disque $mnt : $USAGE" \
|| INFO "Disque $mnt : $USAGE"
fi
done
# Uptime système
UPTIME_SYS=$(uptime -p 2>/dev/null || uptime | sed 's/.*up /up /' | cut -d',' -f1-2)
INFO "Système : $UPTIME_SYS"
# Journaux noyau : resets/déconnexions ciblés sur le dongle RTL-SDR uniquement
# (v3.4 comptait tout événement usb du bus, y compris souris/clavier USB sans
# rapport — corrigé le 05/07/2026 après un faux positif sur un récepteur
# Logitech. Le port du dongle est résolu dynamiquement par idVendor:idProduct
# car il peut changer si débranché/rebranché sur un autre port physique.)
DONGLE_VID_PID="0bda:2838"
DONGLE_BUSDEV=$(lsusb | grep -i "$DONGLE_VID_PID" | awk '{print $2, $4}' | tr -d ':' | head -1)
DONGLE_BUS=$(echo "$DONGLE_BUSDEV" | awk '{print $1}')
DONGLE_DEV=$(echo "$DONGLE_BUSDEV" | awk '{print $2}')
if [ -n "$DONGLE_BUS" ] && [ -n "$DONGLE_DEV" ]; then
DONGLE_SYSPATH=$(udevadm info -q path -n "/dev/bus/usb/${DONGLE_BUS}/${DONGLE_DEV}" 2>/dev/null)
DONGLE_PORT=$(echo "$DONGLE_SYSPATH" | grep -oP '\d+-[\d.]+(?=/|$)' | tail -1)
if [ -n "$DONGLE_PORT" ]; then
DONGLE_USB_EVENTS=$(dmesg -T 2>/dev/null | grep -E "usb ${DONGLE_PORT}:" | grep -ci "reset\|disconnect")
if [ "${DONGLE_USB_EVENTS:-0}" -gt 0 ]; then
WARN "$DONGLE_USB_EVENTS événement(s) reset/déconnexion sur le port du dongle RTL-SDR ($DONGLE_PORT) — corrélation possible avec les blocages watchdog"
else
OK "Aucun reset/déconnexion USB sur le port du dongle RTL-SDR ($DONGLE_PORT) — un blocage ne serait donc pas d'origine matérielle USB"
fi
INFO "Dongle RTL-SDR localisé : port $DONGLE_PORT (bus $DONGLE_BUS device $DONGLE_DEV)"
else
WARN "Port USB du dongle non résolu (syspath: ${DONGLE_SYSPATH:-introuvable})"
fi
else
WARN "Dongle RTL-SDR ($DONGLE_VID_PID) non trouvé via lsusb — vérifier connexion physique"
fi
# Règle udev RTL-SDR
if [ -f /etc/udev/rules.d/99-adsb-docker.rules ]; then
OK "Règle udev 99-adsb-docker.rules présente (relance auto si USB reconnecté)"
else
WARN "Règle udev 99-adsb-docker.rules absente — reconnexion USB nécessite intervention manuelle"
fi
# Service systemd adsb-stack
SVC_STATE=$(systemctl is-active adsb-stack.service 2>/dev/null || echo "absent")
SVC_DIR=$(systemctl show adsb-stack.service -p WorkingDirectory 2>/dev/null | cut -d= -f2)
[ "$SVC_STATE" = "active" ] \
&& OK "adsb-stack.service : $SVC_STATE (WorkingDirectory=$SVC_DIR)" \
|| INFO "adsb-stack.service : $SVC_STATE"
# ── 9b. PARE-FEU ───────────────────────────────────────────────────────────────
HDR "9b. Pare-feu"
FIREWALL_TOOL=""
command -v ufw &>/dev/null && FIREWALL_TOOL="ufw"
command -v firewall-cmd &>/dev/null && FIREWALL_TOOL="firewalld"
if [ -z "$FIREWALL_TOOL" ]; then
INFO "Aucun pare-feu (ufw/firewalld) installé — aucune restriction possible côté hôte"
else
case "$FIREWALL_TOOL" in
ufw)
UFW_ACTIVE=$(ufw status 2>/dev/null | head -1 | grep -qE "^Status:\s*active\s*$" && echo "yes" || echo "no")
if [ "$UFW_ACTIVE" = "no" ]; then
OK "ufw inactif — pas de restriction sur les ports du stack"
else
INFO "ufw actif — vérification des ports du stack"
FW_PORT_LIST=("8080:webapp")
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
FW_PORT_LIST+=("${RX_PORT}:tar1090 ($RX_CTN)")
done
for PORT_DESC in "${FW_PORT_LIST[@]}"; do
PORT="${PORT_DESC%%:*}"; DESC="${PORT_DESC#*:}"
if ufw status 2>/dev/null | grep -qE "^${PORT}(/tcp)?[[:space:]]+ALLOW"; then
OK "Port $PORT ($DESC) autorisé dans ufw"
else
FAIL "Port $PORT ($DESC) NON autorisé dans ufw — inaccessible depuis le LAN (ufw allow ${PORT}/tcp)"
fi
done
fi
;;
firewalld)
FW_STATE=$(firewall-cmd --state 2>/dev/null)
if [ "$FW_STATE" != "running" ]; then
OK "firewalld inactif — pas de restriction sur les ports du stack"
else
INFO "firewalld actif — vérification des ports du stack"
FW_PORT_LIST=("8080:webapp")
for RX in "${RECEIVERS[@]}"; do
IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX"
FW_PORT_LIST+=("${RX_PORT}:tar1090 ($RX_CTN)")
done
for PORT_DESC in "${FW_PORT_LIST[@]}"; do
PORT="${PORT_DESC%%:*}"; DESC="${PORT_DESC#*:}"
if firewall-cmd --list-ports 2>/dev/null | grep -q "${PORT}/tcp"; then
OK "Port $PORT ($DESC) autorisé dans firewalld"
else
FAIL "Port $PORT ($DESC) NON autorisé dans firewalld — inaccessible depuis le LAN"
fi
done
fi
;;
esac
fi
# Test d'accès réel, indépendant du pare-feu détecté (confirme l'accès effectif
# de bout en bout, y compris via des mécanismes non couverts ci-dessus comme
# iptables nu, un pare-feu de box/routeur, ou un binding d'interface incorrect)
HTTP_WEBAPP_LOCAL=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://127.0.0.1:8080/" 2>/dev/null)
[ "$HTTP_WEBAPP_LOCAL" = "200" ] \
&& OK "Webapp accessible en local (127.0.0.1:8080) : HTTP $HTTP_WEBAPP_LOCAL" \
|| WARN "Webapp non accessible en local (127.0.0.1:8080) : HTTP ${HTTP_WEBAPP_LOCAL:-000}"
HTTP_WEBAPP_LAN=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://${IP}:8080/" 2>/dev/null)
[ "$HTTP_WEBAPP_LAN" = "200" ] \
&& OK "Webapp accessible depuis l'IP LAN (${IP}:8080) : HTTP $HTTP_WEBAPP_LAN" \
|| FAIL "Webapp INACCESSIBLE depuis l'IP LAN (${IP}:8080) : HTTP ${HTTP_WEBAPP_LAN:-000} — pare-feu, binding réseau, ou routeur à vérifier"
# ══════════════════════════════════════════════════════════════════════════════
# RÉSUMÉ TERMINAL
# ══════════════════════════════════════════════════════════════════════════════
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ RÉSUMÉ ║${NC}"
echo -e "${BOLD}╠══════════════════════════════════════════════════════╣${NC}"
if [ "$ERRORS" -eq 0 ] && [ "$WARNS" -eq 0 ]; then
GLOBAL_STATUS="OK"; GLOBAL_COLOR="$GREEN"
echo -e "${BOLD}║ ${GREEN}✓ Stack opérationnel — aucune anomalie${NC}${BOLD} ║${NC}"
elif [ "$ERRORS" -eq 0 ]; then
GLOBAL_STATUS="WARN"; GLOBAL_COLOR="$YELLOW"
echo -e "${BOLD}║ ${YELLOW}⚠ Stack opérationnel — $WARNS avertissement(s)${NC}${BOLD} ║${NC}"
else
GLOBAL_STATUS="ERROR"; GLOBAL_COLOR="$RED"
echo -e "${BOLD}║ ${RED}✗ $ERRORS erreur(s), $WARNS avertissement(s)${NC}${BOLD} ║${NC}"
fi
echo -e "${BOLD}║ Dashboard : http://${IP}:8080${NC}${BOLD} ║${NC}"
echo -e "${BOLD}║ tar1090 : http://${IP}:8090${NC}${BOLD} ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}"
# ══════════════════════════════════════════════════════════════════════════════
# GÉNÉRATION HTML
# ══════════════════════════════════════════════════════════════════════════════
case "$GLOBAL_STATUS" in
OK) BADGE_CLS="badge-ok"; BADGE_TXT="✓ Opérationnel" ;;
WARN) BADGE_CLS="badge-warn"; BADGE_TXT="⚠ ${WARNS} avertissement(s)" ;;
ERROR) BADGE_CLS="badge-err"; BADGE_TXT="✗ ${ERRORS} erreur(s)" ;;
esac
cat > "$HTML_OUT" << HTMLEOF
ADS-B Linux-25 — Status
/dev/null && echo ok-kpi || echo warn-kpi)">
${AVIONS:-0}
Avions en vue
/dev/null && echo ok-kpi || echo warn-kpi)">
${WITH_POS:-0}
Avec GPS
${AV_TODAY:-0}
Avions aujourd'hui
${SESS_TODAY:-0}
Sessions aujourd'hui
${CUR_GAIN:-?} dB
Gain SDR
| État | Détail | Statut |
${HTML_ROWS}
HTMLEOF
# Copier dans le répertoire source webapp ET dans le conteneur
SRC_DIR="/data/adsb/webapp/src"
if [ -d "$SRC_DIR" ]; then
cp "$HTML_OUT" "$SRC_DIR/status.html" 2>/dev/null
fi
docker cp "$HTML_OUT" adsb-webapp:/var/www/html/status.html 2>/dev/null && \
echo -e "\n ${GREEN}→${NC} Rapport HTML : http://${IP}:8080/status.html" || \
echo -e "\n ${YELLOW}⚠${NC} Copie Docker échouée — fichier local : $HTML_OUT"
exit $ERRORS