1333 lines
70 KiB
Bash
Executable File
1333 lines
70 KiB
Bash
Executable File
#!/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+="<tr class='section-row'><td colspan='3'><b>$1</b></td></tr>"; }
|
|
h_ok() { HTML_ROWS+="<tr><td class='ic ok'>✓</td><td>$1</td><td class='tag ok'>OK</td></tr>"; }
|
|
h_fail() { HTML_ROWS+="<tr><td class='ic err'>✗</td><td>$1</td><td class='tag err'>ERREUR</td></tr>"; }
|
|
h_warn() { HTML_ROWS+="<tr><td class='ic warn'>⚠</td><td>$1</td><td class='tag warn'>ATTENTION</td></tr>"; }
|
|
h_info() { HTML_ROWS+="<tr><td class='ic inf'>→</td><td colspan='2' class='info-cell'>$1</td></tr>"; }
|
|
|
|
# ── 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" != "<nil>" ] && 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_<serial>). 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
|
|
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<meta http-equiv="refresh" content="60">
|
|
<title>ADS-B Linux-25 — Status</title>
|
|
<style>
|
|
:root {
|
|
--bg:#0d1117; --surface:#161b22; --border:#30363d;
|
|
--text:#c9d1d9; --muted:#8b949e; --ok:#3fb950;
|
|
--warn:#d29922; --err:#f85149; --info:#58a6ff;
|
|
--blue:#1f6feb; --teal:#39d353;
|
|
}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
html,body{height:100%;width:100%}
|
|
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',Arial,sans-serif;
|
|
font-size:14px;padding:16px;min-height:100%;overflow-y:auto}
|
|
h1{color:var(--info);font-size:1.4em;margin-bottom:4px}
|
|
.subtitle{color:var(--muted);font-size:.85em;margin-bottom:20px}
|
|
.header{display:flex;justify-content:space-between;align-items:flex-start;
|
|
flex-wrap:wrap;gap:12px;margin-bottom:24px;
|
|
padding:16px;background:var(--surface);border-radius:8px;
|
|
border:1px solid var(--border)}
|
|
.header-left h1{margin:0}
|
|
.kpi-row{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:24px}
|
|
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:8px;
|
|
padding:12px 18px;min-width:130px;text-align:center}
|
|
.kpi .val{font-size:1.8em;font-weight:700;color:var(--info)}
|
|
.kpi .lbl{font-size:.75em;color:var(--muted);margin-top:2px}
|
|
.kpi.ok-kpi .val{color:var(--ok)}
|
|
.kpi.warn-kpi .val{color:var(--warn)}
|
|
.kpi.err-kpi .val{color:var(--err)}
|
|
.badge{display:inline-block;padding:6px 16px;border-radius:20px;
|
|
font-weight:700;font-size:.95em}
|
|
.badge-ok {background:rgba(63,185,80,.15);color:var(--ok);border:1px solid var(--ok)}
|
|
.badge-warn{background:rgba(210,153,34,.15);color:var(--warn);border:1px solid var(--warn)}
|
|
.badge-err {background:rgba(248,81,73,.15);color:var(--err);border:1px solid var(--err)}
|
|
.links{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:20px}
|
|
.links a{background:var(--blue);color:#fff;padding:6px 14px;border-radius:6px;
|
|
text-decoration:none;font-size:.85em}
|
|
.links a:hover{opacity:.85}
|
|
table{width:100%;border-collapse:collapse;background:var(--surface);
|
|
border:1px solid var(--border);border-radius:8px;overflow:hidden}
|
|
th{background:#1c2128;color:var(--muted);font-weight:600;text-align:left;
|
|
padding:8px 12px;font-size:.8em;text-transform:uppercase;letter-spacing:.5px}
|
|
td{padding:7px 12px;border-top:1px solid var(--border);vertical-align:middle}
|
|
tr:hover td{background:rgba(255,255,255,.03)}
|
|
.section-row td{background:#1c2128;color:var(--info);font-size:.82em;
|
|
text-transform:uppercase;letter-spacing:.8px;padding:6px 12px}
|
|
.ic{width:28px;text-align:center;font-size:1.1em}
|
|
.ic.ok {color:var(--ok)}
|
|
.ic.err {color:var(--err)}
|
|
.ic.warn{color:var(--warn)}
|
|
.ic.inf {color:var(--info)}
|
|
.tag{width:90px;text-align:center}
|
|
.tag{font-size:.75em;font-weight:700;padding:2px 8px;border-radius:12px}
|
|
.tag.ok {background:rgba(63,185,80,.15);color:var(--ok)}
|
|
.tag.err {background:rgba(248,81,73,.15);color:var(--err)}
|
|
.tag.warn{background:rgba(210,153,34,.15);color:var(--warn)}
|
|
.info-cell{color:var(--muted);font-size:.88em;font-style:italic}
|
|
.footer{margin-top:16px;color:var(--muted);font-size:.78em;text-align:right}
|
|
@media(max-width:600px){.kpi{min-width:100px}.header{flex-direction:column}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<h1>✈ ADS-B Linux-25 — Status</h1>
|
|
<div class="subtitle">Généré le $NOW_DISP — actualisation auto toutes les 60s</div>
|
|
<div style="margin-top:8px"><span class="badge ${BADGE_CLS}">${BADGE_TXT}</span></div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:.85em;color:var(--muted)">IP : <b style="color:var(--text)">${IP}</b></div>
|
|
<div style="font-size:.85em;color:var(--muted);margin-top:4px">
|
|
Erreurs : <b style="color:var(--err)">${ERRORS}</b>
|
|
Avert. : <b style="color:var(--warn)">${WARNS}</b>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="kpi-row">
|
|
<div class="kpi $([ "${AVIONS:-0}" -gt 0 ] 2>/dev/null && echo ok-kpi || echo warn-kpi)">
|
|
<div class="val">${AVIONS:-0}</div><div class="lbl">Avions en vue</div></div>
|
|
<div class="kpi $([ "${WITH_POS:-0}" -gt 0 ] 2>/dev/null && echo ok-kpi || echo warn-kpi)">
|
|
<div class="val">${WITH_POS:-0}</div><div class="lbl">Avec GPS</div></div>
|
|
<div class="kpi">
|
|
<div class="val" style="color:var(--teal)">${AV_TODAY:-0}</div>
|
|
<div class="lbl">Avions aujourd'hui</div></div>
|
|
<div class="kpi">
|
|
<div class="val">${SESS_TODAY:-0}</div><div class="lbl">Sessions aujourd'hui</div></div>
|
|
<div class="kpi">
|
|
<div class="val" style="font-size:1.2em">${DB_SIZE:-?}</div>
|
|
<div class="lbl">Base adsb</div></div>
|
|
<div class="kpi">
|
|
<div class="val" style="font-size:1.2em">${CUR_GAIN:-?} dB</div>
|
|
<div class="lbl">Gain SDR</div></div>
|
|
</div>
|
|
|
|
<div class="links">
|
|
<a href="http://${IP}:8080" target="_blank">🖥 Dashboard</a>
|
|
<a href="http://${IP}:8090" target="_blank">🗺 tar1090</a>
|
|
<a href="http://${IP}:8080/api.php?action=kpi&period=24&granularity=60" target="_blank">📊 API KPI</a>
|
|
<a href="http://${IP}:8080/api.php?action=live&period=24&granularity=60" target="_blank">📡 API Live</a>
|
|
</div>
|
|
|
|
<table>
|
|
<thead><tr><th>État</th><th>Détail</th><th>Statut</th></tr></thead>
|
|
<tbody>
|
|
${HTML_ROWS}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="footer">
|
|
ADS-B Linux-25 — check_adsb.sh v3.7 — JCA 2026 |
|
|
Prochain refresh dans <span id="ctr">60</span>s
|
|
</div>
|
|
<script>
|
|
let t=60;
|
|
setInterval(()=>{ t--; document.getElementById('ctr').textContent=t;
|
|
if(t<=0) location.reload(); },1000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
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
|